chat: Add mentions support for users
Todo: move the chat to it's proper page and handle users that will and won't be shown on the user list.
parent
bdf81e7c68
commit
3530276b4a
|
@ -6,6 +6,10 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"threadr/models"
|
"threadr/models"
|
||||||
|
"html/template"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
@ -69,6 +73,27 @@ func init() {
|
||||||
go hub.Run()
|
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, `<span class="chat-message-mention">$0</span>`)
|
||||||
|
return template.HTML(formatted)
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
@ -117,19 +142,34 @@ func ChatHandler(app *App) http.HandlerFunc {
|
||||||
Content: chatMsg.Content,
|
Content: chatMsg.Content,
|
||||||
ReplyTo: chatMsg.ReplyTo,
|
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)
|
log.Printf("Error saving chat message: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Fetch the saved message with timestamp and user details
|
// Fetch the saved message with timestamp and user details
|
||||||
var msgID int
|
savedMsg, err := models.GetChatMessageByID(app.DB, newMsgID)
|
||||||
app.DB.QueryRow("SELECT LAST_INSERT_ID()").Scan(&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: %v", err)
|
||||||
continue
|
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
|
hub.broadcast <- response
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -152,21 +192,35 @@ func ChatHandler(app *App) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render chat page
|
// Render chat page
|
||||||
messages, err := models.GetRecentChatMessages(app.DB, 50)
|
rawMessages, err := models.GetRecentChatMessages(app.DB, 50)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error fetching chat messages: %v", err)
|
log.Printf("Error fetching chat messages: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
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
|
// Reverse messages to show oldest first
|
||||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
for i, j := 0, len(displayMessages)-1; i < j; i, j = i+1, j-1 {
|
||||||
messages[i], messages[j] = messages[j], messages[i]
|
displayMessages[i], displayMessages[j] = displayMessages[j], displayMessages[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
data := struct {
|
data := struct {
|
||||||
PageData
|
PageData
|
||||||
Messages []models.ChatMessage
|
Messages []ChatDisplayMessage
|
||||||
}{
|
}{
|
||||||
PageData: PageData{
|
PageData: PageData{
|
||||||
Title: "ThreadR - Chat",
|
Title: "ThreadR - Chat",
|
||||||
|
@ -177,7 +231,7 @@ func ChatHandler(app *App) http.HandlerFunc {
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
CurrentURL: r.URL.Path,
|
CurrentURL: r.URL.Path,
|
||||||
},
|
},
|
||||||
Messages: messages,
|
Messages: displayMessages,
|
||||||
}
|
}
|
||||||
if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil {
|
if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil {
|
||||||
log.Printf("Error executing template in ChatHandler: %v", err)
|
log.Printf("Error executing template in ChatHandler: %v", err)
|
||||||
|
|
|
@ -3,6 +3,8 @@ package models
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"time"
|
"time"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ChatMessage struct {
|
type ChatMessage struct {
|
||||||
|
@ -13,13 +15,64 @@ type ChatMessage struct {
|
||||||
Timestamp time.Time
|
Timestamp time.Time
|
||||||
Username string // For display, fetched from user
|
Username string // For display, fetched from user
|
||||||
PfpURL 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())"
|
query := "INSERT INTO chat_messages (user_id, content, reply_to, timestamp) VALUES (?, ?, ?, NOW())"
|
||||||
_, err := db.Exec(query, msg.UserID, msg.Content, msg.ReplyTo)
|
result, err := db.Exec(query, msg.UserID, msg.Content, msg.ReplyTo)
|
||||||
return err
|
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) {
|
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 {
|
if pfpURL.Valid {
|
||||||
msg.PfpURL = pfpURL.String
|
msg.PfpURL = pfpURL.String
|
||||||
}
|
}
|
||||||
// Parse mentions from content (simple @username detection)
|
|
||||||
msg.Mentions = extractMentions(msg.Content)
|
|
||||||
messages = append(messages, msg)
|
messages = append(messages, msg)
|
||||||
}
|
}
|
||||||
return messages, nil
|
return messages, nil
|
||||||
|
@ -82,7 +133,6 @@ func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
|
||||||
if pfpURL.Valid {
|
if pfpURL.Valid {
|
||||||
msg.PfpURL = pfpURL.String
|
msg.PfpURL = pfpURL.String
|
||||||
}
|
}
|
||||||
msg.Mentions = extractMentions(msg.Content)
|
|
||||||
return &msg, nil
|
return &msg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,29 +154,3 @@ func GetUsernamesMatching(db *sql.DB, prefix string) ([]string, error) {
|
||||||
}
|
}
|
||||||
return usernames, nil
|
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
|
|
||||||
}
|
|
|
@ -15,7 +15,7 @@ type Notification struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetNotificationsByUserID(db *sql.DB, userID int) ([]Notification, error) {
|
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)
|
rows, err := db.Query(query, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -36,14 +36,14 @@ func GetNotificationsByUserID(db *sql.DB, userID int) ([]Notification, error) {
|
||||||
|
|
||||||
// Stubbed for future implementation
|
// Stubbed for future implementation
|
||||||
func CreateNotification(db *sql.DB, notification Notification) error {
|
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)
|
_, err := db.Exec(query, notification.UserID, notification.Type, notification.RelatedID, notification.Read)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stubbed for future implementation
|
// Stubbed for future implementation
|
||||||
func MarkNotificationAsRead(db *sql.DB, id int) error {
|
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)
|
_, err := db.Exec(query, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
|
@ -82,6 +82,9 @@ func GetUserByUsername(db *sql.DB, username string) (*User, error) {
|
||||||
var createdAtString sql.NullString
|
var createdAtString sql.NullString
|
||||||
var updatedAtString 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)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,6 +51,14 @@
|
||||||
height: 30px;
|
height: 30px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-right: 8px;
|
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 {
|
.chat-message-username {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
@ -68,6 +76,7 @@
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
|
word-wrap: break-word; /* Ensure long words break */
|
||||||
}
|
}
|
||||||
.chat-message-reply {
|
.chat-message-reply {
|
||||||
background-color: rgba(0,0,0,0.1);
|
background-color: rgba(0,0,0,0.1);
|
||||||
|
@ -78,7 +87,7 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.chat-message-mention {
|
.chat-message-mention {
|
||||||
color: #f582ae;
|
color: #f582ae; /* Highlight color for mentions */
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
.chat-input {
|
.chat-input {
|
||||||
|
@ -88,6 +97,8 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.chat-input textarea {
|
.chat-input textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
resize: none;
|
resize: none;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
@ -123,6 +134,9 @@
|
||||||
}
|
}
|
||||||
.autocomplete-popup {
|
.autocomplete-popup {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
bottom: 100%; /* Position above the textarea */
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
border: 1px solid #001858;
|
border: 1px solid #001858;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
@ -131,14 +145,16 @@
|
||||||
box-shadow: 0px 4px 8px rgba(0,0,0,0.2);
|
box-shadow: 0px 4px 8px rgba(0,0,0,0.2);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: none;
|
display: none;
|
||||||
|
margin-bottom: 8px; /* Space between popup and textarea */
|
||||||
}
|
}
|
||||||
.autocomplete-item {
|
.autocomplete-item {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
.autocomplete-item:hover {
|
.autocomplete-item:hover, .autocomplete-item:focus {
|
||||||
background-color: #f3d2c1;
|
background-color: #f3d2c1;
|
||||||
|
outline: none; /* Remove default focus outline */
|
||||||
}
|
}
|
||||||
.reply-indicator {
|
.reply-indicator {
|
||||||
background-color: #001858;
|
background-color: #001858;
|
||||||
|
@ -167,6 +183,13 @@
|
||||||
background: none;
|
background: none;
|
||||||
color: #f582ae;
|
color: #f582ae;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* New wrapper for autocomplete positioning */
|
||||||
|
.chat-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.chat-container {
|
.chat-container {
|
||||||
background-color: #444;
|
background-color: #444;
|
||||||
|
@ -189,7 +212,7 @@
|
||||||
border-color: #fef6e4;
|
border-color: #fef6e4;
|
||||||
color: #fef6e4;
|
color: #fef6e4;
|
||||||
}
|
}
|
||||||
.autocomplete-item:hover {
|
.autocomplete-item:hover, .autocomplete-item:focus {
|
||||||
background-color: #555;
|
background-color: #555;
|
||||||
}
|
}
|
||||||
.post-actions a {
|
.post-actions a {
|
||||||
|
@ -225,9 +248,9 @@
|
||||||
<div class="chat-message" id="msg-{{.ID}}">
|
<div class="chat-message" id="msg-{{.ID}}">
|
||||||
<div class="chat-message-header">
|
<div class="chat-message-header">
|
||||||
{{if .PfpURL}}
|
{{if .PfpURL}}
|
||||||
<img src="{{.PfpURL}}" alt="PFP" class="chat-message-pfp">
|
<div class="chat-message-pfp"><img src="{{.PfpURL}}" alt="PFP"></div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="chat-message-pfp" style="background-color: #001858;"></div>
|
<div class="chat-message-pfp"></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<span class="chat-message-username">{{.Username}}</span>
|
<span class="chat-message-username">{{.Username}}</span>
|
||||||
<span class="chat-message-timestamp">{{.Timestamp.Format "02/01/2006 15:04"}}</span>
|
<span class="chat-message-timestamp">{{.Timestamp.Format "02/01/2006 15:04"}}</span>
|
||||||
|
@ -235,7 +258,7 @@
|
||||||
{{if gt .ReplyTo 0}}
|
{{if gt .ReplyTo 0}}
|
||||||
<div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to {{.Username}}</div>
|
<div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to {{.Username}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="chat-message-content">{{.Content | html}}</div>
|
<div class="chat-message-content">{{.FormattedContent}}</div> <!-- Use FormattedContent -->
|
||||||
<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({{.ID}}, '{{.Username}}')">Reply</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -247,17 +270,21 @@
|
||||||
<span id="reply-username">Replying to </span>
|
<span id="reply-username">Replying to </span>
|
||||||
<button onclick="cancelReply()">X</button>
|
<button onclick="cancelReply()">X</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="chat-input-text" placeholder="Type a message..."></textarea>
|
<div class="chat-input-wrapper"> <!-- New wrapper for autocomplete positioning -->
|
||||||
|
<textarea id="chat-input-text" placeholder="Type a message..."></textarea>
|
||||||
|
<div id="autocomplete-popup" class="autocomplete-popup"></div>
|
||||||
|
</div>
|
||||||
<button onclick="sendMessage()">Send</button>
|
<button onclick="sendMessage()">Send</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="autocomplete-popup" class="autocomplete-popup"></div>
|
|
||||||
</main>
|
</main>
|
||||||
{{template "cookie_banner" .}}
|
{{template "cookie_banner" .}}
|
||||||
<script>
|
<script>
|
||||||
let ws;
|
let ws;
|
||||||
let autocompleteActive = false;
|
let autocompleteActive = false;
|
||||||
let autocompletePrefix = '';
|
let autocompleteCurrentPrefix = ''; // Track the current prefix being typed
|
||||||
|
let autocompleteRangeStart = -1; // Start index of the @mention in the textarea
|
||||||
|
|
||||||
let replyToId = -1;
|
let replyToId = -1;
|
||||||
let replyUsername = '';
|
let replyUsername = '';
|
||||||
|
|
||||||
|
@ -292,6 +319,7 @@
|
||||||
} else {
|
} else {
|
||||||
console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined');
|
console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined');
|
||||||
}
|
}
|
||||||
|
hideAutocompletePopup(); // Ensure popup is hidden after sending
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendMessage(msg) {
|
function appendMessage(msg) {
|
||||||
|
@ -299,18 +327,18 @@
|
||||||
const msgDiv = document.createElement('div');
|
const msgDiv = document.createElement('div');
|
||||||
msgDiv.className = 'chat-message';
|
msgDiv.className = 'chat-message';
|
||||||
msgDiv.id = 'msg-' + msg.ID;
|
msgDiv.id = 'msg-' + msg.ID;
|
||||||
let pfpHTML = msg.PfpURL ? `<img src="${msg.PfpURL}" alt="PFP" class="chat-message-pfp">` : `<div class="chat-message-pfp" style="background-color: #001858;"></div>`;
|
let pfpHTML = msg.PfpURL ? `<img src="${msg.PfpURL}" alt="PFP">` : ``; // PFP div has default background
|
||||||
let replyHTML = msg.ReplyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.ReplyTo})">Replying to ${msg.Username}</div>` : '';
|
let replyHTML = msg.ReplyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.ReplyTo})">Replying to ${msg.Username}</div>` : '';
|
||||||
// Process content for mentions
|
|
||||||
let content = msg.Content.replace(/@[\w]+/g, match => `<span class="chat-message-mention">${match}</span>`);
|
// Use msg.FormattedContent directly as it's already processed on the server
|
||||||
msgDiv.innerHTML = `
|
msgDiv.innerHTML = `
|
||||||
<div class="chat-message-header">
|
<div class="chat-message-header">
|
||||||
${pfpHTML}
|
<div class="chat-message-pfp">${pfpHTML}</div>
|
||||||
<span class="chat-message-username">${msg.Username}</span>
|
<span class="chat-message-username">${msg.Username}</span>
|
||||||
<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.FormattedContent}</div> <!-- Use FormattedContent -->
|
||||||
<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>
|
||||||
|
@ -339,76 +367,117 @@
|
||||||
function scrollToMessage(id) {
|
function scrollToMessage(id) {
|
||||||
const msgElement = document.getElementById('msg-' + id);
|
const msgElement = document.getElementById('msg-' + id);
|
||||||
if (msgElement) {
|
if (msgElement) {
|
||||||
msgElement.scrollIntoView({ behavior: 'smooth' });
|
msgElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); // block: 'center' for better visibility
|
||||||
|
// Optional: briefly highlight the message
|
||||||
|
msgElement.style.outline = '2px solid #f582ae';
|
||||||
|
setTimeout(() => {
|
||||||
|
msgElement.style.outline = 'none';
|
||||||
|
}, 1500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAutocompletePopup(usernames, x, y) {
|
function showAutocompletePopup(usernames) {
|
||||||
const popup = document.getElementById('autocomplete-popup');
|
const popup = document.getElementById('autocomplete-popup');
|
||||||
popup.innerHTML = '';
|
popup.innerHTML = '';
|
||||||
popup.style.left = x + 'px';
|
|
||||||
popup.style.top = y + 'px';
|
|
||||||
popup.style.display = 'block';
|
|
||||||
autocompleteActive = true;
|
|
||||||
usernames.forEach(username => {
|
usernames.forEach(username => {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'autocomplete-item';
|
item.className = 'autocomplete-item';
|
||||||
item.textContent = username;
|
item.textContent = `@${username}`; // Display with @ prefix
|
||||||
|
item.setAttribute('tabindex', '0'); // Make focusable
|
||||||
item.onclick = () => {
|
item.onclick = () => {
|
||||||
completeMention(username);
|
completeMention(username);
|
||||||
popup.style.display = 'none';
|
hideAutocompletePopup();
|
||||||
autocompleteActive = false;
|
|
||||||
};
|
};
|
||||||
popup.appendChild(item);
|
popup.appendChild(item);
|
||||||
});
|
});
|
||||||
|
popup.style.display = usernames.length > 0 ? 'block' : 'none';
|
||||||
|
autocompleteActive = usernames.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAutocompletePopup() {
|
||||||
|
document.getElementById('autocomplete-popup').style.display = 'none';
|
||||||
|
autocompleteActive = false;
|
||||||
|
autocompleteCurrentPrefix = '';
|
||||||
|
autocompleteRangeStart = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeMention(username) {
|
function completeMention(username) {
|
||||||
const input = document.getElementById('chat-input-text');
|
const input = document.getElementById('chat-input-text');
|
||||||
const text = input.value;
|
const text = input.value;
|
||||||
const atIndex = text.lastIndexOf('@', input.selectionStart - 1);
|
if (autocompleteRangeStart !== -1) {
|
||||||
if (atIndex !== -1) {
|
const before = text.substring(0, autocompleteRangeStart);
|
||||||
const before = text.substring(0, atIndex);
|
|
||||||
const after = text.substring(input.selectionStart);
|
const after = text.substring(input.selectionStart);
|
||||||
input.value = before + username + (after.startsWith(' ') ? '' : ' ') + after;
|
input.value = before + '@' + username + ' ' + after;
|
||||||
|
input.selectionStart = input.selectionEnd = before.length + username.length + 2; // Move caret after the space
|
||||||
input.focus();
|
input.focus();
|
||||||
}
|
}
|
||||||
|
hideAutocompletePopup();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('chat-input-text').addEventListener('input', async (e) => {
|
document.getElementById('chat-input-text').addEventListener('input', async (e) => {
|
||||||
const text = e.target.value;
|
const text = e.target.value;
|
||||||
const caretPos = e.target.selectionStart;
|
const caretPos = e.target.selectionStart;
|
||||||
const atIndex = text.lastIndexOf('@', caretPos - 1);
|
|
||||||
if (atIndex !== -1 && (caretPos === text.length || text[caretPos] === ' ')) {
|
// Find the last '@' before the caret that is part of a mention
|
||||||
const prefix = text.substring(atIndex + 1, caretPos);
|
let atIndex = -1;
|
||||||
autocompletePrefix = prefix;
|
for (let i = caretPos - 1; i >= 0; i--) {
|
||||||
const response = await fetch('{{.BasePath}}/chat/?autocomplete=true&prefix=' + encodeURIComponent(prefix));
|
if (text[i] === '@') {
|
||||||
const usernames = await response.json();
|
// Check if the character before @ is a space or start of string
|
||||||
if (usernames.length > 0) {
|
const charBefore = i > 0 ? text[i-1] : ' ';
|
||||||
const rect = e.target.getBoundingClientRect();
|
if (charBefore === ' ' || charBefore === '\n' || charBefore === '\t') { // Added tab check
|
||||||
// Approximate caret position (this is a rough estimate)
|
atIndex = i;
|
||||||
const charWidth = 8; // Rough estimate of character width in pixels
|
break;
|
||||||
const caretX = rect.left + (caretPos - text.lastIndexOf('\n', caretPos - 1) - 1) * charWidth;
|
}
|
||||||
showAutocompletePopup(usernames, caretX, rect.top - 10);
|
}
|
||||||
} else {
|
// Stop if we hit a space or newline before an '@' (outside of a valid @mention context)
|
||||||
document.getElementById('autocomplete-popup').style.display = 'none';
|
if (text[i] === ' ' || text[i] === '\n' || text[i] === '\t') { // Added tab check
|
||||||
autocompleteActive = false;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
document.getElementById('autocomplete-popup').style.display = 'none';
|
|
||||||
autocompleteActive = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (atIndex !== -1 && atIndex < caretPos) { // Ensure @ is before caret
|
||||||
|
const prefix = text.substring(atIndex + 1, caretPos);
|
||||||
|
// Only trigger if prefix contains valid characters (alphanumeric, underscore)
|
||||||
|
if (prefix.match(/^[\w]*$/)) {
|
||||||
|
autocompleteCurrentPrefix = prefix;
|
||||||
|
autocompleteRangeStart = atIndex;
|
||||||
|
const response = await fetch('{{.BasePath}}/chat/?autocomplete=true&prefix=' + encodeURIComponent(prefix));
|
||||||
|
const usernames = await response.json();
|
||||||
|
showAutocompletePopup(usernames);
|
||||||
|
return; // Exit after handling autocomplete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hideAutocompletePopup(); // Hide if no valid @mention in progress
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('chat-input-text').addEventListener('keydown', (e) => {
|
document.getElementById('chat-input-text').addEventListener('keydown', (e) => {
|
||||||
if (autocompleteActive) {
|
if (autocompleteActive) {
|
||||||
const popup = document.getElementById('autocomplete-popup');
|
const popup = document.getElementById('autocomplete-popup');
|
||||||
const items = popup.getElementsByClassName('autocomplete-item');
|
const items = Array.from(popup.getElementsByClassName('autocomplete-item'));
|
||||||
if (e.key === 'Enter' && items.length > 0) {
|
let currentFocus = document.activeElement;
|
||||||
items[0].click();
|
let currentIndex = items.indexOf(currentFocus);
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.key === 'ArrowDown' && items.length > 0) {
|
if (items.length > 0) {
|
||||||
items[0].focus();
|
currentIndex = (currentIndex + 1) % items.length;
|
||||||
|
items[currentIndex].focus();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (items.length > 0) {
|
||||||
|
currentIndex = (currentIndex - 1 + items.length) % items.length;
|
||||||
|
items[currentIndex].focus();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Enter' || e.key === 'Tab') { // Added Tab to accept
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentFocus && currentFocus.classList.contains('autocomplete-item')) {
|
||||||
|
currentFocus.click();
|
||||||
|
} else if (items.length > 0) {
|
||||||
|
items[0].click(); // Select first item if nothing specific focused
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
hideAutocompletePopup();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
} else if (e.key === 'Enter' && !e.shiftKey) {
|
} else if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
@ -419,15 +488,15 @@
|
||||||
|
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (!e.target.closest('#autocomplete-popup') && !e.target.closest('#chat-input-text')) {
|
if (!e.target.closest('#autocomplete-popup') && !e.target.closest('#chat-input-text')) {
|
||||||
document.getElementById('autocomplete-popup').style.display = 'none';
|
hideAutocompletePopup();
|
||||||
autocompleteActive = false;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Connect WebSocket on page load
|
// Connect WebSocket on page load
|
||||||
window.onload = function() {
|
window.onload = function() {
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight;
|
const chatMessagesDiv = document.getElementById('chat-messages');
|
||||||
|
chatMessagesDiv.scrollTop = chatMessagesDiv.scrollHeight;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
Loading…
Reference in New Issue