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:

  • Press
  • /invite to invite someone.
  • Press
  • /online to see who's currently online.
  • Press /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 %}