chat: polish mention system

We now have a proper autocomplete. Next on the list is moving the chat page into it's proper place and add pop-ups for profiles.
jocadbz
Joca 2025-08-03 19:21:06 -03:00
parent d2d64d69fc
commit d3db9723ec
Signed by: jocadbz
GPG Key ID: B1836DCE2F50BDF7
4 changed files with 116 additions and 100 deletions

View File

@ -2,10 +2,12 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"html/template"
"log" "log"
"net/http" "net/http"
"sync" "sync"
"threadr/models" "threadr/models"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@ -136,21 +138,6 @@ func ChatHandler(app *App) http.HandlerFunc {
return return
} }
if r.URL.Query().Get("autocomplete") == "true" {
// Handle autocomplete for mentions
prefix := r.URL.Query().Get("prefix")
usernames, err := models.GetUsernamesMatching(app.DB, prefix)
if err != nil {
log.Printf("Error fetching usernames for autocomplete: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
response, _ := json.Marshal(usernames)
w.Header().Set("Content-Type", "application/json")
w.Write(response)
return
}
// Render chat page // Render chat page
messages, err := models.GetRecentChatMessages(app.DB, 50) messages, err := models.GetRecentChatMessages(app.DB, 50)
if err != nil { if err != nil {
@ -164,9 +151,17 @@ func ChatHandler(app *App) http.HandlerFunc {
messages[i], messages[j] = messages[j], messages[i] messages[i], messages[j] = messages[j], messages[i]
} }
allUsernames, err := models.GetAllUsernames(app.DB)
if err != nil {
log.Printf("Error fetching all usernames: %v", err)
allUsernames = []string{} // Proceed without autocomplete on error
}
allUsernamesJSON, _ := json.Marshal(allUsernames)
data := struct { data := struct {
PageData PageData
Messages []models.ChatMessage Messages []models.ChatMessage
AllUsernames template.JS
}{ }{
PageData: PageData{ PageData: PageData{
Title: "ThreadR - Chat", Title: "ThreadR - Chat",
@ -177,7 +172,8 @@ 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: messages,
AllUsernames: template.JS(allUsernamesJSON),
} }
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)

View File

@ -78,25 +78,6 @@ func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
return &msg, nil return &msg, nil
} }
func GetUsernamesMatching(db *sql.DB, prefix string) ([]string, error) {
query := "SELECT username FROM users WHERE username LIKE ? LIMIT 10"
rows, err := db.Query(query, prefix+"%")
if err != nil {
return nil, err
}
defer rows.Close()
var usernames []string
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return nil, err
}
usernames = append(usernames, username)
}
return usernames, nil
}
// Simple utility to extract mentions from content // 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+)`)
@ -106,4 +87,4 @@ func extractMentions(content string) []string {
mentions[i] = match[1] mentions[i] = match[1]
} }
return mentions return mentions
} }

View File

@ -153,3 +153,22 @@ const (
func HasGlobalPermission(user *User, perm int64) bool { func HasGlobalPermission(user *User, perm int64) bool {
return user.Permissions&perm != 0 return user.Permissions&perm != 0
} }
func GetAllUsernames(db *sql.DB) ([]string, error) {
query := "SELECT username FROM users ORDER BY username ASC"
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var usernames []string
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return nil, err
}
usernames = append(usernames, username)
}
return usernames, nil
}

View File

@ -257,9 +257,8 @@
<script> <script>
let ws; let ws;
let autocompleteActive = false; let autocompleteActive = false;
let autocompletePrefix = '';
let replyToId = -1; let replyToId = -1;
let replyUsername = ''; const allUsernames = {{.AllUsernames}};
function connectWebSocket() { function connectWebSocket() {
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true'); ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true');
@ -288,7 +287,7 @@
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg)); ws.send(JSON.stringify(msg));
input.value = ''; input.value = '';
cancelReply(); // Reset reply state after sending cancelReply();
} 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');
} }
@ -299,15 +298,13 @@
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 = ''; let pfpHTML = `<div class="chat-message-pfp" style="background-color: #001858;"></div>`;
if (msg.pfpFileId && msg.pfpFileId.Valid) { if (msg.pfpFileId && msg.pfpFileId.Valid) {
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">`;
} else {
pfpHTML = `<div class="chat-message-pfp" style="background-color: #001858;"></div>`;
} }
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...</div>` : '';
// Process content for mentions let content = msg.content.replace(/@(\w+)/g, '<span class="chat-message-mention">@$1</span>');
let content = msg.content.replace(/@[\w]+/g, match => `<span class="chat-message-mention">${match}</span>`);
msgDiv.innerHTML = ` msgDiv.innerHTML = `
<div class="chat-message-header"> <div class="chat-message-header">
${pfpHTML} ${pfpHTML}
@ -326,7 +323,6 @@
function replyToMessage(id, username) { function replyToMessage(id, username) {
replyToId = id; replyToId = id;
replyUsername = username;
const replyIndicator = document.getElementById('reply-indicator'); const replyIndicator = document.getElementById('reply-indicator');
const replyUsernameSpan = document.getElementById('reply-username'); const replyUsernameSpan = document.getElementById('reply-username');
replyUsernameSpan.textContent = `Replying to ${username}`; replyUsernameSpan.textContent = `Replying to ${username}`;
@ -336,7 +332,6 @@
function cancelReply() { function cancelReply() {
replyToId = -1; replyToId = -1;
replyUsername = '';
const replyIndicator = document.getElementById('reply-indicator'); const replyIndicator = document.getElementById('reply-indicator');
replyIndicator.style.display = 'none'; replyIndicator.style.display = 'none';
} }
@ -344,22 +339,27 @@
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' });
msgElement.style.transition = 'background-color 0.5s';
msgElement.style.backgroundColor = '#f582ae';
setTimeout(() => { msgElement.style.backgroundColor = ''; }, 1000);
} }
} }
function showAutocompletePopup(usernames, x, y) { function showAutocompletePopup(suggestions, x, y) {
const popup = document.getElementById('autocomplete-popup'); const popup = document.getElementById('autocomplete-popup');
popup.innerHTML = ''; popup.innerHTML = '';
popup.style.position = 'fixed';
popup.style.left = x + 'px'; popup.style.left = x + 'px';
popup.style.top = y + 'px'; popup.style.top = y + 'px';
popup.style.display = 'block'; popup.style.display = 'block';
autocompleteActive = true; autocompleteActive = true;
usernames.forEach(username => { suggestions.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;
item.onclick = () => { item.onmousedown = (e) => {
e.preventDefault();
completeMention(username); completeMention(username);
popup.style.display = 'none'; popup.style.display = 'none';
autocompleteActive = false; autocompleteActive = false;
@ -371,66 +371,86 @@
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); const caretPos = input.selectionStart;
const textBeforeCaret = text.substring(0, caretPos);
const atIndex = textBeforeCaret.lastIndexOf('@');
if (atIndex !== -1) { if (atIndex !== -1) {
const before = text.substring(0, atIndex); const prefix = text.substring(0, atIndex);
const after = text.substring(input.selectionStart); const suffix = text.substring(caretPos);
input.value = before + username + (after.startsWith(' ') ? '' : ' ') + after; input.value = prefix + '@' + username + ' ' + suffix;
const newCaretPos = prefix.length + 1 + username.length + 1;
input.focus(); input.focus();
input.setSelectionRange(newCaretPos, newCaretPos);
} }
} }
document.getElementById('chat-input-text').addEventListener('input', async (e) => { function getCaretCoordinates(element, position) {
const text = e.target.value; const mirrorDivId = 'input-mirror-div';
const caretPos = e.target.selectionStart; let div = document.getElementById(mirrorDivId);
const atIndex = text.lastIndexOf('@', caretPos - 1); if (!div) {
if (atIndex !== -1 && (caretPos === text.length || text[caretPos] === ' ')) { div = document.createElement('div');
const prefix = text.substring(atIndex + 1, caretPos); div.id = mirrorDivId;
autocompletePrefix = prefix; document.body.appendChild(div);
// TODO: Fix this.
const response = await fetch('{{.BasePath}}/chat/?autocomplete=true&prefix=' + encodeURIComponent(prefix));
const usernames = await response.json();
if (usernames.length > 0) {
const rect = e.target.getBoundingClientRect();
// Approximate caret position (this is a rough estimate)
const charWidth = 8; // Rough estimate of character width in pixels
const caretX = rect.left + (caretPos - text.lastIndexOf('\n', caretPos - 1) - 1) * charWidth;
showAutocompletePopup(usernames, caretX, rect.top - 10);
} else {
document.getElementById('autocomplete-popup').style.display = 'none';
autocompleteActive = false;
}
} else {
document.getElementById('autocomplete-popup').style.display = 'none';
autocompleteActive = false;
} }
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]; });
div.style.position = 'absolute';
div.style.top = '-9999px';
div.style.left = '0px';
div.textContent = element.value.substring(0, position);
const span = document.createElement('span');
span.textContent = element.value.substring(position) || '.';
div.appendChild(span);
return { top: span.offsetTop, left: span.offsetLeft };
}
document.getElementById('chat-input-text').addEventListener('input', (e) => {
const input = e.target;
const text = input.value;
const caretPos = input.selectionStart;
const popup = document.getElementById('autocomplete-popup');
const textBeforeCaret = text.substring(0, caretPos);
const atIndex = textBeforeCaret.lastIndexOf('@');
if (atIndex !== -1 && (atIndex === 0 || /\s/.test(text.charAt(atIndex - 1)))) {
const query = textBeforeCaret.substring(atIndex + 1);
if (!/\s/.test(query)) {
const suggestions = allUsernames.filter(u => u.toLowerCase().startsWith(query.toLowerCase())).slice(0, 10);
if (suggestions.length > 0 && query.length > 0) {
const coords = getCaretCoordinates(input, atIndex);
const rect = input.getBoundingClientRect();
showAutocompletePopup(suggestions, rect.left + coords.left, rect.top + coords.top + 20);
} else {
popup.style.display = 'none';
autocompleteActive = false;
}
return;
}
}
popup.style.display = 'none';
autocompleteActive = false;
});
document.getElementById('chat-input-text').addEventListener('blur', () => {
setTimeout(() => {
if (!document.querySelector('.autocomplete-popup:hover')) {
document.getElementById('autocomplete-popup').style.display = 'none';
autocompleteActive = false;
}
}, 150);
}); });
document.getElementById('chat-input-text').addEventListener('keydown', (e) => { document.getElementById('chat-input-text').addEventListener('keydown', (e) => {
if (autocompleteActive) { if (e.key === 'Enter' && !e.shiftKey) {
const popup = document.getElementById('autocomplete-popup');
const items = popup.getElementsByClassName('autocomplete-item');
if (e.key === 'Enter' && items.length > 0) {
items[0].click();
e.preventDefault();
} else if (e.key === 'ArrowDown' && items.length > 0) {
items[0].focus();
e.preventDefault();
}
} else if (e.key === 'Enter' && !e.shiftKey) {
sendMessage();
e.preventDefault(); e.preventDefault();
sendMessage();
} }
}); });
document.addEventListener('click', (e) => {
if (!e.target.closest('#autocomplete-popup') && !e.target.closest('#chat-input-text')) {
document.getElementById('autocomplete-popup').style.display = 'none';
autocompleteActive = false;
}
});
// Connect WebSocket on page load
window.onload = function() { window.onload = function() {
connectWebSocket(); connectWebSocket();
document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight; document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight;
@ -438,4 +458,4 @@
</script> </script>
</body> </body>
</html> </html>
{{end}} {{end}}