Chat: Add Markdown support

Using RegEx because It's easier.
We will change to another solution if this proves to be a problem.
jocadbz
Joca 2025-09-05 22:09:17 -03:00
parent 30f7cc7b82
commit 7b934e00a6
Signed by: jocadbz
GPG Key ID: B1836DCE2F50BDF7
3 changed files with 144 additions and 42 deletions

View File

@ -82,6 +82,12 @@ func init() {
go hub.Run() go hub.Run()
} }
type IncomingChatMessage struct {
Type string `json:"type"`
Content string `json:"content"`
ReplyTo int `json:"replyTo"`
}
func ChatHandler(app *App) http.HandlerFunc { func ChatHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session) 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) log.Printf("Error reading WebSocket message: %v", err)
break break
} }
var chatMsg struct { var chatMsg IncomingChatMessage
Type string `json:"type"`
Content string `json:"content"`
ReplyTo int `json:"replyTo"`
}
if err := json.Unmarshal(msg, &chatMsg); err != nil { if err := json.Unmarshal(msg, &chatMsg); err != nil {
log.Printf("Error unmarshaling message: %v", err) log.Printf("Error unmarshaling message: %v", err)
continue continue
} }
if chatMsg.Type == "message" { if chatMsg.Type == "message" {
msgObj := models.ChatMessage{ if err := models.CreateChatMessage(app.DB, boardID, userID, chatMsg.Content, chatMsg.ReplyTo); err != nil {
BoardID: boardID,
UserID: userID,
Content: chatMsg.Content,
ReplyTo: chatMsg.ReplyTo,
}
if err := models.CreateChatMessage(app.DB, msgObj); err != nil {
log.Printf("Error saving chat message: %v", err) log.Printf("Error saving chat message: %v", err)
continue continue
} }
var msgID int 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) savedMsg, err := models.GetChatMessageByID(app.DB, msgID)
if err != nil { if err != nil {
log.Printf("Error fetching saved message: %v", err) log.Printf("Error fetching saved message for broadcast: %v", err)
continue continue
} }
hub.broadcast <- *savedMsg hub.broadcast <- *savedMsg

View File

@ -2,7 +2,11 @@ package models
import ( import (
"database/sql" "database/sql"
"fmt"
"html"
"html/template"
"regexp" "regexp"
"strings"
"time" "time"
) )
@ -10,7 +14,7 @@ type ChatMessage struct {
ID int `json:"id"` ID int `json:"id"`
BoardID int `json:"boardId"` BoardID int `json:"boardId"`
UserID int `json:"userId"` UserID int `json:"userId"`
Content string `json:"content"` Content template.HTML `json:"content"`
ReplyTo int `json:"replyTo"` ReplyTo int `json:"replyTo"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
Username string `json:"username"` Username string `json:"username"`
@ -18,9 +22,9 @@ type ChatMessage struct {
Mentions []string `json:"mentions"` 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())" 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 return err
} }
@ -42,7 +46,8 @@ func GetRecentChatMessages(db *sql.DB, boardID int, limit int) ([]ChatMessage, e
for rows.Next() { for rows.Next() {
var msg ChatMessage var msg ChatMessage
var timestampStr string var timestampStr string
err := rows.Scan(&msg.ID, &msg.UserID, &msg.Content, &msg.ReplyTo, &timestampStr, &msg.Username, &msg.PfpFileID) var rawContent string
err := rows.Scan(&msg.ID, &msg.UserID, &rawContent, &msg.ReplyTo, &timestampStr, &msg.Username, &msg.PfpFileID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -51,7 +56,8 @@ func GetRecentChatMessages(db *sql.DB, boardID int, limit int) ([]ChatMessage, e
if err != nil { if err != nil {
msg.Timestamp = time.Time{} msg.Timestamp = time.Time{}
} }
msg.Mentions = extractMentions(msg.Content) msg.Content = renderMarkdown(rawContent)
msg.Mentions = extractMentions(rawContent)
messages = append(messages, msg) messages = append(messages, msg)
} }
return messages, nil return messages, nil
@ -66,7 +72,8 @@ func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
row := db.QueryRow(query, id) row := db.QueryRow(query, id)
var msg ChatMessage var msg ChatMessage
var timestampStr string var timestampStr string
err := row.Scan(&msg.ID, &msg.BoardID, &msg.UserID, &msg.Content, &msg.ReplyTo, &timestampStr, &msg.Username, &msg.PfpFileID) var rawContent string
err := row.Scan(&msg.ID, &msg.BoardID, &msg.UserID, &rawContent, &msg.ReplyTo, &timestampStr, &msg.Username, &msg.PfpFileID)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@ -77,11 +84,11 @@ func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
if err != nil { if err != nil {
msg.Timestamp = time.Time{} msg.Timestamp = time.Time{}
} }
msg.Mentions = extractMentions(msg.Content) msg.Content = renderMarkdown(rawContent)
msg.Mentions = extractMentions(rawContent)
return &msg, nil return &msg, nil
} }
// Simple utility to extract mentions from content
func extractMentions(content string) []string { func extractMentions(content string) []string {
re := regexp.MustCompile(`@(\w+)`) re := regexp.MustCompile(`@(\w+)`)
matches := re.FindAllStringSubmatch(content, -1) matches := re.FindAllStringSubmatch(content, -1)
@ -91,3 +98,108 @@ func extractMentions(content string) []string {
} }
return mentions 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)
}

View File

@ -275,9 +275,9 @@
{{if gt .ReplyTo 0}} {{if gt .ReplyTo 0}}
<div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to message...</div> <div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to message...</div>
{{end}} {{end}}
<div class="chat-message-content">{{.Content | html}}</div> <div class="chat-message-content">{{.Content}}</div>
<div class="post-actions"> <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>
</div> </div>
{{end}} {{end}}
@ -299,7 +299,7 @@
let autocompleteActive = false; let autocompleteActive = false;
let replyToId = -1; let replyToId = -1;
const allUsernames = {{.AllUsernames}}; const allUsernames = {{.AllUsernames}};
const currentUsername = "{{.CurrentUsername}}"; // Get current user's username const currentUsername = "{{.CurrentUsername}}";
function connectWebSocket() { function connectWebSocket() {
const boardID = {{.Board.ID}}; 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">`; 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 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 = ` msgDiv.innerHTML = `
<div class="chat-message-header"> <div class="chat-message-header">
@ -358,7 +357,7 @@
<span class="chat-message-timestamp">${new Date(msg.timestamp).toLocaleString()}</span> <span class="chat-message-timestamp">${new Date(msg.timestamp).toLocaleString()}</span>
</div> </div>
${replyHTML} ${replyHTML}
<div class="chat-message-content">${content}</div> <div class="chat-message-content">${msg.content}</div>
<div class="post-actions"> <div class="post-actions">
<a href="javascript:void(0)" onclick="replyToMessage(${msg.id}, '${msg.username}')">Reply</a> <a href="javascript:void(0)" onclick="replyToMessage(${msg.id}, '${msg.username}')">Reply</a>
</div> </div>
@ -433,12 +432,9 @@
function getCaretCoordinates(element, position) { function getCaretCoordinates(element, position) {
const mirrorDivId = 'input-mirror-div'; const mirrorDivId = 'input-mirror-div';
let div = document.getElementById(mirrorDivId); let div = document.createElement('div');
if (!div) {
div = document.createElement('div');
div.id = mirrorDivId; div.id = mirrorDivId;
document.body.appendChild(div); document.body.appendChild(div);
}
const style = window.getComputedStyle(element); const style = window.getComputedStyle(element);
const properties = ['border', 'boxSizing', 'fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight', 'padding', 'textDecoration', 'textIndent', 'textTransform', 'whiteSpace', 'wordSpacing', 'wordWrap', 'width']; 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]; }); properties.forEach(prop => { div.style[prop] = style[prop]; });
@ -451,7 +447,9 @@
span.textContent = element.value.substring(position) || '.'; span.textContent = element.value.substring(position) || '.';
div.appendChild(span); 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) => { document.getElementById('chat-input-text').addEventListener('input', (e) => {
@ -499,14 +497,6 @@
window.onload = function() { window.onload = function() {
connectWebSocket(); 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; document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight;
}; };
</script> </script>