This commit is contained in:
retoor 2025-07-30 02:51:26 +02:00
parent 6337350b60
commit 59b0494328
2 changed files with 98 additions and 91 deletions

View File

@ -5,40 +5,42 @@
// 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. // 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. // 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 const LONG_TIME = 1000 * 60 * 20;
export class ReplyEvent extends Event { export class ReplyEvent extends Event {
constructor(messageTextTarget) { constructor(messageTextTarget) {
super('reply', { bubbles: true, composed: true }); super('reply', { bubbles: true, composed: true });
this.messageTextTarget = messageTextTarget; this.messageTextTarget = messageTextTarget;
// Clone and sanitize message node to text-only reply
const newMessage = messageTextTarget.cloneNode(true); const newMessage = messageTextTarget.cloneNode(true);
newMessage.style.maxHeight = "0" newMessage.style.maxHeight = "0";
messageTextTarget.parentElement.insertBefore(newMessage, messageTextTarget); messageTextTarget.parentElement.insertBefore(newMessage, messageTextTarget);
newMessage.querySelectorAll('.embed-url-link').forEach(link => { // Remove all .embed-url-link
link.remove() newMessage.querySelectorAll('.embed-url-link').forEach(link => link.remove());
})
// Replace <picture> with their <img>
newMessage.querySelectorAll('picture').forEach(picture => { newMessage.querySelectorAll('picture').forEach(picture => {
const img = picture.querySelector('img'); const img = picture.querySelector('img');
if (img) { if (img) picture.replaceWith(img);
picture.replaceWith(img); });
}
})
// Replace <img> with just their src
newMessage.querySelectorAll('img').forEach(img => { newMessage.querySelectorAll('img').forEach(img => {
const src = img.src || img.currentSrc; const src = img.src || img.currentSrc;
img.replaceWith(document.createTextNode(src)); img.replaceWith(document.createTextNode(src));
}) });
// Replace <iframe> with their src
newMessage.querySelectorAll('iframe').forEach(iframe => { newMessage.querySelectorAll('iframe').forEach(iframe => {
const src = iframe.src || iframe.currentSrc; const src = iframe.src || iframe.currentSrc;
iframe.replaceWith(document.createTextNode(src)); iframe.replaceWith(document.createTextNode(src));
}) });
// Replace <a> with href or markdown
newMessage.querySelectorAll('a').forEach(a => { newMessage.querySelectorAll('a').forEach(a => {
const href = a.getAttribute('href'); const href = a.getAttribute('href');
const text = a.innerText || a.textContent; const text = a.innerText || a.textContent;
@ -47,22 +49,21 @@ export class ReplyEvent extends Event {
} else { } else {
a.replaceWith(document.createTextNode(`[${text}](${href})`)); a.replaceWith(document.createTextNode(`[${text}](${href})`));
} }
}) });
this.replyText = newMessage.innerText.replaceAll("\n\n", "\n").trim(); this.replyText = newMessage.innerText.replaceAll("\n\n", "\n").trim();
newMessage.remove() newMessage.remove();
} }
} }
class MessageElement extends HTMLElement { class MessageElement extends HTMLElement {
// static observedAttributes = ['data-uid', 'data-color', 'data-channel_uid', 'data-user_nick', 'data-created_at', 'data-user_uid'];
updateUI() { updateUI() {
if (this._originalChildren === undefined) { if (this._originalChildren === undefined) {
const { color, user_nick, created_at, user_uid} = this.dataset; const { color, user_nick, created_at, user_uid } = this.dataset;
this.classList.add('message'); this.classList.add('message');
this.style.maxWidth = '100%'; this.style.maxWidth = '100%';
this._originalChildren = Array.from(this.children); this._originalChildren = Array.from(this.children);
this.innerHTML = ` this.innerHTML = `
<a class="avatar" style="background-color: ${color || ''}; color: black;" href="/user/${user_uid || ''}.html"> <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"> <img class="avatar-img" width="40" height="40" src="/avatar/${user_uid || ''}.svg" alt="${user_nick || ''}" loading="lazy">
@ -72,12 +73,12 @@ class MessageElement extends HTMLElement {
<div class="text"></div> <div class="text"></div>
<div class="time no-select" data-created_at="${created_at || ''}"> <div class="time no-select" data-created_at="${created_at || ''}">
<span></span> <span></span>
<a href="#reply">reply</a></div> <a href="#reply">reply</a>
</div>
</div> </div>
`; `;
this.messageDiv = this.querySelector('.text'); this.messageDiv = this.querySelector('.text');
if (this._originalChildren && this._originalChildren.length > 0) { if (this._originalChildren && this._originalChildren.length > 0) {
this._originalChildren.forEach(child => { this._originalChildren.forEach(child => {
this.messageDiv.appendChild(child); this.messageDiv.appendChild(child);
@ -90,9 +91,10 @@ class MessageElement extends HTMLElement {
this.replyDiv.addEventListener('click', (e) => { this.replyDiv.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
this.dispatchEvent(new ReplyEvent(this.messageDiv)); this.dispatchEvent(new ReplyEvent(this.messageDiv));
}) });
} }
// Sibling logic for user switches and long time gaps
if ((!this.siblingGenerated || this.siblingGenerated !== this.nextElementSibling) && this.nextElementSibling) { if ((!this.siblingGenerated || this.siblingGenerated !== this.nextElementSibling) && this.nextElementSibling) {
this.siblingGenerated = this.nextElementSibling; this.siblingGenerated = this.nextElementSibling;
if (this.nextElementSibling?.dataset?.user_uid !== this.dataset.user_uid) { if (this.nextElementSibling?.dataset?.user_uid !== this.dataset.user_uid) {
@ -115,7 +117,7 @@ class MessageElement extends HTMLElement {
updateMessage(...messages) { updateMessage(...messages) {
if (this._originalChildren) { if (this._originalChildren) {
this.messageDiv.replaceChildren(...messages) this.messageDiv.replaceChildren(...messages);
this._originalChildren = messages; this._originalChildren = messages;
} }
} }
@ -124,38 +126,26 @@ class MessageElement extends HTMLElement {
this.updateUI(); this.updateUI();
} }
disconnectedCallback() { disconnectedCallback() {}
} connectedMoveCallback() {}
connectedMoveCallback() {
}
attributeChangedCallback(name, oldValue, newValue) { attributeChangedCallback(name, oldValue, newValue) {
this.updateUI() this.updateUI();
} }
} }
class MessageList extends HTMLElement { class MessageList extends HTMLElement {
constructor() { constructor() {
super(); super();
app.ws.addEventListener("update_message_text", (data) => {
if (this.messageMap.has(data.uid)) {
this.upsertMessage(data);
}
});
app.ws.addEventListener("set_typing", (data) => {
this.triggerGlow(data.user_uid,data.color);
});
this.messageMap = new Map(); this.messageMap = new Map();
this.visibleSet = new Set(); this.visibleSet = new Set();
this._observer = new IntersectionObserver((entries) => { this._observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
this.visibleSet.add(entry.target); this.visibleSet.add(entry.target);
const messageElement = entry.target; if (entry.target instanceof MessageElement) {
if (messageElement instanceof MessageElement) { entry.target.updateUI();
messageElement.updateUI();
} }
} else { } else {
this.visibleSet.delete(entry.target); this.visibleSet.delete(entry.target);
@ -164,28 +154,42 @@ class MessageList extends HTMLElement {
}, { }, {
root: this, root: this,
threshold: 0, threshold: 0,
}) });
// End-of-messages marker
this.endOfMessages = document.createElement('div'); this.endOfMessages = document.createElement('div');
this.endOfMessages.classList.add('message-list-bottom'); this.endOfMessages.classList.add('message-list-bottom');
this.prepend(this.endOfMessages); this.prepend(this.endOfMessages);
for(const c of this.children) { // Observe existing children and index by uid
for (const c of this.children) {
this._observer.observe(c); this._observer.observe(c);
if (c instanceof MessageElement) { if (c instanceof MessageElement) {
this.messageMap.set(c.dataset.uid, c); this.messageMap.set(c.dataset.uid, c);
} }
} }
// Wire up socket events
app.ws.addEventListener("update_message_text", (data) => {
if (this.messageMap.has(data.uid)) {
this.upsertMessage(data);
}
});
app.ws.addEventListener("set_typing", (data) => {
this.triggerGlow(data.user_uid, data.color);
});
this.scrollToBottom(true); this.scrollToBottom(true);
} }
connectedCallback() { connectedCallback() {
this.addEventListener('click', (e) => { this.addEventListener('click', (e) => {
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return; 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'); 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;'; 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;';
@ -206,12 +210,11 @@ class MessageList extends HTMLElement {
overlay.appendChild(fullImg); overlay.appendChild(fullImg);
document.body.appendChild(overlay); document.body.appendChild(overlay);
overlay.addEventListener('click', () => { overlay.addEventListener('click', () => {
if (overlay.parentNode) { if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
overlay.parentNode.removeChild(overlay);
}
}); });
// Optional: ESC key closes overlay // ESC to close
const escListener = (evt) => { const escListener = (evt) => {
if (evt.key === 'Escape') { if (evt.key === 'Escape') {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay); if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
@ -219,8 +222,9 @@ class MessageList extends HTMLElement {
} }
}; };
document.addEventListener('keydown', escListener); document.addEventListener('keydown', escListener);
}) });
} }
isElementVisible(element) { isElementVisible(element) {
if (!element) return false; if (!element) return false;
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@ -231,10 +235,12 @@ class MessageList extends HTMLElement {
rect.right <= (window.innerWidth || document.documentElement.clientWidth) rect.right <= (window.innerWidth || document.documentElement.clientWidth)
); );
} }
isScrolledToBottom() { isScrolledToBottom() {
return this.visibleSet.has(this.endOfMessages) return this.visibleSet.has(this.endOfMessages);
} }
scrollToBottom(force = false, behavior= 'instant') {
scrollToBottom(force = false, behavior = 'instant') {
if (force || !this.isScrolledToBottom()) { if (force || !this.isScrolledToBottom()) {
this.endOfMessages.scrollIntoView({ behavior, block: 'end' }); this.endOfMessages.scrollIntoView({ behavior, block: 'end' });
setTimeout(() => { setTimeout(() => {
@ -261,13 +267,12 @@ class MessageList extends HTMLElement {
} }
} }
updateTimes() { updateTimes() {
this.visibleSet.forEach((messageElement) => { this.visibleSet.forEach((messageElement) => {
if (messageElement instanceof MessageElement) { if (messageElement instanceof MessageElement) {
messageElement.updateUI(); messageElement.updateUI();
} }
}) });
} }
upsertMessage(data) { upsertMessage(data) {
@ -275,16 +280,16 @@ class MessageList extends HTMLElement {
if (message) { if (message) {
message.parentElement?.removeChild(message); message.parentElement?.removeChild(message);
} }
if (!data.message) return;
if (!data.message) return
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
wrapper.innerHTML = data.html; wrapper.innerHTML = data.html;
if (message) { if (message) {
// If the old element is already custom, only update its message children
message.updateMessage(...(wrapper.firstElementChild._originalChildren || wrapper.firstElementChild.children)); message.updateMessage(...(wrapper.firstElementChild._originalChildren || wrapper.firstElementChild.children));
} else { } else {
// If not, insert the new one and observe
message = wrapper.firstElementChild; message = wrapper.firstElementChild;
this.messageMap.set(data.uid, message); this.messageMap.set(data.uid, message);
this._observer.observe(message); this._observer.observe(message);
@ -298,3 +303,4 @@ class MessageList extends HTMLElement {
customElements.define("chat-message", MessageElement); customElements.define("chat-message", MessageElement);
customElements.define("message-list", MessageList); customElements.define("message-list", MessageList);

View File

@ -188,6 +188,7 @@ class WebdavApplication(aiohttp.web.Application):
headers = { headers = {
"DAV": "1, 2", "DAV": "1, 2",
"Allow": "OPTIONS, GET, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, PROPPATCH, PROPGET, PROPSET, PROPDEL", "Allow": "OPTIONS, GET, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, PROPPATCH, PROPGET, PROPSET, PROPDEL",
"MS-Author-Via": "DAV",
} }
return aiohttp.web.Response(status=200, headers=headers) return aiohttp.web.Response(status=200, headers=headers)