diff --git a/src/snek/static/base.css b/src/snek/static/base.css index 423471a..a1c6d30 100644 --- a/src/snek/static/base.css +++ b/src/snek/static/base.css @@ -368,7 +368,7 @@ input[type="text"], .chat-input textarea { } } -.message.switch-user + .message, .message.long-time + .message, .message:first-child { +.message.switch-user + .message, .message.long-time + .message, .message-list-bottom + .message{ .time { display: block; opacity: 1; diff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js index f8352f0..db01550 100644 --- a/src/snek/static/message-list.js +++ b/src/snek/static/message-list.js @@ -9,8 +9,53 @@ import {app} from "./app.js"; const LONG_TIME = 1000 * 60 * 20 +export class ReplyEvent extends Event { + constructor(messageTextTarget) { + super('reply', { bubbles: true, composed: true }); + this.messageTextTarget = messageTextTarget; + + const newMessage = messageTextTarget.cloneNode(true); + newMessage.style.maxHeight = "0" + messageTextTarget.parentElement.insertBefore(newMessage, messageTextTarget); + + newMessage.querySelectorAll('.embed-url-link').forEach(link => { + link.remove() + }) + + newMessage.querySelectorAll('picture').forEach(picture => { + const img = picture.querySelector('img'); + if (img) { + picture.replaceWith(img); + } + }) + + newMessage.querySelectorAll('img').forEach(img => { + const src = img.src || img.currentSrc; + img.replaceWith(document.createTextNode(src)); + }) + + newMessage.querySelectorAll('iframe').forEach(iframe => { + const src = iframe.src || iframe.currentSrc; + iframe.replaceWith(document.createTextNode(src)); + }) + + newMessage.querySelectorAll('a').forEach(a => { + const href = a.getAttribute('href'); + const text = a.innerText || a.textContent; + if (text === href || text === '') { + a.replaceWith(document.createTextNode(href)); + } else { + a.replaceWith(document.createTextNode(`[${text}](${href})`)); + } + }) + + this.replyText = newMessage.innerText.replaceAll("\n\n", "\n").trim(); + newMessage.remove() + } +} + class MessageElement extends HTMLElement { - static observedAttributes = ['data-uid', 'data-color', 'data-channel_uid', 'data-user_nick', 'data-created_at', 'data-user_uid']; + // static observedAttributes = ['data-uid', 'data-color', 'data-channel_uid', 'data-user_nick', 'data-created_at', 'data-user_uid']; isVisible() { if (!this) return false; @@ -51,6 +96,12 @@ class MessageElement extends HTMLElement { } this.timeDiv = this.querySelector('.time span'); + this.replyDiv = this.querySelector('.time a'); + + this.replyDiv.addEventListener('click', (e) => { + e.preventDefault(); + this.dispatchEvent(new ReplyEvent(this.messageDiv)); + }) } if (!this.siblingGenerated && this.nextElementSibling) { @@ -99,7 +150,9 @@ class MessageList extends HTMLElement { constructor() { super(); app.ws.addEventListener("update_message_text", (data) => { - this.upsertMessage(data); + if (this.messageMap.has(data.uid)) { + this.upsertMessage(data); + } }); app.ws.addEventListener("set_typing", (data) => { this.triggerGlow(data.user_uid,data.color); @@ -108,29 +161,33 @@ class MessageList extends HTMLElement { this.messageMap = new Map(); this.visibleSet = new Set(); this._observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - this.visibleSet.add(entry.target); - const messageElement = entry.target; - if (messageElement instanceof MessageElement) { - messageElement.updateUI(); - } - } else { - this.visibleSet.delete(entry.target); + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.visibleSet.add(entry.target); + const messageElement = entry.target; + if (messageElement instanceof MessageElement) { + messageElement.updateUI(); } - }); - console.log(this.visibleSet); + } else { + this.visibleSet.delete(entry.target); + } + }); }, { - root: this, - threshold: 0.1 + root: this, + threshold: 0, }) for(const c of this.children) { this._observer.observe(c); - if (c instanceof MessageElement) { - this.messageMap.set(c.dataset.uid, c); - } + if (c instanceof MessageElement) { + this.messageMap.set(c.dataset.uid, c); + } } + + this.endOfMessages = document.createElement('div'); + this.endOfMessages.classList.add('message-list-bottom'); + this.prepend(this.endOfMessages); + this.scrollToBottom(true); } @@ -174,7 +231,6 @@ class MessageList extends HTMLElement { }; document.addEventListener('keydown', escListener); }) - } isElementVisible(element) { if (!element) return false; @@ -187,13 +243,13 @@ class MessageList extends HTMLElement { ); } isScrolledToBottom() { - return this.isElementVisible(this.firstElementChild); + return this.isElementVisible(this.endOfMessages); } - scrollToBottom(force = false, behavior= 'smooth') { - if (force || this.isScrolledToBottom()) { - this.firstElementChild.scrollIntoView({ behavior, block: 'start' }); + scrollToBottom(force = false, behavior= 'instant') { + if (force || !this.isScrolledToBottom()) { + this.firstElementChild.scrollIntoView({ behavior, block: 'end' }); setTimeout(() => { - this.firstElementChild.scrollIntoView({ behavior, block: 'start' }); + this.firstElementChild.scrollIntoView({ behavior, block: 'end' }); }, 200); } } @@ -227,9 +283,8 @@ class MessageList extends HTMLElement { upsertMessage(data) { let message = this.messageMap.get(data.uid); - const newMessage = !!message; if (message) { - message.parentElement.removeChild(message); + message.parentElement?.removeChild(message); } if (!data.message) return @@ -239,16 +294,16 @@ class MessageList extends HTMLElement { wrapper.innerHTML = data.html; if (message) { - message.updateMessage(...wrapper.firstElementChild._originalChildren); + message.updateMessage(...(wrapper.firstElementChild._originalChildren || wrapper.firstElementChild.children)); } else { - message = wrapper.firstElementChild; - this.messageMap.set(data.uid, message); - this._observer.observe(message); + message = wrapper.firstElementChild; + this.messageMap.set(data.uid, message); + this._observer.observe(message); } const scrolledToBottom = this.isScrolledToBottom(); this.prepend(message); - if (scrolledToBottom) this.scrollToBottom(true, !newMessage ? 'smooth' : 'auto'); + if (scrolledToBottom) this.scrollToBottom(true); } } diff --git a/src/snek/templates/web.html b/src/snek/templates/web.html index 46a0593..0e60bec 100644 --- a/src/snek/templates/web.html +++ b/src/snek/templates/web.html @@ -72,12 +72,13 @@ function throttle(fn, wait) { // --- Scroll: load extra messages, throttled --- let isLoadingExtra = false; async function loadExtra() { - const firstMessage = messagesContainer.children[messagesContainer.children.length - 1]; + 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; @@ -138,10 +139,16 @@ chatInputField.textarea.focus(); // --- Reply helper --- function replyMessage(message) { - chatInputField.value = "```markdown\n> " + (message || '').split("\n").join("\n> ") + "\n```\n"; + 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) || [])]; @@ -215,7 +222,7 @@ document.addEventListener('keydown', function(event) { keyTimeout = setTimeout(() => { gPressCount = 0; }, 300); if (gPressCount === 2) { gPressCount = 0; - messagesContainer.querySelector(".message:first-child")?.scrollIntoView({ block: "end", inline: "nearest" }); + messagesContainer.lastElementChild?.scrollIntoView({ block: "end", inline: "nearest" }); loadExtra(); } } @@ -254,7 +261,7 @@ function updateLayout(doScrollDown) { function isScrolledPastHalf() { let scrollTop = messagesContainer.scrollTop; let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight; - return scrollTop < scrollableHeight / 2; + return Math.abs(scrollTop) > scrollableHeight / 2; } // --- Initial layout update ---