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

504 lines
19 KiB
HTML

{{define "chat"}}
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<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: calc(100% - 2em); /* Adjust for header */
display: flex;
flex-direction: column;
border: none;
border-radius: 0;
background-color: #fef6e4;
box-shadow: none;
}
.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;
background-color: #001858; /* Default for no PFP */
flex-shrink: 0; /* Prevent shrinking */
}
.chat-message-pfp img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.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; /* Ensure long words break */
}
.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; /* Highlight color for mentions */
font-weight: bold;
}
.chat-input {
padding: 8px;
border-top: 1px solid #001858;
display: flex;
flex-direction: column;
}
.chat-input textarea {
width: 100%;
box-sizing: border-box;
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;
bottom: 100%; /* Position above the textarea */
left: 0;
right: 0;
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;
margin-bottom: 8px; /* Space between popup and textarea */
}
.autocomplete-item {
padding: 6px 10px;
cursor: pointer;
font-size: 0.9em;
}
.autocomplete-item:hover, .autocomplete-item:focus {
background-color: #f3d2c1;
outline: none; /* Remove default focus outline */
}
.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 wrapper for autocomplete positioning */
.chat-input-wrapper {
position: relative;
width: 100%;
}
@media (prefers-color-scheme: dark) {
.chat-container {
background-color: #444;
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, .autocomplete-item:focus {
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;
}
}
</style>
</head>
<body>
{{template "navbar" .}}
<main>
<header style="display: none;">
<h2>General Chat</h2>
</header>
<div class="chat-container">
<div class="chat-messages" id="chat-messages">
{{range .Messages}}
<div class="chat-message" id="msg-{{.ID}}">
<div class="chat-message-header">
{{if .PfpURL}}
<div class="chat-message-pfp"><img src="{{.PfpURL}}" alt="PFP"></div>
{{else}}
<div class="chat-message-pfp"></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 {{.Username}}</div>
{{end}}
<div class="chat-message-content">{{.FormattedContent}}</div> <!-- Use FormattedContent -->
<div class="post-actions">
<a href="javascript:void(0)" onclick="replyToMessage({{.ID}}, '{{.Username}}')">Reply</a>
</div>
</div>
{{end}}
</div>
<div class="chat-input">
<div id="reply-indicator" class="reply-indicator">
<span id="reply-username">Replying to </span>
<button onclick="cancelReply()">X</button>
</div>
<div class="chat-input-wrapper"> <!-- New wrapper for autocomplete positioning -->
<textarea id="chat-input-text" placeholder="Type a message..."></textarea>
<div id="autocomplete-popup" class="autocomplete-popup"></div>
</div>
<button onclick="sendMessage()">Send</button>
</div>
</div>
</main>
{{template "cookie_banner" .}}
<script>
let ws;
let autocompleteActive = false;
let autocompleteCurrentPrefix = ''; // Track the current prefix being typed
let autocompleteRangeStart = -1; // Start index of the @mention in the textarea
let replyToId = -1;
let replyUsername = '';
function connectWebSocket() {
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true', [], { credentials: 'include' });
ws.onmessage = function(event) {
const msg = JSON.parse(event.data);
appendMessage(msg);
};
ws.onclose = function() {
console.log("WebSocket closed, reconnecting...");
setTimeout(connectWebSocket, 5000); // Reconnect after 5s
};
ws.onerror = function(error) {
console.error("WebSocket error:", error);
};
}
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(); // Reset reply state after sending
} else {
console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined');
}
hideAutocompletePopup(); // Ensure popup is hidden after sending
}
function appendMessage(msg) {
const messages = document.getElementById('chat-messages');
const msgDiv = document.createElement('div');
msgDiv.className = 'chat-message';
msgDiv.id = 'msg-' + msg.ID;
let pfpHTML = msg.PfpURL ? `<img src="${msg.PfpURL}" alt="PFP">` : ``; // PFP div has default background
let replyHTML = msg.ReplyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.ReplyTo})">Replying to ${msg.Username}</div>` : '';
// Use msg.FormattedContent directly as it's already processed on the server
msgDiv.innerHTML = `
<div class="chat-message-header">
<div class="chat-message-pfp">${pfpHTML}</div>
<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.FormattedContent}</div> <!-- Use FormattedContent -->
<div class="post-actions">
<a href="javascript:void(0)" onclick="replyToMessage(${msg.ID}, '${msg.Username}')">Reply</a>
</div>
`;
messages.appendChild(msgDiv);
messages.scrollTop = messages.scrollHeight;
}
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}`;
replyIndicator.style.display = 'flex';
document.getElementById('chat-input-text').focus();
}
function cancelReply() {
replyToId = -1;
replyUsername = '';
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' }); // block: 'center' for better visibility
// Optional: briefly highlight the message
msgElement.style.outline = '2px solid #f582ae';
setTimeout(() => {
msgElement.style.outline = 'none';
}, 1500);
}
}
function showAutocompletePopup(usernames) {
const popup = document.getElementById('autocomplete-popup');
popup.innerHTML = '';
usernames.forEach(username => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.textContent = `@${username}`; // Display with @ prefix
item.setAttribute('tabindex', '0'); // Make focusable
item.onclick = () => {
completeMention(username);
hideAutocompletePopup();
};
popup.appendChild(item);
});
popup.style.display = usernames.length > 0 ? 'block' : 'none';
autocompleteActive = usernames.length > 0;
}
function hideAutocompletePopup() {
document.getElementById('autocomplete-popup').style.display = 'none';
autocompleteActive = false;
autocompleteCurrentPrefix = '';
autocompleteRangeStart = -1;
}
function completeMention(username) {
const input = document.getElementById('chat-input-text');
const text = input.value;
if (autocompleteRangeStart !== -1) {
const before = text.substring(0, autocompleteRangeStart);
const after = text.substring(input.selectionStart);
input.value = before + '@' + username + ' ' + after;
input.selectionStart = input.selectionEnd = before.length + username.length + 2; // Move caret after the space
input.focus();
}
hideAutocompletePopup();
}
document.getElementById('chat-input-text').addEventListener('input', async (e) => {
const text = e.target.value;
const caretPos = e.target.selectionStart;
// Find the last '@' before the caret that is part of a mention
let atIndex = -1;
for (let i = caretPos - 1; i >= 0; i--) {
if (text[i] === '@') {
// Check if the character before @ is a space or start of string
const charBefore = i > 0 ? text[i-1] : ' ';
if (charBefore === ' ' || charBefore === '\n' || charBefore === '\t') { // Added tab check
atIndex = i;
break;
}
}
// Stop if we hit a space or newline before an '@' (outside of a valid @mention context)
if (text[i] === ' ' || text[i] === '\n' || text[i] === '\t') { // Added tab check
break;
}
}
if (atIndex !== -1 && atIndex < caretPos) { // Ensure @ is before caret
const prefix = text.substring(atIndex + 1, caretPos);
// Only trigger if prefix contains valid characters (alphanumeric, underscore)
if (prefix.match(/^[\w]*$/)) {
autocompleteCurrentPrefix = prefix;
autocompleteRangeStart = atIndex;
const response = await fetch('{{.BasePath}}/chat/?autocomplete=true&prefix=' + encodeURIComponent(prefix));
const usernames = await response.json();
showAutocompletePopup(usernames);
return; // Exit after handling autocomplete
}
}
hideAutocompletePopup(); // Hide if no valid @mention in progress
});
document.getElementById('chat-input-text').addEventListener('keydown', (e) => {
if (autocompleteActive) {
const popup = document.getElementById('autocomplete-popup');
const items = Array.from(popup.getElementsByClassName('autocomplete-item'));
let currentFocus = document.activeElement;
let currentIndex = items.indexOf(currentFocus);
if (e.key === 'ArrowDown') {
e.preventDefault();
if (items.length > 0) {
currentIndex = (currentIndex + 1) % items.length;
items[currentIndex].focus();
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (items.length > 0) {
currentIndex = (currentIndex - 1 + items.length) % items.length;
items[currentIndex].focus();
}
} else if (e.key === 'Enter' || e.key === 'Tab') { // Added Tab to accept
e.preventDefault();
if (currentFocus && currentFocus.classList.contains('autocomplete-item')) {
currentFocus.click();
} else if (items.length > 0) {
items[0].click(); // Select first item if nothing specific focused
}
} else if (e.key === 'Escape') {
hideAutocompletePopup();
e.preventDefault();
}
} else if (e.key === 'Enter' && !e.shiftKey) {
sendMessage();
e.preventDefault();
}
});
document.addEventListener('click', (e) => {
if (!e.target.closest('#autocomplete-popup') && !e.target.closest('#chat-input-text')) {
hideAutocompletePopup();
}
});
// Connect WebSocket on page load
window.onload = function() {
connectWebSocket();
const chatMessagesDiv = document.getElementById('chat-messages');
chatMessagesDiv.scrollTop = chatMessagesDiv.scrollHeight;
};
</script>
</body>
</html>
{{end}}