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
parent
d2d64d69fc
commit
d3db9723ec
|
@ -2,10 +2,12 @@ package handlers
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"threadr/models"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
@ -136,21 +138,6 @@ func ChatHandler(app *App) http.HandlerFunc {
|
|||
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
|
||||
messages, err := models.GetRecentChatMessages(app.DB, 50)
|
||||
if err != nil {
|
||||
|
@ -164,9 +151,17 @@ func ChatHandler(app *App) http.HandlerFunc {
|
|||
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 {
|
||||
PageData
|
||||
Messages []models.ChatMessage
|
||||
Messages []models.ChatMessage
|
||||
AllUsernames template.JS
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "ThreadR - Chat",
|
||||
|
@ -177,7 +172,8 @@ func ChatHandler(app *App) http.HandlerFunc {
|
|||
StaticPath: app.Config.ThreadrDir + "/static",
|
||||
CurrentURL: r.URL.Path,
|
||||
},
|
||||
Messages: messages,
|
||||
Messages: messages,
|
||||
AllUsernames: template.JS(allUsernamesJSON),
|
||||
}
|
||||
if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil {
|
||||
log.Printf("Error executing template in ChatHandler: %v", err)
|
||||
|
|
|
@ -78,25 +78,6 @@ func GetChatMessageByID(db *sql.DB, id int) (*ChatMessage, error) {
|
|||
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
|
||||
func extractMentions(content string) []string {
|
||||
re := regexp.MustCompile(`@(\w+)`)
|
||||
|
@ -106,4 +87,4 @@ func extractMentions(content string) []string {
|
|||
mentions[i] = match[1]
|
||||
}
|
||||
return mentions
|
||||
}
|
||||
}
|
|
@ -153,3 +153,22 @@ const (
|
|||
func HasGlobalPermission(user *User, perm int64) bool {
|
||||
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
|
||||
}
|
|
@ -257,9 +257,8 @@
|
|||
<script>
|
||||
let ws;
|
||||
let autocompleteActive = false;
|
||||
let autocompletePrefix = '';
|
||||
let replyToId = -1;
|
||||
let replyUsername = '';
|
||||
const allUsernames = {{.AllUsernames}};
|
||||
|
||||
function connectWebSocket() {
|
||||
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true');
|
||||
|
@ -288,7 +287,7 @@
|
|||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
input.value = '';
|
||||
cancelReply(); // Reset reply state after sending
|
||||
cancelReply();
|
||||
} else {
|
||||
console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined');
|
||||
}
|
||||
|
@ -299,15 +298,13 @@
|
|||
const msgDiv = document.createElement('div');
|
||||
msgDiv.className = 'chat-message';
|
||||
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) {
|
||||
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>` : '';
|
||||
// Process content for mentions
|
||||
let content = msg.content.replace(/@[\w]+/g, match => `<span class="chat-message-mention">${match}</span>`);
|
||||
let replyHTML = msg.replyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.replyTo})">Replying...</div>` : '';
|
||||
let content = msg.content.replace(/@(\w+)/g, '<span class="chat-message-mention">@$1</span>');
|
||||
|
||||
msgDiv.innerHTML = `
|
||||
<div class="chat-message-header">
|
||||
${pfpHTML}
|
||||
|
@ -326,7 +323,6 @@
|
|||
|
||||
function replyToMessage(id, username) {
|
||||
replyToId = id;
|
||||
replyUsername = username;
|
||||
const replyIndicator = document.getElementById('reply-indicator');
|
||||
const replyUsernameSpan = document.getElementById('reply-username');
|
||||
replyUsernameSpan.textContent = `Replying to ${username}`;
|
||||
|
@ -336,7 +332,6 @@
|
|||
|
||||
function cancelReply() {
|
||||
replyToId = -1;
|
||||
replyUsername = '';
|
||||
const replyIndicator = document.getElementById('reply-indicator');
|
||||
replyIndicator.style.display = 'none';
|
||||
}
|
||||
|
@ -344,22 +339,27 @@
|
|||
function scrollToMessage(id) {
|
||||
const msgElement = document.getElementById('msg-' + id);
|
||||
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');
|
||||
popup.innerHTML = '';
|
||||
popup.style.position = 'fixed';
|
||||
popup.style.left = x + 'px';
|
||||
popup.style.top = y + 'px';
|
||||
popup.style.display = 'block';
|
||||
autocompleteActive = true;
|
||||
usernames.forEach(username => {
|
||||
suggestions.forEach(username => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'autocomplete-item';
|
||||
item.textContent = username;
|
||||
item.onclick = () => {
|
||||
item.onmousedown = (e) => {
|
||||
e.preventDefault();
|
||||
completeMention(username);
|
||||
popup.style.display = 'none';
|
||||
autocompleteActive = false;
|
||||
|
@ -371,66 +371,86 @@
|
|||
function completeMention(username) {
|
||||
const input = document.getElementById('chat-input-text');
|
||||
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) {
|
||||
const before = text.substring(0, atIndex);
|
||||
const after = text.substring(input.selectionStart);
|
||||
input.value = before + username + (after.startsWith(' ') ? '' : ' ') + after;
|
||||
const prefix = text.substring(0, atIndex);
|
||||
const suffix = text.substring(caretPos);
|
||||
input.value = prefix + '@' + username + ' ' + suffix;
|
||||
const newCaretPos = prefix.length + 1 + username.length + 1;
|
||||
input.focus();
|
||||
input.setSelectionRange(newCaretPos, newCaretPos);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('chat-input-text').addEventListener('input', async (e) => {
|
||||
const text = e.target.value;
|
||||
const caretPos = e.target.selectionStart;
|
||||
const atIndex = text.lastIndexOf('@', caretPos - 1);
|
||||
if (atIndex !== -1 && (caretPos === text.length || text[caretPos] === ' ')) {
|
||||
const prefix = text.substring(atIndex + 1, caretPos);
|
||||
autocompletePrefix = prefix;
|
||||
// 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;
|
||||
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);
|
||||
}
|
||||
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) => {
|
||||
if (autocompleteActive) {
|
||||
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();
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
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() {
|
||||
connectWebSocket();
|
||||
document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight;
|
||||
|
@ -438,4 +458,4 @@
|
|||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
{{end}}
|
Loading…
Reference in New Issue