Chat: Add Markdown support
Using RegEx because It's easier. We will change to another solution if this proves to be a problem.jocadbz
parent
30f7cc7b82
commit
7b934e00a6
|
@ -82,6 +82,12 @@ func init() {
|
|||
go hub.Run()
|
||||
}
|
||||
|
||||
type IncomingChatMessage struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
ReplyTo int `json:"replyTo"`
|
||||
}
|
||||
|
||||
func ChatHandler(app *App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
session := r.Context().Value("session").(*sessions.Session)
|
||||
|
@ -158,32 +164,26 @@ func ChatHandler(app *App) http.HandlerFunc {
|
|||
log.Printf("Error reading WebSocket message: %v", err)
|
||||
break
|
||||
}
|
||||
var chatMsg struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
ReplyTo int `json:"replyTo"`
|
||||
}
|
||||
var chatMsg IncomingChatMessage
|
||||
if err := json.Unmarshal(msg, &chatMsg); err != nil {
|
||||
log.Printf("Error unmarshaling message: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if chatMsg.Type == "message" {
|
||||
msgObj := models.ChatMessage{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
Content: chatMsg.Content,
|
||||
ReplyTo: chatMsg.ReplyTo,
|
||||
}
|
||||
if err := models.CreateChatMessage(app.DB, msgObj); err != nil {
|
||||
if err := models.CreateChatMessage(app.DB, boardID, userID, chatMsg.Content, chatMsg.ReplyTo); err != nil {
|
||||
log.Printf("Error saving chat message: %v", err)
|
||||
continue
|
||||
}
|
||||
var msgID int
|
||||
app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&msgID)
|
||||
err = app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&msgID)
|
||||
if err != nil {
|
||||
log.Printf("Error getting last insert id: %v", err)
|
||||
continue
|
||||
}
|
||||
savedMsg, err := models.GetChatMessageByID(app.DB, msgID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching saved message: %v", err)
|
||||
log.Printf("Error fetching saved message for broadcast: %v", err)
|
||||
continue
|
||||
}
|
||||
hub.broadcast <- *savedMsg
|
||||
|
|
128
models/chat.go
128
models/chat.go
|
@ -2,7 +2,11 @@ package models
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
@ -10,7 +14,7 @@ type ChatMessage struct {
|
|||
ID int `json:"id"`
|
||||
BoardID int `json:"boardId"`
|
||||
UserID int `json:"userId"`
|
||||
Content string `json:"content"`
|
||||
Content template.HTML `json:"content"`
|
||||
ReplyTo int `json:"replyTo"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Username string `json:"username"`
|
||||
|
@ -18,9 +22,9 @@ type ChatMessage struct {
|
|||
Mentions []string `json:"mentions"`
|
||||
}
|
||||
|
||||
func CreateChatMessage(db *sql.DB, msg ChatMessage) error {
|
||||
func CreateChatMessage(db *sql.DB, boardID, userID int, rawContent string, replyTo int) error {
|
||||
query := "INSERT INTO chat_messages (board_id, user_id, content, reply_to, timestamp) VALUES (?, ?, ?, ?, NOW())"
|
||||
_, err := db.Exec(query, msg.BoardID, msg.UserID, msg.Content, msg.ReplyTo)
|
||||
_, err := db.Exec(query, boardID, userID, rawContent, replyTo)
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -42,7 +46,8 @@ func GetRecentChatMessages(db *sql.DB, boardID int, limit int) ([]ChatMessage, e
|
|||
for rows.Next() {
|
||||
var msg ChatMessage
|
||||
var timestampStr string
|
||||
err := rows.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, ×tampStr, &msg.Username, &msg.PfpFileID)
|
||||
var rawContent string
|
||||
err := rows.Scan(&msg.ID, &msg.UserID, &rawContent, &msg.ReplyTo, ×tampStr, &msg.Username, &msg.PfpFileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -51,7 +56,8 @@ func GetRecentChatMessages(db *sql.DB, boardID int, limit int) ([]ChatMessage, e
|
|||
if err != nil {
|
||||
msg.Timestamp = time.Time{}
|
||||
}
|
||||
msg.Mentions = extractMentions(msg.Content)
|
||||
msg.Content = renderMarkdown(rawContent)
|
||||
msg.Mentions = extractMentions(rawContent)
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
return messages, nil
|
||||
|
@ -66,7 +72,8 @@ func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
|
|||
row := db.QueryRow(query, id)
|
||||
var msg ChatMessage
|
||||
var timestampStr string
|
||||
err := row.Scan(&msg.ID, &msg.BoardID, &msg.UserID, &msg.Content, &msg.ReplyTo, ×tampStr, &msg.Username, &msg.PfpFileID)
|
||||
var rawContent string
|
||||
err := row.Scan(&msg.ID, &msg.BoardID, &msg.UserID, &rawContent, &msg.ReplyTo, ×tampStr, &msg.Username, &msg.PfpFileID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
|
@ -77,11 +84,11 @@ func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
|
|||
if err != nil {
|
||||
msg.Timestamp = time.Time{}
|
||||
}
|
||||
msg.Mentions = extractMentions(msg.Content)
|
||||
msg.Content = renderMarkdown(rawContent)
|
||||
msg.Mentions = extractMentions(rawContent)
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
// Simple utility to extract mentions from content
|
||||
func extractMentions(content string) []string {
|
||||
re := regexp.MustCompile(`@(\w+)`)
|
||||
matches := re.FindAllStringSubmatch(content, -1)
|
||||
|
@ -90,4 +97,109 @@ func extractMentions(content string) []string {
|
|||
mentions[i] = match[1]
|
||||
}
|
||||
return mentions
|
||||
}
|
||||
|
||||
func processInlineMarkdown(line string) string {
|
||||
line = regexp.MustCompile("`([^`]+)`").ReplaceAllString(line, "<code>$1</code>")
|
||||
line = regexp.MustCompile(`\*\*([^\*]+)\*\*`).ReplaceAllString(line, "<strong>$1</strong>")
|
||||
line = regexp.MustCompile(`__([^_]+)__`).ReplaceAllString(line, "<strong>$1</strong>")
|
||||
line = regexp.MustCompile(`\*([^\*]+)\*`).ReplaceAllString(line, "<em>$1</em>")
|
||||
line = regexp.MustCompile(`_([^_]+)_`).ReplaceAllString(line, "<em>$1</em>")
|
||||
line = regexp.MustCompile(`@(\w+)`).ReplaceAllString(line, `<span class="chat-message-mention">@$1</span>`)
|
||||
return line
|
||||
}
|
||||
|
||||
func renderMarkdown(content string) template.HTML {
|
||||
var sb strings.Builder
|
||||
|
||||
codeBlockRegex := regexp.MustCompile("(?s)```(\\w*)\n(.*?)\n```")
|
||||
codeBlocks := make(map[string]string)
|
||||
codeBlockCounter := 0
|
||||
|
||||
contentWithoutCodeBlocks := codeBlockRegex.ReplaceAllStringFunc(content, func(match string) string {
|
||||
submatches := codeBlockRegex.FindStringSubmatch(match)
|
||||
|
||||
lang := submatches[1]
|
||||
codeContent := submatches[2]
|
||||
|
||||
escapedCode := html.EscapeString(codeContent)
|
||||
var renderedCodeBlock string
|
||||
if lang != "" {
|
||||
renderedCodeBlock = fmt.Sprintf("<pre><code class=\"language-%s\">%s</code></pre>", lang, escapedCode)
|
||||
} else {
|
||||
renderedCodeBlock = fmt.Sprintf("<pre><code>%s</code></pre>", escapedCode)
|
||||
}
|
||||
|
||||
placeholder := fmt.Sprintf("<!--CODEBLOCK_%d-->", codeBlockCounter)
|
||||
codeBlocks[placeholder] = renderedCodeBlock
|
||||
codeBlockCounter++
|
||||
return placeholder
|
||||
})
|
||||
|
||||
lines := strings.Split(contentWithoutCodeBlocks, "\n")
|
||||
|
||||
inList := false
|
||||
|
||||
for _, line := range lines {
|
||||
trimmedLine := strings.TrimSpace(line)
|
||||
|
||||
if strings.HasPrefix(trimmedLine, "### ") {
|
||||
if inList { sb.WriteString("</ul>\n"); inList = false }
|
||||
sb.WriteString("<h3>")
|
||||
sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "### ")))
|
||||
sb.WriteString("</h3>\n")
|
||||
continue
|
||||
} else if strings.HasPrefix(trimmedLine, "## ") {
|
||||
if inList { sb.WriteString("</ul>\n"); inList = false }
|
||||
sb.WriteString("<h2>")
|
||||
sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "## ")))
|
||||
sb.WriteString("</h2>\n")
|
||||
continue
|
||||
} else if strings.HasPrefix(trimmedLine, "# ") {
|
||||
if inList { sb.WriteString("</ul>\n"); inList = false }
|
||||
sb.WriteString("<h1>")
|
||||
sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "# ")))
|
||||
sb.WriteString("</h1>\n")
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmedLine, "* ") || strings.HasPrefix(trimmedLine, "- ") {
|
||||
if !inList {
|
||||
sb.WriteString("<ul>\n")
|
||||
inList = true
|
||||
}
|
||||
listItemContent := strings.TrimPrefix(strings.TrimPrefix(trimmedLine, "* "), "- ")
|
||||
sb.WriteString("<li>")
|
||||
sb.WriteString(processInlineMarkdown(listItemContent))
|
||||
sb.WriteString("</li>\n")
|
||||
continue
|
||||
}
|
||||
|
||||
if inList {
|
||||
sb.WriteString("</ul>\n")
|
||||
inList = false
|
||||
}
|
||||
|
||||
if trimmedLine != "" {
|
||||
sb.WriteString("<p>")
|
||||
sb.WriteString(processInlineMarkdown(trimmedLine))
|
||||
sb.WriteString("</p>\n")
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if inList {
|
||||
sb.WriteString("</ul>\n")
|
||||
}
|
||||
|
||||
finalContent := sb.String()
|
||||
|
||||
for placeholder, blockHTML := range codeBlocks {
|
||||
finalContent = strings.ReplaceAll(finalContent, placeholder, blockHTML)
|
||||
}
|
||||
|
||||
finalContent = regexp.MustCompile(`\n{3,}`).ReplaceAllString(finalContent, "\n\n")
|
||||
|
||||
return template.HTML(finalContent)
|
||||
}
|
|
@ -275,9 +275,9 @@
|
|||
{{if gt .ReplyTo 0}}
|
||||
<div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to message...</div>
|
||||
{{end}}
|
||||
<div class="chat-message-content">{{.Content | html}}</div>
|
||||
<div class="chat-message-content">{{.Content}}</div>
|
||||
<div class="post-actions">
|
||||
<a href="javascript:void(0)" onclick="replyToMessage({{.ID}}, '{{.Username}}')">Reply</a>
|
||||
<a href="javascript:void(0)" onclick="replyToMessage({{printf "%v" .ID}}, '{{.Username}}')">Reply</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
@ -299,7 +299,7 @@
|
|||
let autocompleteActive = false;
|
||||
let replyToId = -1;
|
||||
const allUsernames = {{.AllUsernames}};
|
||||
const currentUsername = "{{.CurrentUsername}}"; // Get current user's username
|
||||
const currentUsername = "{{.CurrentUsername}}";
|
||||
|
||||
function connectWebSocket() {
|
||||
const boardID = {{.Board.ID}};
|
||||
|
@ -349,7 +349,6 @@
|
|||
pfpHTML = `<img src="{{.BasePath}}/file?id=${msg.pfpFileId.Int64}&t=${new Date().getTime()}" alt="PFP" class="chat-message-pfp">`;
|
||||
}
|
||||
let replyHTML = msg.replyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.replyTo})">Replying to message...</div>` : '';
|
||||
let content = msg.content.replace(/@(\w+)/g, '<span class="chat-message-mention">@$1</span>');
|
||||
|
||||
msgDiv.innerHTML = `
|
||||
<div class="chat-message-header">
|
||||
|
@ -358,7 +357,7 @@
|
|||
<span class="chat-message-timestamp">${new Date(msg.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
${replyHTML}
|
||||
<div class="chat-message-content">${content}</div>
|
||||
<div class="chat-message-content">${msg.content}</div>
|
||||
<div class="post-actions">
|
||||
<a href="javascript:void(0)" onclick="replyToMessage(${msg.id}, '${msg.username}')">Reply</a>
|
||||
</div>
|
||||
|
@ -433,12 +432,9 @@
|
|||
|
||||
function getCaretCoordinates(element, position) {
|
||||
const mirrorDivId = 'input-mirror-div';
|
||||
let div = document.getElementById(mirrorDivId);
|
||||
if (!div) {
|
||||
div = document.createElement('div');
|
||||
div.id = mirrorDivId;
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
let div = document.createElement('div');
|
||||
div.id = mirrorDivId;
|
||||
document.body.appendChild(div);
|
||||
const style = window.getComputedStyle(element);
|
||||
const properties = ['border', 'boxSizing', 'fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight', 'padding', 'textDecoration', 'textIndent', 'textTransform', 'whiteSpace', 'wordSpacing', 'wordWrap', 'width'];
|
||||
properties.forEach(prop => { div.style[prop] = style[prop]; });
|
||||
|
@ -451,7 +447,9 @@
|
|||
span.textContent = element.value.substring(position) || '.';
|
||||
div.appendChild(span);
|
||||
|
||||
return { top: span.offsetTop, left: span.offsetLeft };
|
||||
const coords = { top: span.offsetTop, left: span.offsetLeft };
|
||||
document.body.removeChild(div);
|
||||
return coords;
|
||||
}
|
||||
|
||||
document.getElementById('chat-input-text').addEventListener('input', (e) => {
|
||||
|
@ -499,14 +497,6 @@
|
|||
|
||||
window.onload = function() {
|
||||
connectWebSocket();
|
||||
|
||||
// This part is now handled by the server-side rendering with the `chat-message-highlighted` class
|
||||
// document.querySelectorAll('.chat-message-content').forEach(function(el) {
|
||||
// const text = el.innerHTML;
|
||||
// const newHTML = text.replace(/@(\w+)/g, '<span class="chat-message-mention">@$1</span>');
|
||||
// el.innerHTML = newHTML;
|
||||
// });
|
||||
|
||||
document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight;
|
||||
};
|
||||
</script>
|
||||
|
|
Loading…
Reference in New Issue