Cleaned up message rendering a bit

This commit is contained in:
BordedDev 2025-07-17 02:22:47 +02:00
parent 9a0ba22fe8
commit 29b9fce07d
5 changed files with 238 additions and 151 deletions

View File

@ -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("<chat-message"):
await (await self.get(uid=message["uid"])).save()
message["html"] = (await self.get(uid=message["uid"])).html
return {
"uid": message["uid"],
"color": user["color"],
@ -115,14 +124,13 @@ class ChannelMessageService(BaseService):
model["html"] = whitelist_attributes(model["html"])
return await super().save(model)
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return []
history_start_filter = ""
if channel["history_start"]:
history_start_filter = f" AND created_at > '{channel['history_start']}'"
history_start_filter = f" AND created_at > '{channel['history_start']}'"
results = []
offset = page * page_size
try:

View File

@ -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;

View File

@ -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 = `
<a class="avatar" style="background-color: ${color || ''}; color: black;" href="/user/${user_uid || ''}.html">
<img class="avatar-img" width="40" height="40" src="/avatar/${user_uid || ''}.svg" alt="${user_nick || ''}" loading="lazy">
</a>
<div class="message-content">
<div class="author" style="color: ${color || ''};">${user_nick || ''}</div>
<div class="text"></div>
<div class="time no-select" data-created_at="${created_at || ''}">
<span></span>
<a href="#reply">reply</a></div>
</div>
`;
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);

View File

@ -1 +1 @@
<div style="max-width:100%;" data-uid="{{uid}}" data-color="{{color}}" data-channel_uid="{{channel_uid}}" data-user_nick="{{user_nick}}" data-created_at="{{created_at}}" data-user_uid="{{user_uid}}" class="message"><a class="avatar" style="background-color: {{color}}; color: black;" href="/user/{{user_uid}}.html"><img class="avatar-img" width="40px" height="40px" src="/avatar/{{user_uid}}.svg" /></a><div class="message-content"><div class="author" style="color: {{color}};">{{user_nick}}</div><div class="text">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class="time no-select" data-created_at="{{created_at}}"></div></div></div>
<chat-message data-uid="{{uid}}" data-color="{{color}}" data-channel_uid="{{channel_uid}}" data-user_nick="{{user_nick}}" data-created_at="{{created_at}}" data-user_uid="{{user_uid}}">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</chat-message>

View File

@ -25,12 +25,11 @@
</div>
{% endif %}
{% for message in messages %}
{% for message in messages|reverse %}
{% autoescape false %}
{{ message.html }}
{% endautoescape %}
{% endfor %}
<div class="message-list-bottom"></div>
</message-list>
<chat-input live-type="true" channel="{{ channel.uid.value }}"></chat-input>
</section>
@ -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?.();
}