diff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py index 8b28542..e0f5edf 100644 --- a/src/snek/service/channel_message.py +++ b/src/snek/service/channel_message.py @@ -5,10 +5,9 @@ from snek.system.template import whitelist_attributes class ChannelMessageService(BaseService): mapper_name = "channel_message" - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._configured_indexes = False + self._configured_indexes = False async def maintenance(self): args = {} @@ -18,7 +17,7 @@ class ChannelMessageService(BaseService): html = message["html"] await self.save(message) - self.mapper.db['channel_message'].upsert( + self.mapper.db["channel_message"].upsert( { "uid": message["uid"], "updated_at": updated_at, @@ -27,7 +26,7 @@ class ChannelMessageService(BaseService): ) if html != message["html"]: print("Reredefined message", message["uid"]) - + while True: changed = 0 async for message in self.find(is_final=False): @@ -41,7 +40,6 @@ class ChannelMessageService(BaseService): if not changed: break - async def create(self, channel_uid, user_uid, message, is_final=True): model = await self.new() @@ -72,13 +70,19 @@ class ChannelMessageService(BaseService): if await super().save(model): if not self._configured_indexes: - if not self.mapper.db["channel_message"].has_index(['is_final','user_uid','channel_uid']): - self.mapper.db["channel_message"].create_index(['is_final','user_uid','channel_uid'], unique=False) - if not self.mapper.db["channel_message"].has_index(['uid']): - self.mapper.db["channel_message"].create_index(['uid'], unique=True) - if not self.mapper.db["channel_message"].has_index(['deleted_at']): - self.mapper.db["channel_message"].create_index(['deleted_at'], unique=False) - self._configured_indexes = True + if not self.mapper.db["channel_message"].has_index( + ["is_final", "user_uid", "channel_uid"] + ): + self.mapper.db["channel_message"].create_index( + ["is_final", "user_uid", "channel_uid"], unique=False + ) + if not self.mapper.db["channel_message"].has_index(["uid"]): + self.mapper.db["channel_message"].create_index(["uid"], unique=True) + if not self.mapper.db["channel_message"].has_index(["deleted_at"]): + self.mapper.db["channel_message"].create_index( + ["deleted_at"], unique=False + ) + self._configured_indexes = True return model raise Exception(f"Failed to create channel message: {model.errors}.") @@ -86,6 +90,11 @@ class ChannelMessageService(BaseService): user = await self.services.user.get(uid=message["user_uid"]) if not user: return {} + + if not message["html"].startswith(" '{channel['history_start']}'" + history_start_filter = f" AND created_at > '{channel['history_start']}'" results = [] offset = page * page_size try: diff --git a/src/snek/static/base.css b/src/snek/static/base.css index fa74fd3..423471a 100644 --- a/src/snek/static/base.css +++ b/src/snek/static/base.css @@ -144,7 +144,7 @@ footer { .chat-messages { display: flex; - flex-direction: column; + flex-direction: column-reverse; } .container { @@ -368,7 +368,7 @@ input[type="text"], .chat-input textarea { } } -.message:has(+ .message.switch-user), .message:has(+ .message.long-time), .message:not(:has(+ .message)) { +.message.switch-user + .message, .message.long-time + .message, .message:first-child { .time { display: block; opacity: 1; diff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js index 1a4c895..1ecabbd 100644 --- a/src/snek/static/message-list.js +++ b/src/snek/static/message-list.js @@ -5,112 +5,252 @@ // The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application. // MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty. -import { app } from "../app.js"; +import {app} from "./app.js"; + +const LONG_TIME = 1000 * 60 * 20 + +class MessageElement extends HTMLElement { + static observedAttributes = ['data-uid', 'data-color', 'data-channel_uid', 'data-user_nick', 'data-created_at', 'data-user_uid']; + + isVisible() { + if (!this) return false; + const rect = this.getBoundingClientRect(); + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + ); + } + + updateUI() { + if (this._originalChildren === undefined) { + const { color, user_nick, created_at, user_uid} = this.dataset; + this.classList.add('message'); + this.style.maxWidth = '100%'; + this._originalChildren = Array.from(this.children); + this.innerHTML = ` + + ${user_nick || ''} + +
+
${user_nick || ''}
+
+
+ + reply
+
+ `; + + this.messageDiv = this.querySelector('.text'); + + if (this._originalChildren && this._originalChildren.length > 0) { + this._originalChildren.forEach(child => { + this.messageDiv.appendChild(child); + }); + } + + this.timeDiv = this.querySelector('.time span'); + } + + if (!this.siblingGenerated && this.nextElementSibling) { + this.siblingGenerated = true; + if (this.nextElementSibling?.dataset?.user_uid !== this.dataset.user_uid) { + this.classList.add('switch-user'); + } else { + this.classList.remove('switch-user'); + const siblingTime = new Date(this.nextElementSibling.dataset.created_at); + const currentTime = new Date(this.dataset.created_at); + + if (currentTime.getTime() - siblingTime.getTime() > LONG_TIME) { + this.classList.add('long-time'); + } else { + this.classList.remove('long-time'); + } + } + } + + this.timeDiv.innerText = app.timeDescription(this.dataset.created_at); + } + + updateMessage(...messages) { + if (this._originalChildren) { + this.messageDiv.replaceChildren(...messages) + this._originalChildren = messages; + } + } + + connectedCallback() { + this.updateUI(); + } + + disconnectedCallback() { + } + + connectedMoveCallback() { + } + + attributeChangedCallback(name, oldValue, newValue) { + this.updateUI() + } +} + class MessageList extends HTMLElement { constructor() { super(); app.ws.addEventListener("update_message_text", (data) => { - this.updateMessageText(data.uid, data); + this.upsertMessage(data); }); app.ws.addEventListener("set_typing", (data) => { this.triggerGlow(data.user_uid,data.color); }); - this.items = []; + 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); + } + }); + console.log(this.visibleSet); + }, { + root: this, + threshold: 0.1 + }) + + for(const c of this.children) { + this._observer.observe(c); + if (c instanceof MessageElement) { + this.messageMap.set(c.dataset.uid, c); + } + } + this.scrollToBottom(true); } - connectedCallback() { - const messagesContainer = this - messagesContainer.addEventListener('click', (e) => { - if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return; + connectedCallback() { + this.addEventListener('click', (e) => { + if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return; - const img = e.target; + const img = e.target; - const overlay = document.createElement('div'); - overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;' + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;cursor:pointer;'; - const urlObj = new URL(img.currentSrc || img.src) - urlObj.searchParams.delete("width"); - urlObj.searchParams.delete("height"); + const urlObj = new URL(img.currentSrc || img.src, window.location.origin); + urlObj.searchParams.delete('width'); + urlObj.searchParams.delete('height'); - const fullImg = document.createElement('img'); + const fullImg = document.createElement('img'); + fullImg.src = urlObj.toString(); + fullImg.alt = img.alt || ''; + fullImg.style.maxWidth = '90%'; + fullImg.style.maxHeight = '90%'; + fullImg.style.boxShadow = '0 0 32px #000'; + fullImg.style.borderRadius = '8px'; + fullImg.style.background = '#222'; + fullImg.style.objectFit = 'contain'; + fullImg.loading = 'lazy'; - fullImg.src = urlObj.toString(); - fullImg.alt = img.alt; - fullImg.style.maxWidth = '90%'; - fullImg.style.maxHeight = '90%'; - - overlay.appendChild(fullImg); - document.body.appendChild(overlay); - overlay.addEventListener('click', () => document.body.removeChild(overlay)); + overlay.appendChild(fullImg); + document.body.appendChild(overlay); + overlay.addEventListener('click', () => { + if (overlay.parentNode) { + overlay.parentNode.removeChild(overlay); + } + }); + // Optional: ESC key closes overlay + const escListener = (evt) => { + if (evt.key === 'Escape') { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + document.removeEventListener('keydown', escListener); + } + }; + document.addEventListener('keydown', escListener); }) } isElementVisible(element) { + if (!element) return false; const rect = element.getBoundingClientRect(); return ( - rect.top >= 0 && - rect.left >= 0 && - rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && - rect.right <= (window.innerWidth || document.documentElement.clientWidth) + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) ); -} + } isScrolledToBottom() { - return this.isElementVisible(this.querySelector(".message-list-bottom")); + return this.isElementVisible(this.firstElementChild); } - scrollToBottom(force) { - //this.scrollTop = this.scrollHeight; - - this.querySelector(".message-list-bottom").scrollIntoView(); - this.querySelector(".message-list-bottom").scrollIntoView(); + scrollToBottom(force = false, behavior= 'smooth') { + if (force || this.isScrolledToBottom()) { + this.firstElementChild.scrollIntoView({ behavior, block: 'end' }); setTimeout(() => { - - // this.scrollTop = this.scrollHeight; - this.querySelector(".message-list-bottom").scrollIntoView(); - },200) - } - updateMessageText(uid, message) { - const messageDiv = this.querySelector('div[data-uid="' + uid + '"]'); - - if (!messageDiv) { - return; + this.firstElementChild.scrollIntoView({ behavior, block: 'end' }); + }, 200); } - const scrollToBottom = this.isScrolledToBottom(); - const receivedHtml = document.createElement("div"); - receivedHtml.innerHTML = message.html; - const html = receivedHtml.querySelector(".text").innerHTML; - const textElement = messageDiv.querySelector(".text"); - textElement.innerHTML = html; - textElement.style.display = message.text == "" ? "none" : "block"; - if(scrollToBottom) - this.scrollToBottom(true) } - triggerGlow(uid,color) { - app.starField.glowColor(color) - let lastElement = null; - this.querySelectorAll(".avatar").forEach((el) => { - const div = el.closest("a"); - if (el.href.indexOf(uid) != -1) { + + triggerGlow(uid, color) { + if (!uid || !color) return; + app.starField.glowColor(color); + let lastElement = null; + this.querySelectorAll('.avatar').forEach((el) => { + const anchor = el.closest('a'); + if (anchor && typeof anchor.href === 'string' && anchor.href.includes(uid)) { lastElement = el; } }); if (lastElement) { - lastElement.classList.add("glow"); + lastElement.classList.add('glow'); setTimeout(() => { - lastElement.classList.remove("glow"); + lastElement.classList.remove('glow'); }, 1000); } } - set data(items) { - this.items = items; - this.render(); - } - render() { - this.innerHTML = ""; - //this.insertAdjacentHTML("beforeend", html); + updateTimes() { + this.visibleSet.forEach((messageElement) => { + if (messageElement instanceof MessageElement) { + messageElement.updateUI(); + } + }) + } + + upsertMessage(data) { + let message = this.messageMap.get(data.uid); + const newMessage = !!message; + if (message) { + message.parentElement.removeChild(message); + } + + if (!data.message) return + + const wrapper = document.createElement("div"); + + wrapper.innerHTML = data.html; + + if (message) { + message.updateMessage(...wrapper.firstElementChild._originalChildren); + } else { + 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'); } } +customElements.define("chat-message", MessageElement); customElements.define("message-list", MessageList); diff --git a/src/snek/templates/message.html b/src/snek/templates/message.html index a5a0f3b..be60de5 100644 --- a/src/snek/templates/message.html +++ b/src/snek/templates/message.html @@ -1 +1 @@ -
{{user_nick}}
{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}
+{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %} \ No newline at end of file diff --git a/src/snek/templates/web.html b/src/snek/templates/web.html index 81b2abb..46a0593 100644 --- a/src/snek/templates/web.html +++ b/src/snek/templates/web.html @@ -25,12 +25,11 @@ {% endif %} - {% for message in messages %} + {% for message in messages|reverse %} {% autoescape false %} {{ message.html }} {% endautoescape %} {% endfor %} -
@@ -73,7 +72,7 @@ function throttle(fn, wait) { // --- Scroll: load extra messages, throttled --- let isLoadingExtra = false; async function loadExtra() { - const firstMessage = messagesContainer.querySelector(".message:first-child"); + const firstMessage = messagesContainer.children[messagesContainer.children.length - 1]; if (isLoadingExtra || !isScrolledPastHalf() || !firstMessage) return; isLoadingExtra = true; const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at); @@ -84,7 +83,7 @@ async function loadExtra() { temp.innerHTML = msg.html; frag.appendChild(temp.firstChild); }); - firstMessage.parentNode.insertBefore(frag, firstMessage); + messagesContainer.appendChild(frag); updateLayout(false); } isLoadingExtra = false; @@ -93,32 +92,7 @@ messagesContainer.addEventListener("scroll", throttle(loadExtra, 200)); // --- Only update visible times --- function updateTimes() { - const containers = messagesContainer.querySelectorAll(".time"); - const viewportHeight = window.innerHeight || document.documentElement.clientHeight; - containers.forEach(container => { - const rect = container.getBoundingClientRect(); - if (rect.top >= 0 && rect.bottom <= viewportHeight) { - const messageDiv = container.closest('.message'); - let text = messageDiv.querySelector(".text").innerText; - const time = document.createElement("span"); - time.innerText = app.timeDescription(container.dataset.created_at); - messageDiv.querySelector(".text").querySelectorAll("img").forEach(img => { - text += " " + img.src - }) - - - - container.replaceChildren(time); - const reply = document.createElement("a"); - reply.innerText = " reply"; - reply.href = "#reply"; - container.appendChild(reply); - reply.addEventListener('click', e => { - e.preventDefault(); - replyMessage(text); - }); - } - }); + messagesContainer.updateTimes(); } setInterval(() => requestIdleCallback(updateTimes), 30000); @@ -164,7 +138,7 @@ chatInputField.textarea.focus(); // --- Reply helper --- function replyMessage(message) { - chatInputField.value = "```markdown\n> " + (message || '') + "\n```\n"; + chatInputField.value = "```markdown\n> " + (message || '').split("\n").join("\n> ") + "\n```\n"; chatInputField.focus(); } @@ -223,22 +197,8 @@ app.addEventListener("channel-message", (data) => { } } } - const lastElement = messagesContainer.querySelector(".message-list-bottom"); - const doScrollDown = messagesContainer.isScrolledToBottom(); - - const oldMessage = messagesContainer.querySelector(`.message[data-uid="${data.uid}"]`); - if (oldMessage) { - oldMessage.remove(); - } - const message = document.createElement("div"); - - - message.innerHTML = data.html; - message.style.display = display; - messagesContainer.insertBefore(message.firstChild, lastElement); - updateLayout(doScrollDown); - setTimeout(() => updateLayout(doScrollDown), 1000); + messagesContainer.upsertMessage(data) app.rpc.markAsRead(channelUid); }); @@ -284,27 +244,6 @@ document.addEventListener('keydown', function(event) { // --- Layout update --- function updateLayout(doScrollDown) { updateTimes(); - let previousUser = null, previousDate = null; - messagesContainer.querySelectorAll(".message").forEach((message) => { - if (previousUser !== message.dataset.user_uid) { - message.classList.add("switch-user"); - previousUser = message.dataset.user_uid; - previousDate = new Date(message.dataset.created_at); - } else { - message.classList.remove("switch-user"); - if (!previousDate) { - previousDate = new Date(message.dataset.created_at); - } else { - const currentDate = new Date(message.dataset.created_at); - if (currentDate.getTime() - previousDate.getTime() > 1000 * 60 * 20) { - message.classList.add("long-time"); - } else { - message.classList.remove("long-time"); - } - previousDate = currentDate; - } - } - }); if (doScrollDown) messagesContainer.scrollToBottom?.(); }