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 (
|
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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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}}
|
Loading…
Reference in New Issue