threadr.lostcave.ddnss.de/templates/pages/chat.html

905 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

{{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}}">
<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 tempId = 'temp-' + Date.now();
const msg = {
type: 'message',
content: content,
replyTo: replyToId
};
// Show message immediately with "sending" status
const tempMsg = {
id: tempId,
content: content,
username: currentUsername,
timestamp: new Date().toISOString(),
replyTo: replyToId,
status: 'sending'
};
appendMessage(tempMsg, true);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
input.value = '';
cancelReply();
// Update status to "sent" after a brief delay
setTimeout(() => {
const msgElement = document.getElementById('msg-' + tempId);
if (msgElement) {
const statusSpan = msgElement.querySelector('.message-status');
if (statusSpan) {
statusSpan.className = 'message-status sent';
statusSpan.textContent = '✓';
}
}
}, 500);
} else {
// Mark as failed if not connected
setTimeout(() => {
const msgElement = document.getElementById('msg-' + tempId);
if (msgElement) {
const statusSpan = msgElement.querySelector('.message-status');
if (statusSpan) {
statusSpan.className = 'message-status failed';
statusSpan.textContent = '✗ Failed - Click to retry';
statusSpan.onclick = () => {
msgElement.remove();
sendMessage();
};
}
}
}, 500);
console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined');
}
}
// 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, isOptimistic = false) {
const messages = document.getElementById('chat-messages');
// Don't duplicate if this is a real message and we already have the optimistic one
if (!isOptimistic && document.getElementById('msg-temp-' + msg.id)) {
const tempMsg = document.getElementById('msg-temp-' + msg.id);
tempMsg.remove();
}
// 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;
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>` : '';
let statusHTML = '';
if (isOptimistic) {
statusHTML = `<span class="message-status sending">⋯</span>`;
}
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>
${statusHTML}
</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);
// Scroll handling
if (wasAtBottom) {
messages.scrollTop = messages.scrollHeight;
unreadCount = 0;
updateUnreadBadge();
} else {
// User is not at bottom, increment unread
if (!isOptimistic && 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;
// 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;
}
});
// Initial check for scroll position
checkScrollPosition();
};
</script>
</body>
</html>
{{end}}