diff --git a/handlers/chat.go b/handlers/chat.go index 954efc5..e9210a9 100644 --- a/handlers/chat.go +++ b/handlers/chat.go @@ -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 diff --git a/models/chat.go b/models/chat.go index 8bcdb8f..97301dc 100644 --- a/models/chat.go +++ b/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, "$1") + line = regexp.MustCompile(`\*\*([^\*]+)\*\*`).ReplaceAllString(line, "$1") + line = regexp.MustCompile(`__([^_]+)__`).ReplaceAllString(line, "$1") + line = regexp.MustCompile(`\*([^\*]+)\*`).ReplaceAllString(line, "$1") + line = regexp.MustCompile(`_([^_]+)_`).ReplaceAllString(line, "$1") + line = regexp.MustCompile(`@(\w+)`).ReplaceAllString(line, `@$1`) + 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("
%s
", lang, escapedCode) + } else { + renderedCodeBlock = fmt.Sprintf("
%s
", escapedCode) + } + + placeholder := fmt.Sprintf("", 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("\n"); inList = false } + sb.WriteString("

") + sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "### "))) + sb.WriteString("

\n") + continue + } else if strings.HasPrefix(trimmedLine, "## ") { + if inList { sb.WriteString("\n"); inList = false } + sb.WriteString("

") + sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "## "))) + sb.WriteString("

\n") + continue + } else if strings.HasPrefix(trimmedLine, "# ") { + if inList { sb.WriteString("\n"); inList = false } + sb.WriteString("

") + sb.WriteString(processInlineMarkdown(strings.TrimPrefix(trimmedLine, "# "))) + sb.WriteString("

\n") + continue + } + + if strings.HasPrefix(trimmedLine, "* ") || strings.HasPrefix(trimmedLine, "- ") { + if !inList { + sb.WriteString("\n") + inList = false + } + + if trimmedLine != "" { + sb.WriteString("

") + sb.WriteString(processInlineMarkdown(trimmedLine)) + sb.WriteString("

\n") + } else { + sb.WriteString("\n") + } + } + + if inList { + sb.WriteString("\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) } \ No newline at end of file diff --git a/templates/pages/chat.html b/templates/pages/chat.html index 938e2c9..e1a44f7 100644 --- a/templates/pages/chat.html +++ b/templates/pages/chat.html @@ -275,9 +275,9 @@ {{if gt .ReplyTo 0}}
Replying to message...
{{end}} -
{{.Content | html}}
+
{{.Content}}
- Reply + Reply
{{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 = `PFP`; } let replyHTML = msg.replyTo > 0 ? `
Replying to message...
` : ''; - let content = msg.content.replace(/@(\w+)/g, '@$1'); msgDiv.innerHTML = `
@@ -358,7 +357,7 @@ ${new Date(msg.timestamp).toLocaleString()}
${replyHTML} -
${content}
+
${msg.content}
Reply
@@ -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, '@$1'); - // el.innerHTML = newHTML; - // }); - document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight; };