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()
|
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
|
||||||
|
|
128
models/chat.go
128
models/chat.go
|
@ -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, ×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 {
|
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, ×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 {
|
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)
|
||||||
|
@ -90,4 +97,109 @@ func extractMentions(content string) []string {
|
||||||
mentions[i] = match[1]
|
mentions[i] = match[1]
|
||||||
}
|
}
|
||||||
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)
|
||||||
}
|
}
|
|
@ -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.id = mirrorDivId;
|
||||||
div = document.createElement('div');
|
document.body.appendChild(div);
|
||||||
div.id = mirrorDivId;
|
|
||||||
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>
|
||||||
|
|
Loading…
Reference in New Issue