diff --git a/handlers/chat.go b/handlers/chat.go index ce229c1..f907ed3 100644 --- a/handlers/chat.go +++ b/handlers/chat.go @@ -6,6 +6,10 @@ import ( "net/http" "sync" "threadr/models" + "html/template" + "regexp" + "time" + "github.com/gorilla/sessions" "github.com/gorilla/websocket" ) @@ -69,6 +73,27 @@ func init() { go hub.Run() } +// ChatDisplayMessage is used to pass chat message data to the template, +// with content pre-formatted for HTML rendering. +type ChatDisplayMessage struct { + ID int + UserID int + Content string // Raw content for JS processing if needed, maybe not + FormattedContent template.HTML // Content with mentions as HTML spans + ReplyTo int + Timestamp time.Time + Username string + PfpURL string +} + +// formatChatMessageContent takes raw message content and wraps @mentions in HTML spans. +func formatChatMessageContent(content string) template.HTML { + // Regex to find @username. $0 will be replaced with the entire match (e.g., @username) + r := regexp.MustCompile(`@([a-zA-Z0-9_]+)`) + formatted := r.ReplaceAllString(content, `$0`) + return template.HTML(formatted) +} + func ChatHandler(app *App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*sessions.Session) @@ -117,19 +142,34 @@ func ChatHandler(app *App) http.HandlerFunc { Content: chatMsg.Content, ReplyTo: chatMsg.ReplyTo, } - if err := models.CreateChatMessage(app.DB, msgObj); err != nil { + // CreateChatMessage now also handles notifications and returns the new message ID + newMsgID, err := models.CreateChatMessage(app.DB, msgObj) + if err != nil { log.Printf("Error saving chat message: %v", err) continue } // Fetch the saved message with timestamp and user details - var msgID int - app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&msgID) - savedMsg, err := models.GetChatMessageByID(app.DB, msgID) + savedMsg, err := models.GetChatMessageByID(app.DB, newMsgID) if err != nil { log.Printf("Error fetching saved message: %v", err) continue } - response, _ := json.Marshal(savedMsg) + if savedMsg == nil { + log.Printf("Error: saved message with ID %d not found after creation", newMsgID) + continue + } + // Prepare ChatDisplayMessage for broadcast + displayMsg := ChatDisplayMessage{ + ID: savedMsg.ID, + UserID: savedMsg.UserID, + Content: savedMsg.Content, // Keep raw content if needed by JS, or remove + FormattedContent: formatChatMessageContent(savedMsg.Content), + ReplyTo: savedMsg.ReplyTo, + Timestamp: savedMsg.Timestamp, + Username: savedMsg.Username, + PfpURL: savedMsg.PfpURL, + } + response, _ := json.Marshal(displayMsg) hub.broadcast <- response } } @@ -152,21 +192,35 @@ func ChatHandler(app *App) http.HandlerFunc { } // Render chat page - messages, err := models.GetRecentChatMessages(app.DB, 50) + rawMessages, err := models.GetRecentChatMessages(app.DB, 50) if err != nil { log.Printf("Error fetching chat messages: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } + var displayMessages []ChatDisplayMessage + for _, msg := range rawMessages { + displayMessages = append(displayMessages, ChatDisplayMessage{ + ID: msg.ID, + UserID: msg.UserID, + Content: msg.Content, // Raw content + FormattedContent: formatChatMessageContent(msg.Content), + ReplyTo: msg.ReplyTo, + Timestamp: msg.Timestamp, + Username: msg.Username, + PfpURL: msg.PfpURL, + }) + } + // Reverse messages to show oldest first - for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { - messages[i], messages[j] = messages[j], messages[i] + for i, j := 0, len(displayMessages)-1; i < j; i, j = i+1, j-1 { + displayMessages[i], displayMessages[j] = displayMessages[j], displayMessages[i] } data := struct { PageData - Messages []models.ChatMessage + Messages []ChatDisplayMessage }{ PageData: PageData{ Title: "ThreadR - Chat", @@ -177,7 +231,7 @@ func ChatHandler(app *App) http.HandlerFunc { StaticPath: app.Config.ThreadrDir + "/static", CurrentURL: r.URL.Path, }, - Messages: messages, + Messages: displayMessages, } if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil { log.Printf("Error executing template in ChatHandler: %v", err) diff --git a/models/chat.go b/models/chat.go index 7e33565..0814b74 100644 --- a/models/chat.go +++ b/models/chat.go @@ -3,6 +3,8 @@ package models import ( "database/sql" "time" + "log" + "regexp" ) type ChatMessage struct { @@ -13,13 +15,64 @@ type ChatMessage struct { Timestamp time.Time Username string // For display, fetched from user PfpURL string // For display, fetched from user - Mentions []string // List of mentioned usernames + Mentions []string // List of mentioned usernames (not directly used for display, but for notifications) } -func CreateChatMessage(db *sql.DB, msg ChatMessage) error { +// extractMentions finds all @username mentions in content and returns a slice of unique usernames. +func extractMentions(content string) []string { + r := regexp.MustCompile(`@([a-zA-Z0-9_]+)`) // Matches @ followed by one or more word characters + matches := r.FindAllStringSubmatch(content, -1) + uniqueMentions := make(map[string]struct{}) + for _, match := range matches { + if len(match) > 1 { + uniqueMentions[match[1]] = struct{}{} + } + } + var result []string + for username := range uniqueMentions { // Corrected: uniqueMentions + result = append(result, username) + } + return result +} + +// CreateChatMessage inserts a new chat message and creates notifications for mentioned users. +func CreateChatMessage(db *sql.DB, msg ChatMessage) (int, error) { query := "INSERT INTO chat_messages (user_id, content, reply_to, timestamp) VALUES (?, ?, ?, NOW())" - _, err := db.Exec(query, msg.UserID, msg.Content, msg.ReplyTo) - return err + result, err := db.Exec(query, msg.UserID, msg.Content, msg.ReplyTo) + if err != nil { + return 0, err + } + + // Get the ID of the newly inserted message + msgID64, err := result.LastInsertId() + if err != nil { + return 0, err + } + msgID := int(msgID64) + + // Create notifications for mentioned users + mentionedUsernames := extractMentions(msg.Content) + for _, username := range mentionedUsernames { + mentionedUser, err := GetUserByUsername(db, username) // models/user.go GetUserByUsername + if err != nil { + // Log error but don't fail message creation for non-existent users + log.Printf("Warning: Could not find mentioned user '%s' for notification: %v", username, err) + continue + } + if mentionedUser != nil && mentionedUser.ID != msg.UserID { // Don't notify self + notification := Notification{ + UserID: mentionedUser.ID, + Type: "chat_mention", + RelatedID: msgID, // The ID of the newly created chat message + Read: false, + } + err := CreateNotification(db, notification) // models/notification.go CreateNotification + if err != nil { + log.Printf("Error creating chat mention notification for user %d: %v", mentionedUser.ID, err) + } + } + } + return msgID, nil } func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) { @@ -51,8 +104,6 @@ func GetRecentChatMessages(db *sql.DB, limit int) ([]ChatMessage, error) { if pfpURL.Valid { msg.PfpURL = pfpURL.String } - // Parse mentions from content (simple @username detection) - msg.Mentions = extractMentions(msg.Content) messages = append(messages, msg) } return messages, nil @@ -82,7 +133,6 @@ func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) { if pfpURL.Valid { msg.PfpURL = pfpURL.String } - msg.Mentions = extractMentions(msg.Content) return &msg, nil } @@ -103,30 +153,4 @@ func GetUsernamesMatching(db *sql.DB, prefix string) ([]string, error) { usernames = append(usernames, username) } return usernames, nil -} - -// Simple utility to extract mentions from content -func extractMentions(content string) []string { - var mentions []string - var currentMention string - inMention := false - - for _, char := range content { - if char == '@' { - inMention = true - currentMention = "@" - } else if inMention && (char == ' ' || char == '\n' || char == '\t') { - if len(currentMention) > 1 { - mentions = append(mentions, currentMention) - } - inMention = false - currentMention = "" - } else if inMention { - currentMention += string(char) - } - } - if inMention && len(currentMention) > 1 { - mentions = append(mentions, currentMention) - } - return mentions } \ No newline at end of file diff --git a/models/notification.go b/models/notification.go index 105d21b..65a56c4 100644 --- a/models/notification.go +++ b/models/notification.go @@ -15,7 +15,7 @@ type Notification struct { } func GetNotificationsByUserID(db *sql.DB, userID int) ([]Notification, error) { - query := "SELECT id, user_id, type, related_id, read, created_at FROM notifications WHERE user_id = ? ORDER BY created_at DESC" + query := "SELECT id, user_id, type, related_id, is_read, created_at FROM notifications WHERE user_id = ? ORDER BY created_at DESC" rows, err := db.Query(query, userID) if err != nil { return nil, err @@ -36,14 +36,14 @@ func GetNotificationsByUserID(db *sql.DB, userID int) ([]Notification, error) { // Stubbed for future implementation func CreateNotification(db *sql.DB, notification Notification) error { - query := "INSERT INTO notifications (user_id, type, related_id, read, created_at) VALUES (?, ?, ?, ?, NOW())" + query := "INSERT INTO notifications (user_id, type, related_id, is_read, created_at) VALUES (?, ?, ?, ?, NOW())" _, err := db.Exec(query, notification.UserID, notification.Type, notification.RelatedID, notification.Read) return err } // Stubbed for future implementation func MarkNotificationAsRead(db *sql.DB, id int) error { - query := "UPDATE notifications SET read = true WHERE id = ?" + query := "UPDATE notifications SET is_read = true WHERE id = ?" _, err := db.Exec(query, id) return err } \ No newline at end of file diff --git a/models/user.go b/models/user.go index 1c6d03a..d59f310 100644 --- a/models/user.go +++ b/models/user.go @@ -82,6 +82,9 @@ func GetUserByUsername(db *sql.DB, username string) (*User, error) { var createdAtString sql.NullString var updatedAtString sql.NullString err := row.Scan(&user.ID, &user.Username, &displayName, &pfpURL, &bio, &user.AuthenticationString, &user.AuthenticationSalt, &user.AuthenticationAlgorithm, &createdAtString, &updatedAtString, &user.Verified, &user.Permissions) + if err == sql.ErrNoRows { // Explicitly handle sql.ErrNoRows + return nil, nil + } if err != nil { return nil, err } diff --git a/templates/pages/chat.html b/templates/pages/chat.html index 494de47..a830f0d 100644 --- a/templates/pages/chat.html +++ b/templates/pages/chat.html @@ -51,6 +51,14 @@ height: 30px; border-radius: 50%; margin-right: 8px; + background-color: #001858; /* Default for no PFP */ + flex-shrink: 0; /* Prevent shrinking */ + } + .chat-message-pfp img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; } .chat-message-username { font-weight: bold; @@ -68,6 +76,7 @@ border-radius: 5px; line-height: 1.3; font-size: 0.9em; + word-wrap: break-word; /* Ensure long words break */ } .chat-message-reply { background-color: rgba(0,0,0,0.1); @@ -78,7 +87,7 @@ cursor: pointer; } .chat-message-mention { - color: #f582ae; + color: #f582ae; /* Highlight color for mentions */ font-weight: bold; } .chat-input { @@ -88,6 +97,8 @@ flex-direction: column; } .chat-input textarea { + width: 100%; + box-sizing: border-box; resize: none; height: 50px; margin-bottom: 8px; @@ -123,6 +134,9 @@ } .autocomplete-popup { position: absolute; + bottom: 100%; /* Position above the textarea */ + left: 0; + right: 0; background-color: #fff; border: 1px solid #001858; border-radius: 5px; @@ -131,14 +145,16 @@ box-shadow: 0px 4px 8px rgba(0,0,0,0.2); z-index: 1000; display: none; + margin-bottom: 8px; /* Space between popup and textarea */ } .autocomplete-item { padding: 6px 10px; cursor: pointer; font-size: 0.9em; } - .autocomplete-item:hover { + .autocomplete-item:hover, .autocomplete-item:focus { background-color: #f3d2c1; + outline: none; /* Remove default focus outline */ } .reply-indicator { background-color: #001858; @@ -167,6 +183,13 @@ background: none; color: #f582ae; } + + /* New wrapper for autocomplete positioning */ + .chat-input-wrapper { + position: relative; + width: 100%; + } + @media (prefers-color-scheme: dark) { .chat-container { background-color: #444; @@ -189,7 +212,7 @@ border-color: #fef6e4; color: #fef6e4; } - .autocomplete-item:hover { + .autocomplete-item:hover, .autocomplete-item:focus { background-color: #555; } .post-actions a { @@ -225,9 +248,9 @@
{{if .PfpURL}} - PFP +
PFP
{{else}} -
+
{{end}} {{.Username}} {{.Timestamp.Format "02/01/2006 15:04"}} @@ -235,7 +258,7 @@ {{if gt .ReplyTo 0}}
Replying to {{.Username}}
{{end}} -
{{.Content | html}}
+
{{.FormattedContent}}
Reply
@@ -247,17 +270,21 @@ Replying to
- +
+ +
+
-
{{template "cookie_banner" .}}