Projects / snek / src / snek / templates /Â web.html
git clone https://molodetz.nl/retoor/snek.git
Raw source file available here .
{% extends "app.html" %}
{% block header_text %} style="color:#fff">{{ name }}{% endblock %}
{% block main %}
{% include "channel.html" %}
class="chat-area">
rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css">
src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js">
src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js">
id="terminal" class="hidden">
id="jump-to-unread-btn" style="display: none; position: absolute; top: 10px; right: 10px; z-index: 1000; padding: 8px 16px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.2);">
Jump to First Unread
class="chat-messages">
{% if not messages %}
Welcome
to your new channel!
This
is the start of something great. Use the commands below to get started:
/invite to invite someone./online to see who's currently online./help to view all available commands.Enjoy chatting!
{% endif %}
{% for message in messages|reverse %}
{% autoescape false %}
{{ message.html }}
{% endautoescape %}
{% endfor %}
{% include "dialog_container.html" %}
{% include "dialog_help.html" %}
{% include "dialog_online.html" %}
{% include "dialog_create_channel.html" %}
{% include "dialog_invite.html" %}
{% include "dialog_leave_channel.html" %}
{% include "dialog_channel_settings.html" %}
import {app} from "/app.js";
// --- Cache selectors ---
const chatInputField = document.querySelector("chat-input");
const messagesContainer = document.querySelector(".chat-messages");
const chatArea = document.querySelector(".chat-area");
const channelUid = "{{ channel.uid.value }}";
const username = "{{ user.username.value }}";
// --- Command completions ---
chatInputField.autoCompletions = {
"/online": showOnline,
"/clear": () => { messagesContainer.innerHTML = ''; },
"/live": () => { chatInputField.liveType = !chatInputField.liveType; },
"/help": showHelp,
"/container": async() => { containerDialog.openWithStatus(); },
"/create": showCreateChannel,
"/invite": showInvite,
"/leave": showLeaveChannel,
"/settings": showChannelSettings
};
// --- Throttle utility ---
function throttle(fn, wait) {
let last = 0;
return function(...args) {
const now = Date.now();
if (now - last >= wait) {
last = now;
fn.apply(this, args);
}
};
}
// --- Scroll: load extra messages, throttled ---
let isLoadingExtra = false;
async function loadExtra() {
const firstMessage = messagesContainer.lastElementChild;
if (isLoadingExtra || !isScrolledPastHalf() || !firstMessage) return;
isLoadingExtra = true;
const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);
if (messages.length) {
const frag = document.createDocumentFragment();
messages.reverse();
messages.forEach(msg => {
const temp = document.createElement("div");
temp.innerHTML = msg.html;
frag.appendChild(temp.firstChild);
});
messagesContainer.appendChild(frag);
updateLayout(false);
}
isLoadingExtra = false;
}
messagesContainer.addEventListener("scroll", throttle(loadExtra, 200));
// --- Only update visible times ---
function updateTimes() {
messagesContainer.updateTimes();
}
setInterval(() => requestIdleCallback(updateTimes), 30000);
// --- Paste & drag/drop uploads ---
const textBox = chatInputField.textarea;
function uploadDataTransfer(dt) {
if (dt.items.length > 0) {
const uploadButton = chatInputField.fileUploadGrid;
for (const item of dt.items) {
if (item.kind === "file") {
const file = item.getAsFile();
if (file) {
uploadButton.uploadsStarted++
uploadButton.createTile(file)
} else {
console.error("Failed to get file from DataTransferItem");
}
}
}
}
}
textBox.addEventListener("paste", async (e) => {
try {
console.log("Pasted data:", e.clipboardData);
if (e.clipboardData.types.every(v => v.startsWith("text/"))) {
const codeType = e.clipboardData.types.find(t => !t.startsWith('text/plain') && !t.startsWith('text/html') && !t.startsWith('text/rtf'));
const probablyCode = codeType ||e.clipboardData.types.some(t => !t.startsWith('text/plain'));
for (const item of e.clipboardData.items) {
if (item.kind === "string" && item.type === "text/plain") {
e.preventDefault();
item.getAsString(text => {
const value = chatInputField.value;
if (probablyCode) {
let code = text;
const minIndentDepth = code.split('\n').reduce((acc, line) => {
if (!line.trim()) return acc;
const match = line.match(/^(\s*)/);
return match ? Math.min(acc, match[1].length) : acc;
}, 9000);
code = code.split('\n').map(line => line.slice(minIndentDepth)).join('\n')
text = `\`\`\`${codeType?.split('/')?.[1] ?? ''}\n${code}\n\`\`\`\n`;
}
const area = chatInputField.textarea
if(area){
const start = area.selectionStart
if ("\n" !== value[start - 1]) {
text = `\n${text}`;
}
document.execCommand('insertText', false, text);
}
});
break;
}
}
} else {
uploadDataTransfer(e.clipboardData);
}
} catch (error) {
console.error("Failed to read clipboard contents: ", error);
}
});
chatArea.addEventListener("drop", async (e) => {
e.preventDefault();
uploadDataTransfer(e.dataTransfer);
});
chatArea.addEventListener("dragover", e => {
e.preventDefault();
e.dataTransfer.dropEffect = "link";
});
// --- Focus input on load ---
chatInputField.textarea.focus();
// --- Reply helper ---
function replyMessage(message) {
chatInputField.value = "```markdown\n> " + (message || '').trim().split("\n").join("\n> ") + "\n```\n";
chatInputField.textarea.dispatchEvent(new Event('change', { bubbles: true }));
chatInputField.focus();
}
messagesContainer.addEventListener("reply", (e) => {
const messageText = e.replyText || e.messageTextTarget.textContent.trim();
replyMessage(messageText);
})
// --- Mention helpers ---
function extractMentions(message) {
return [...new Set(message.match(/@\w+/g) || [])];
}
function isMentionToMe(message) {
return message.toLowerCase().includes('@' + username.toLowerCase());
}
function isMentionForSomeoneElse(message) {
const mentions = extractMentions(message);
return mentions.length > 0 && !mentions.includes('@' + username);
}
// --- WebSocket events ---
app.ws.addEventListener("refresh", (data) => {
app.starField.showNotify(data.message);
setTimeout(() => window.location.reload(), 4000);
});
app.ws.addEventListener("stars_render", (data)=>{
app.starField.renderWord(data.message, { rainbow: true, resolution: 8 });
setTimeout(() => app.starField.shuffleAll(5000), 10000);
})
app.ws.addEventListener("deployed", (data) => {
app.starField.renderWord("Deployed", { rainbow: true, resolution: 8 });
setTimeout(() => app.starField.shuffleAll(5000), 10000);
});
app.ws.addEventListener("starfield.render_word", (data) => {
app.starField.renderWord(data.word, data);
});
// --- Channel message event ---
app.addEventListener("channel-message", (data) => {
let display = data.text && data.text.trim() ? 'block' : 'none';
if (data.channel_uid !== channelUid) {
if (!isMentionForSomeoneElse(data.message)) {
channelSidebar.notify(data);
app.playSound("messageOtherChannel");
}
return;
}
if (data.username !== username) {
if (isMentionToMe(data.message)) {
app.playSound("mention");
} else if (!isMentionForSomeoneElse(data.message)) {
if(data.is_final){
app.playSound("message");
}
}
}
messagesContainer.upsertMessage(data)
app.rpc.markAsRead(channelUid);
});
// --- Keyboard shortcuts ---
let escPressed = false, gPressCount = 0, keyTimeout;
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
gPressCount = 0;
document.activeElement.blur();
}
if (event.key === 'g' && !chatInputField.isActive()) {
gPressCount++;
clearTimeout(keyTimeout);
keyTimeout = setTimeout(() => { gPressCount = 0; }, 300);
if (gPressCount === 2) {
gPressCount = 0;
messagesContainer.lastElementChild?.scrollIntoView({ block: "end", inline: "nearest" });
loadExtra();
}
}
if(event.key == 'i' && !chatInputField.isActive()) {
event.preventDefault();
chatInputField.focus();
}
if(event.key == 'r' && !chatInputField.isActive()) {
event.preventDefault();
const replyLinks = document.querySelectorAll('.time a')
if(replyLinks.length){
const replyLink = replyLinks[replyLinks.length - 1]
replyLink.click()
}
}
if (event.shiftKey && event.key === 'G') {
if (!chatInputField.isActive()) {
event.preventDefault();
updateLayout(true);
setTimeout(() => chatInputField.focus(), 500);
}
}
});
// --- Image click-to-zoom (delegated) ---
// --- Layout update ---
function updateLayout(doScrollDown) {
updateTimes();
if (doScrollDown) messagesContainer.scrollToBottom?.();
}
// --- Utility: check if element is visible ---
// --- Utility: check if scrolled past half ---
function isScrolledPastHalf() {
let scrollTop = messagesContainer.scrollTop;
let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;
return Math.abs(scrollTop) > scrollableHeight / 2;
}
// --- Initial layout update ---
updateLayout(true);
// --- Jump to unread functionality ---
const jumpToUnreadBtn = document.getElementById('jump-to-unread-btn');
let firstUnreadMessageUid = null;
async function checkForUnreadMessages() {
try {
const uid = await app.rpc.getFirstUnreadMessageUid(channelUid);
if (uid) {
firstUnreadMessageUid = uid;
const messageElement = messagesContainer.querySelector(`[data-uid="${uid}"]`);
if (messageElement && !messagesContainer.isElementVisible(messageElement)) {
jumpToUnreadBtn.style.display = 'block';
} else {
jumpToUnreadBtn.style.display = 'none';
}
} else {
jumpToUnreadBtn.style.display = 'none';
}
} catch (error) {
console.error('Error checking for unread messages:', error);
}
}
async function jumpToUnread() {
if (!firstUnreadMessageUid) {
await checkForUnreadMessages();
}
if (firstUnreadMessageUid) {
let messageElement = messagesContainer.querySelector(`[data-uid="${firstUnreadMessageUid}"]`);
if (!messageElement) {
const messages = await app.rpc.getMessages(channelUid, 0, null);
const targetMessage = messages.find(m => m.uid === firstUnreadMessageUid);
if (targetMessage) {
const temp = document.createElement("div");
temp.innerHTML = targetMessage.html;
const newMessageElement = temp.firstChild;
messagesContainer.endOfMessages.after(newMessageElement);
messagesContainer.messageMap.set(targetMessage.uid, newMessageElement);
messagesContainer._observer.observe(newMessageElement);
messageElement = newMessageElement;
}
}
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
messageElement.style.animation = 'highlight-fade 2s';
setTimeout(() => {
messageElement.style.animation = '';
}, 2000);
jumpToUnreadBtn.style.display = 'none';
}
}
}
jumpToUnreadBtn.addEventListener('click', jumpToUnread);
checkForUnreadMessages();
const style = document.createElement('style');
style.textContent = `
@keyframes highlight-fade {
0% { background-color: rgba(255, 255, 0, 0.3); }
100% { background-color: transparent; }
}
`;
document.head.appendChild(style);
{% endblock %}