947 lines
34 KiB
HTML
947 lines
34 KiB
HTML
{{define "chat"}}
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>{{.Title}}</title>
|
||
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
|
||
<script src="{{.StaticPath}}/app.js" defer></script>
|
||
<style>
|
||
body {
|
||
margin: 0;
|
||
padding: 0;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
}
|
||
main {
|
||
padding: 0;
|
||
margin-top: 3em; /* Space for navbar */
|
||
height: calc(100vh - 3em);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
.chat-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border: none;
|
||
border-radius: 0;
|
||
background-color: #fef6e4;
|
||
box-shadow: none;
|
||
}
|
||
.chat-header {
|
||
padding: 10px;
|
||
text-align: center;
|
||
border-bottom: 1px solid #001858;
|
||
position: relative;
|
||
}
|
||
.connection-status {
|
||
position: absolute;
|
||
top: 10px;
|
||
right: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 0.85em;
|
||
padding: 4px 10px;
|
||
border-radius: 12px;
|
||
background-color: rgba(0,0,0,0.1);
|
||
}
|
||
.connection-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
animation: pulse 2s ease-in-out infinite;
|
||
}
|
||
.connection-dot.connected {
|
||
background-color: #4ade80;
|
||
}
|
||
.connection-dot.connecting {
|
||
background-color: #fbbf24;
|
||
}
|
||
.connection-dot.disconnected {
|
||
background-color: #f87171;
|
||
animation: none;
|
||
}
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.5; }
|
||
}
|
||
.typing-indicator {
|
||
padding: 8px 12px;
|
||
background-color: #f3d2c1;
|
||
border-radius: 5px;
|
||
font-size: 0.85em;
|
||
font-style: italic;
|
||
color: #001858;
|
||
margin-bottom: 8px;
|
||
display: none;
|
||
}
|
||
.typing-indicator.visible {
|
||
display: block;
|
||
}
|
||
.typing-dots {
|
||
display: inline-block;
|
||
margin-left: 4px;
|
||
}
|
||
.typing-dots span {
|
||
animation: typing-blink 1.4s infinite;
|
||
display: inline-block;
|
||
}
|
||
.typing-dots span:nth-child(2) {
|
||
animation-delay: 0.2s;
|
||
}
|
||
.typing-dots span:nth-child(3) {
|
||
animation-delay: 0.4s;
|
||
}
|
||
@keyframes typing-blink {
|
||
0%, 60%, 100% { opacity: 0.3; }
|
||
30% { opacity: 1; }
|
||
}
|
||
.jump-to-bottom {
|
||
position: absolute;
|
||
bottom: 70px;
|
||
right: 20px;
|
||
background-color: #001858;
|
||
color: #fef6e4;
|
||
border: 2px solid #001858;
|
||
border-radius: 50%;
|
||
width: 45px;
|
||
height: 45px;
|
||
cursor: pointer;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 1.3em;
|
||
box-shadow: 0px 4px 12px rgba(0,0,0,0.3);
|
||
transition: transform 0.2s ease;
|
||
z-index: 100;
|
||
}
|
||
.jump-to-bottom.visible {
|
||
display: flex;
|
||
}
|
||
.jump-to-bottom:hover {
|
||
transform: translateY(2px);
|
||
background-color: #8bd3dd;
|
||
}
|
||
.jump-to-bottom .unread-badge {
|
||
position: absolute;
|
||
top: -5px;
|
||
right: -5px;
|
||
background-color: #f582ae;
|
||
color: #fef6e4;
|
||
border-radius: 50%;
|
||
width: 20px;
|
||
height: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 0.7em;
|
||
font-weight: bold;
|
||
}
|
||
.message-status {
|
||
font-size: 0.7em;
|
||
color: #666;
|
||
margin-left: 8px;
|
||
}
|
||
.message-status.sending {
|
||
color: #fbbf24;
|
||
}
|
||
.message-status.sent {
|
||
color: #4ade80;
|
||
}
|
||
.message-status.failed {
|
||
color: #f87171;
|
||
cursor: pointer;
|
||
}
|
||
.chat-breadcrumb {
|
||
background-color: #f3d2c1;
|
||
padding: 8px 12px;
|
||
border-bottom: 1px solid #001858;
|
||
font-size: 0.85em;
|
||
text-align: left;
|
||
}
|
||
.chat-breadcrumb a {
|
||
color: #001858;
|
||
text-decoration: none;
|
||
}
|
||
.chat-breadcrumb a:hover {
|
||
color: #f582ae;
|
||
text-decoration: underline;
|
||
}
|
||
.chat-breadcrumb-separator {
|
||
margin: 0 6px;
|
||
opacity: 0.6;
|
||
}
|
||
.chat-messages {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.chat-message {
|
||
margin-bottom: 8px;
|
||
max-width: 90%;
|
||
position: relative;
|
||
}
|
||
.chat-message-header {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 3px;
|
||
}
|
||
.chat-message-pfp {
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 50%;
|
||
margin-right: 8px;
|
||
}
|
||
.chat-message-username {
|
||
font-weight: bold;
|
||
color: #001858;
|
||
font-size: 0.9em;
|
||
}
|
||
.chat-message-timestamp {
|
||
font-size: 0.7em;
|
||
color: #666;
|
||
margin-left: 8px;
|
||
}
|
||
.chat-message-content {
|
||
background-color: #f3d2c1;
|
||
padding: 6px 10px;
|
||
border-radius: 5px;
|
||
line-height: 1.3;
|
||
font-size: 0.9em;
|
||
word-wrap: break-word;
|
||
overflow-wrap: break-word;
|
||
word-break: break-word;
|
||
}
|
||
.chat-message-reply {
|
||
background-color: rgba(0,0,0,0.1);
|
||
padding: 4px 8px;
|
||
border-radius: 5px;
|
||
margin-bottom: 3px;
|
||
font-size: 0.8em;
|
||
cursor: pointer;
|
||
}
|
||
.chat-message-mention {
|
||
color: #f582ae;
|
||
font-weight: bold;
|
||
}
|
||
.chat-input {
|
||
padding: 8px;
|
||
border-top: 1px solid #001858;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.chat-input textarea {
|
||
resize: none;
|
||
height: 50px;
|
||
margin-bottom: 8px;
|
||
font-size: 0.9em;
|
||
}
|
||
.chat-input button {
|
||
align-self: flex-end;
|
||
width: auto;
|
||
padding: 6px 12px;
|
||
font-size: 0.9em;
|
||
}
|
||
.post-actions {
|
||
position: absolute;
|
||
top: 5px;
|
||
right: 5px;
|
||
opacity: 0;
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
.chat-message:hover .post-actions {
|
||
opacity: 1;
|
||
}
|
||
.post-actions a {
|
||
color: #001858;
|
||
text-decoration: none;
|
||
font-size: 0.8em;
|
||
padding: 2px 5px;
|
||
border: 1px solid #001858;
|
||
border-radius: 3px;
|
||
}
|
||
.post-actions a:hover {
|
||
background-color: #8bd3dd;
|
||
color: #fef6e4;
|
||
}
|
||
.autocomplete-popup {
|
||
position: absolute;
|
||
background-color: #fff;
|
||
border: 1px solid #001858;
|
||
border-radius: 5px;
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
box-shadow: 0px 4px 8px rgba(0,0,0,0.2);
|
||
z-index: 1000;
|
||
display: none;
|
||
}
|
||
.autocomplete-item {
|
||
padding: 6px 10px;
|
||
cursor: pointer;
|
||
font-size: 0.9em;
|
||
}
|
||
.autocomplete-item:hover {
|
||
background-color: #f3d2c1;
|
||
}
|
||
.reply-indicator {
|
||
background-color: #001858;
|
||
color: #fef6e4;
|
||
padding: 5px 10px;
|
||
border-radius: 5px;
|
||
margin-bottom: 8px;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.reply-indicator span {
|
||
font-size: 0.9em;
|
||
}
|
||
.reply-indicator button {
|
||
background: none;
|
||
border: none;
|
||
color: #fef6e4;
|
||
cursor: pointer;
|
||
font-size: 0.9em;
|
||
padding: 0 5px;
|
||
margin: 0;
|
||
width: auto;
|
||
}
|
||
.reply-indicator button:hover {
|
||
background: none;
|
||
color: #f582ae;
|
||
}
|
||
/* New style for highlighted messages */
|
||
.chat-message-highlighted {
|
||
border: 2px solid #f582ae; /* Pink border */
|
||
background-color: #ffe0f0; /* Light pink background */
|
||
animation: highlight-fade 2s ease-out;
|
||
}
|
||
@keyframes highlight-fade {
|
||
from {
|
||
background-color: #f582ae;
|
||
border-color: #f582ae;
|
||
}
|
||
to {
|
||
background-color: #ffe0f0;
|
||
border-color: #f582ae;
|
||
}
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
.chat-container {
|
||
background-color: #444;
|
||
}
|
||
.chat-breadcrumb {
|
||
background-color: #555;
|
||
}
|
||
.chat-breadcrumb a {
|
||
color: #fef6e4;
|
||
}
|
||
.typing-indicator {
|
||
background-color: #555;
|
||
color: #fef6e4;
|
||
}
|
||
.jump-to-bottom {
|
||
background-color: #fef6e4;
|
||
color: #001858;
|
||
border-color: #fef6e4;
|
||
}
|
||
.jump-to-bottom:hover {
|
||
background-color: #8bd3dd;
|
||
}
|
||
.chat-header {
|
||
border-color: #fef6e4;
|
||
}
|
||
.chat-message-username {
|
||
color: #fef6e4;
|
||
}
|
||
.chat-message-timestamp {
|
||
color: #aaa;
|
||
}
|
||
.chat-message-content {
|
||
background-color: #555;
|
||
}
|
||
.chat-input {
|
||
border-color: #fef6e4;
|
||
}
|
||
.autocomplete-popup {
|
||
background-color: #444;
|
||
border-color: #fef6e4;
|
||
color: #fef6e4;
|
||
}
|
||
.autocomplete-item:hover {
|
||
background-color: #555;
|
||
}
|
||
.post-actions a {
|
||
color: #fef6e4;
|
||
border-color: #fef6e4;
|
||
}
|
||
.post-actions a:hover {
|
||
background-color: #8bd3dd;
|
||
color: #001858;
|
||
}
|
||
.reply-indicator {
|
||
background-color: #222;
|
||
color: #fef6e4;
|
||
}
|
||
.reply-indicator button {
|
||
color: #fef6e4;
|
||
}
|
||
.reply-indicator button:hover {
|
||
color: #f582ae;
|
||
}
|
||
/* Dark mode highlight */
|
||
.chat-message-highlighted {
|
||
border: 2px solid #f582ae; /* Pink border */
|
||
background-color: #6a0e3f; /* Darker pink background */
|
||
animation: highlight-fade-dark 2s ease-out;
|
||
}
|
||
@keyframes highlight-fade-dark {
|
||
from {
|
||
background-color: #f582ae;
|
||
border-color: #f582ae;
|
||
}
|
||
to {
|
||
background-color: #6a0e3f;
|
||
border-color: #f582ae;
|
||
}
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
{{template "navbar" .}}
|
||
<main>
|
||
<div class="chat-container">
|
||
<div class="chat-breadcrumb">
|
||
<a href="{{.BasePath}}/">Home</a>
|
||
<span class="chat-breadcrumb-separator">›</span>
|
||
<a href="{{.BasePath}}/boards/">Boards</a>
|
||
<span class="chat-breadcrumb-separator">›</span>
|
||
<span>{{.Board.Name}}</span>
|
||
</div>
|
||
<header class="chat-header">
|
||
<div class="connection-status">
|
||
<div class="connection-dot connecting" id="connection-dot"></div>
|
||
<span id="connection-text">Connecting...</span>
|
||
</div>
|
||
<h2>{{.Board.Name}}</h2>
|
||
<p>{{.Board.Description}}</p>
|
||
</header>
|
||
<div class="chat-messages" id="chat-messages">
|
||
<div class="typing-indicator" id="typing-indicator">
|
||
<span id="typing-users"></span><span class="typing-dots"><span>.</span><span>.</span><span>.</span></span>
|
||
</div>
|
||
{{range .Messages}}
|
||
<div class="chat-message{{if .Mentions}}{{range .Mentions}}{{if eq . $.CurrentUsername}} chat-message-highlighted{{end}}{{end}}{{end}}" id="msg-{{.ID}}" data-user="{{.Username}}" data-reply="{{.ReplyTo}}">
|
||
<div class="chat-message-header">
|
||
{{if .PfpFileID.Valid}}
|
||
<img src="{{$.BasePath}}/file?id={{.PfpFileID.Int64}}" alt="PFP" class="chat-message-pfp">
|
||
{{else}}
|
||
<div class="chat-message-pfp" style="background-color: #001858;"></div>
|
||
{{end}}
|
||
<span class="chat-message-username">{{.Username}}</span>
|
||
<span class="chat-message-timestamp">{{.Timestamp.Format "02/01/2006 15:04"}}</span>
|
||
</div>
|
||
{{if gt .ReplyTo 0}}
|
||
<div class="chat-message-reply" onclick="scrollToMessage({{.ReplyTo}})">Replying to message...</div>
|
||
{{end}}
|
||
<div class="chat-message-content">{{.Content}}</div>
|
||
<div class="post-actions">
|
||
<a href="javascript:void(0)" onclick="replyToMessage({{printf "%v" .ID}}, '{{.Username}}')">Reply</a>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
<button class="jump-to-bottom" id="jump-to-bottom" title="Jump to bottom">
|
||
↓
|
||
<span class="unread-badge" id="unread-badge" style="display: none;">0</span>
|
||
</button>
|
||
<div class="chat-input">
|
||
<div id="reply-indicator" class="reply-indicator">
|
||
<span id="reply-username">Replying to </span>
|
||
<button onclick="cancelReply()">X</button>
|
||
</div>
|
||
<textarea id="chat-input-text" placeholder="Type a message..."></textarea>
|
||
<button onclick="sendMessage()">Send</button>
|
||
</div>
|
||
</div>
|
||
<div id="autocomplete-popup" class="autocomplete-popup"></div>
|
||
</main>
|
||
{{template "cookie_banner" .}}
|
||
<script>
|
||
let ws;
|
||
let autocompleteActive = false;
|
||
let replyToId = -1;
|
||
let reconnectAttempts = 0;
|
||
let reconnectTimeout;
|
||
let typingTimeout;
|
||
let isUserAtBottom = true;
|
||
let unreadCount = 0;
|
||
const allUsernames = {{.AllUsernames}};
|
||
const currentUsername = "{{.CurrentUsername}}";
|
||
const maxReconnectDelay = 30000; // 30 seconds max
|
||
|
||
// Update connection status
|
||
function updateConnectionStatus(status) {
|
||
const dot = document.getElementById('connection-dot');
|
||
const text = document.getElementById('connection-text');
|
||
dot.className = 'connection-dot ' + status;
|
||
|
||
if (status === 'connected') {
|
||
text.textContent = 'Connected';
|
||
reconnectAttempts = 0;
|
||
} else if (status === 'connecting') {
|
||
text.textContent = 'Connecting...';
|
||
} else if (status === 'disconnected') {
|
||
text.textContent = 'Disconnected';
|
||
}
|
||
}
|
||
|
||
function connectWebSocket() {
|
||
const boardID = {{.Board.ID}};
|
||
updateConnectionStatus('connecting');
|
||
|
||
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true&id=' + boardID);
|
||
|
||
ws.onopen = function() {
|
||
updateConnectionStatus('connected');
|
||
reconnectAttempts = 0;
|
||
};
|
||
|
||
ws.onmessage = function(event) {
|
||
const msg = JSON.parse(event.data);
|
||
|
||
// Handle typing indicator
|
||
if (msg.type === 'typing') {
|
||
showTypingIndicator(msg.username);
|
||
return;
|
||
}
|
||
|
||
// Handle regular message
|
||
appendMessage(msg);
|
||
};
|
||
ws.onclose = function() {
|
||
updateConnectionStatus('disconnected');
|
||
console.log("WebSocket closed, reconnecting...");
|
||
scheduleReconnect();
|
||
};
|
||
ws.onerror = function(error) {
|
||
console.error("WebSocket error:", error);
|
||
updateConnectionStatus('disconnected');
|
||
};
|
||
}
|
||
|
||
// Reconnect with exponential backoff
|
||
function scheduleReconnect() {
|
||
if (reconnectTimeout) clearTimeout(reconnectTimeout);
|
||
|
||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay);
|
||
reconnectAttempts++;
|
||
|
||
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})...`);
|
||
reconnectTimeout = setTimeout(connectWebSocket, delay);
|
||
}
|
||
|
||
function sendMessage() {
|
||
const input = document.getElementById('chat-input-text');
|
||
const content = input.value.trim();
|
||
if (content === '') return;
|
||
|
||
const msg = {
|
||
type: 'message',
|
||
content: content,
|
||
replyTo: replyToId
|
||
};
|
||
|
||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify(msg));
|
||
input.value = '';
|
||
cancelReply();
|
||
} else {
|
||
console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined');
|
||
alert("Cannot send message: Not connected to chat server");
|
||
}
|
||
}
|
||
|
||
// Typing indicator
|
||
let typingUsers = new Set();
|
||
|
||
function sendTypingIndicator() {
|
||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify({ type: 'typing', username: currentUsername }));
|
||
}
|
||
}
|
||
|
||
function showTypingIndicator(username) {
|
||
if (username === currentUsername) return;
|
||
|
||
typingUsers.add(username);
|
||
updateTypingDisplay();
|
||
|
||
// Clear after 3 seconds
|
||
setTimeout(() => {
|
||
typingUsers.delete(username);
|
||
updateTypingDisplay();
|
||
}, 3000);
|
||
}
|
||
|
||
function updateTypingDisplay() {
|
||
const indicator = document.getElementById('typing-indicator');
|
||
const usersSpan = document.getElementById('typing-users');
|
||
|
||
if (typingUsers.size === 0) {
|
||
indicator.classList.remove('visible');
|
||
} else {
|
||
const users = Array.from(typingUsers);
|
||
if (users.length === 1) {
|
||
usersSpan.textContent = `${users[0]} is typing`;
|
||
} else if (users.length === 2) {
|
||
usersSpan.textContent = `${users[0]} and ${users[1]} are typing`;
|
||
} else {
|
||
usersSpan.textContent = `${users[0]}, ${users[1]}, and ${users.length - 2} other${users.length - 2 > 1 ? 's' : ''} are typing`;
|
||
}
|
||
indicator.classList.add('visible');
|
||
}
|
||
}
|
||
|
||
function appendMessage(msg) {
|
||
const messages = document.getElementById('chat-messages');
|
||
|
||
// Check if message already exists (prevent duplicates)
|
||
if (document.getElementById('msg-' + msg.id)) {
|
||
return; // Don't add duplicate messages
|
||
}
|
||
|
||
// Check if user is at bottom before adding message
|
||
const wasAtBottom = isUserAtBottom;
|
||
|
||
const msgDiv = document.createElement('div');
|
||
let highlightClass = '';
|
||
if (msg.mentions && msg.mentions.includes(currentUsername)) {
|
||
highlightClass = ' chat-message-highlighted';
|
||
}
|
||
msgDiv.className = 'chat-message' + highlightClass;
|
||
msgDiv.id = 'msg-' + msg.id;
|
||
msgDiv.dataset.user = msg.username;
|
||
msgDiv.dataset.reply = msg.replyTo || 0;
|
||
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">`;
|
||
}
|
||
let replyHTML = msg.replyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.replyTo})">Replying to message...</div>` : '';
|
||
|
||
msgDiv.innerHTML = `
|
||
<div class="chat-message-header">
|
||
${pfpHTML}
|
||
<span class="chat-message-username">${msg.username}</span>
|
||
<span class="chat-message-timestamp">${new Date(msg.timestamp).toLocaleString()}</span>
|
||
</div>
|
||
${replyHTML}
|
||
<div class="chat-message-content">${msg.content}</div>
|
||
<div class="post-actions">
|
||
<a href="javascript:void(0)" onclick="replyToMessage(${msg.id}, '${msg.username}')">Reply</a>
|
||
</div>
|
||
`;
|
||
messages.appendChild(msgDiv);
|
||
|
||
// Apply grouping
|
||
applyGrouping();
|
||
|
||
// Scroll handling
|
||
if (wasAtBottom) {
|
||
messages.scrollTop = messages.scrollHeight;
|
||
unreadCount = 0;
|
||
updateUnreadBadge();
|
||
} else {
|
||
// User is not at bottom, increment unread
|
||
if (msg.username !== currentUsername) {
|
||
unreadCount++;
|
||
updateUnreadBadge();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Jump to bottom functionality
|
||
function updateUnreadBadge() {
|
||
const badge = document.getElementById('unread-badge');
|
||
if (unreadCount > 0) {
|
||
badge.textContent = unreadCount > 99 ? '99+' : unreadCount;
|
||
badge.style.display = 'flex';
|
||
} else {
|
||
badge.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function jumpToBottom() {
|
||
const messages = document.getElementById('chat-messages');
|
||
messages.scrollTo({
|
||
top: messages.scrollHeight,
|
||
behavior: 'smooth'
|
||
});
|
||
unreadCount = 0;
|
||
updateUnreadBadge();
|
||
}
|
||
|
||
// Detect if user is at bottom
|
||
function checkScrollPosition() {
|
||
const messages = document.getElementById('chat-messages');
|
||
const jumpButton = document.getElementById('jump-to-bottom');
|
||
const threshold = 100;
|
||
|
||
isUserAtBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < threshold;
|
||
|
||
if (isUserAtBottom) {
|
||
jumpButton.classList.remove('visible');
|
||
unreadCount = 0;
|
||
updateUnreadBadge();
|
||
} else {
|
||
jumpButton.classList.add('visible');
|
||
}
|
||
}
|
||
|
||
|
||
function replyToMessage(id, username) {
|
||
replyToId = id;
|
||
const replyIndicator = document.getElementById('reply-indicator');
|
||
const replyUsernameSpan = document.getElementById('reply-username');
|
||
replyUsernameSpan.textContent = `Replying to ${username}`;
|
||
replyIndicator.style.display = 'flex';
|
||
document.getElementById('chat-input-text').focus();
|
||
}
|
||
|
||
function cancelReply() {
|
||
replyToId = -1;
|
||
const replyIndicator = document.getElementById('reply-indicator');
|
||
replyIndicator.style.display = 'none';
|
||
}
|
||
|
||
function scrollToMessage(id) {
|
||
const msgElement = document.getElementById('msg-' + id);
|
||
if (msgElement) {
|
||
msgElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
msgElement.style.transition = 'background-color 0.5s';
|
||
msgElement.style.backgroundColor = '#f582ae';
|
||
setTimeout(() => { msgElement.style.backgroundColor = ''; }, 1000);
|
||
}
|
||
}
|
||
|
||
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;
|
||
suggestions.forEach(username => {
|
||
const item = document.createElement('div');
|
||
item.className = 'autocomplete-item';
|
||
item.textContent = username;
|
||
item.onmousedown = (e) => {
|
||
e.preventDefault();
|
||
completeMention(username);
|
||
popup.style.display = 'none';
|
||
autocompleteActive = false;
|
||
};
|
||
popup.appendChild(item);
|
||
});
|
||
}
|
||
|
||
function completeMention(username) {
|
||
const input = document.getElementById('chat-input-text');
|
||
const text = input.value;
|
||
const caretPos = input.selectionStart;
|
||
const textBeforeCaret = text.substring(0, caretPos);
|
||
const atIndex = textBeforeCaret.lastIndexOf('@');
|
||
|
||
if (atIndex !== -1) {
|
||
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);
|
||
}
|
||
}
|
||
|
||
function getCaretCoordinates(element, position) {
|
||
const mirrorDivId = 'input-mirror-div';
|
||
let 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);
|
||
|
||
const coords = { top: span.offsetTop, left: span.offsetLeft };
|
||
document.body.removeChild(div);
|
||
return coords;
|
||
}
|
||
|
||
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 (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendMessage();
|
||
}
|
||
});
|
||
|
||
window.onload = function() {
|
||
connectWebSocket();
|
||
const messagesContainer = document.getElementById('chat-messages');
|
||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||
|
||
// Apply message grouping on load
|
||
applyGrouping();
|
||
|
||
// Add scroll event listener
|
||
messagesContainer.addEventListener('scroll', checkScrollPosition);
|
||
|
||
// Add jump to bottom button click handler
|
||
document.getElementById('jump-to-bottom').addEventListener('click', jumpToBottom);
|
||
|
||
// Add typing indicator on input
|
||
const chatInput = document.getElementById('chat-input-text');
|
||
let lastTypingTime = 0;
|
||
chatInput.addEventListener('input', () => {
|
||
const now = Date.now();
|
||
if (now - lastTypingTime > 2000) {
|
||
sendTypingIndicator();
|
||
lastTypingTime = now;
|
||
}
|
||
});
|
||
|
||
// Draft auto-save functionality
|
||
const draftKey = 'draft_chat_{{.Board.ID}}';
|
||
let draftSaveTimeout;
|
||
|
||
// Load existing draft on page load
|
||
const existingDraft = loadDraft(draftKey);
|
||
const draftTimestamp = getDraftTimestamp(draftKey);
|
||
if (existingDraft && draftTimestamp) {
|
||
// Check if draft is less than 7 days old
|
||
const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
|
||
if (draftTimestamp > sevenDaysAgo) {
|
||
const indicator = showDraftIndicator(
|
||
existingDraft,
|
||
draftTimestamp,
|
||
(content) => {
|
||
chatInput.value = content;
|
||
chatInput.focus();
|
||
},
|
||
() => {
|
||
clearDraft(draftKey);
|
||
}
|
||
);
|
||
// Insert indicator before chat input
|
||
const chatInputContainer = document.querySelector('.chat-input');
|
||
chatInputContainer.parentNode.insertBefore(indicator, chatInputContainer);
|
||
} else {
|
||
// Draft is too old, clear it
|
||
clearDraft(draftKey);
|
||
}
|
||
}
|
||
|
||
// Save draft on input (debounced)
|
||
chatInput.addEventListener('input', () => {
|
||
clearTimeout(draftSaveTimeout);
|
||
draftSaveTimeout = setTimeout(() => {
|
||
const content = chatInput.value.trim();
|
||
if (content) {
|
||
saveDraft(draftKey, content);
|
||
} else {
|
||
clearDraft(draftKey);
|
||
}
|
||
}, 2000); // Save after 2 seconds of inactivity
|
||
});
|
||
|
||
// Clear draft after successful message send
|
||
const originalSendMessage = window.sendMessage;
|
||
window.sendMessage = function() {
|
||
const input = document.getElementById('chat-input-text');
|
||
const content = input.value.trim();
|
||
if (content !== '') {
|
||
clearDraft(draftKey);
|
||
}
|
||
originalSendMessage();
|
||
};
|
||
|
||
// Initial check for scroll position
|
||
checkScrollPosition();
|
||
};
|
||
|
||
function applyGrouping() {
|
||
const container = document.getElementById('chat-messages');
|
||
const msgs = Array.from(container.querySelectorAll('.chat-message')).filter(el => el.id.startsWith('msg-'));
|
||
|
||
for (let i = 0; i < msgs.length; i++) {
|
||
const curr = msgs[i];
|
||
|
||
// Remove grouped class first
|
||
curr.classList.remove('grouped');
|
||
|
||
if (i === 0) continue; // First message is never grouped
|
||
|
||
const prev = msgs[i - 1];
|
||
const currUser = curr.dataset.user;
|
||
const prevUser = prev.dataset.user;
|
||
const currReply = parseInt(curr.dataset.reply) || -1;
|
||
|
||
// Group if same user and not a reply (reply is -1 or 0 when there's no reply)
|
||
if (currUser === prevUser && (currReply <= 0)) {
|
||
curr.classList.add('grouped');
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
{{end}} |