2026-06-12 01:58:46 +02:00
|
|
|
// retoor <retoor@molodetz.nl>
|
|
|
|
|
|
2026-06-14 16:46:36 +02:00
|
|
|
import { MessagesSocket } from "./MessagesSocket.js";
|
|
|
|
|
import { contentRenderer } from "./ContentRenderer.js";
|
|
|
|
|
|
|
|
|
|
const TYPING_THROTTLE_MS = 1500;
|
|
|
|
|
const TYPING_HIDE_MS = 4000;
|
2026-07-19 18:57:43 +02:00
|
|
|
const AUTO_SCROLL_MARGIN_PX = 100;
|
|
|
|
|
const STABILIZE_MAX_FRAMES = 300;
|
2026-06-14 16:46:36 +02:00
|
|
|
|
2026-06-11 15:14:13 +02:00
|
|
|
export class MessagesLayout {
|
|
|
|
|
constructor() {
|
|
|
|
|
this.layout = document.querySelector(".messages-layout");
|
|
|
|
|
if (!this.layout) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.thread = document.querySelector(".messages-thread");
|
2026-06-14 16:46:36 +02:00
|
|
|
this.form = document.querySelector(".messages-input-area");
|
2026-07-19 22:31:57 +02:00
|
|
|
this.input = this.form ? this.form.querySelector('textarea[name="content"]') : null;
|
2026-06-14 16:46:36 +02:00
|
|
|
this.upload = this.form ? this.form.querySelector("dp-upload") : null;
|
|
|
|
|
this.sendBtn = this.form ? this.form.querySelector(".messages-send-btn") : null;
|
|
|
|
|
this._uploading = false;
|
|
|
|
|
this._pendingSends = new Set();
|
|
|
|
|
this.typingEl = document.getElementById("typing-indicator");
|
|
|
|
|
|
|
|
|
|
this.selfUid = this.layout.dataset.selfUid || "";
|
|
|
|
|
this.otherUid = this.layout.dataset.otherUid || "";
|
|
|
|
|
|
|
|
|
|
this._lastTypingSent = 0;
|
|
|
|
|
this._typingHideTimer = null;
|
|
|
|
|
this._socketReady = false;
|
2026-07-19 18:57:43 +02:00
|
|
|
this._userAtBottom = true;
|
|
|
|
|
this._stabilizeFrames = 0;
|
|
|
|
|
this._stabilizePending = false;
|
|
|
|
|
|
|
|
|
|
// Expose for diagnostics
|
|
|
|
|
window.__messagesLayout = this;
|
2026-06-11 15:14:13 +02:00
|
|
|
|
|
|
|
|
this.scrollThreadToEnd();
|
|
|
|
|
if (this.input) {
|
|
|
|
|
this.input.focus({ preventScroll: true });
|
|
|
|
|
}
|
2026-06-14 16:46:36 +02:00
|
|
|
|
|
|
|
|
this.connect();
|
|
|
|
|
this.bindForm();
|
|
|
|
|
this.bindTyping();
|
feat: enforce hard test-tier requirement across all DevPlace workflow agents and feature-builder docs
Update the feature-builder agent prompt, test-maintainer agent, and all four workflow JS files (devii-tool, endpoint, feature, job-service) to codify the DevPlace test standard as a non-optional project requirement: one test file per endpoint, directory tree mirroring the URL/source path, split into three tiers (unit, api, e2e). Add explicit Test phases to devii-tool, endpoint, feature, and job-service workflows, and embed tier-specific test instructions (path mapping, fixture choice, coverage scope) directly in each workflow's meta description and TESTS constant.
2026-06-15 14:10:14 +02:00
|
|
|
this.bindViewport();
|
2026-07-19 18:57:43 +02:00
|
|
|
this.bindAutoScroll();
|
|
|
|
|
this._startScrollWatcher();
|
2026-06-14 16:46:36 +02:00
|
|
|
this.markRead();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 18:57:43 +02:00
|
|
|
_startScrollWatcher() {
|
|
|
|
|
if (!this.thread) return;
|
|
|
|
|
|
|
|
|
|
const onAnyChange = () => {
|
|
|
|
|
if (!this._userAtBottom) return;
|
|
|
|
|
if (this._stabilizePending) return;
|
|
|
|
|
this._stabilizePending = true;
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
this._stabilizePending = false;
|
|
|
|
|
this._stabilizeScroll();
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this._scrollWatcher = new MutationObserver(onAnyChange);
|
|
|
|
|
this._scrollWatcher.observe(this.thread, {
|
|
|
|
|
childList: true,
|
|
|
|
|
subtree: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.thread.addEventListener("load", (e) => {
|
|
|
|
|
if (e.target.tagName === "IMG" && this._userAtBottom) {
|
|
|
|
|
onAnyChange();
|
|
|
|
|
}
|
|
|
|
|
}, true);
|
|
|
|
|
|
|
|
|
|
// Kick initial stabilization
|
|
|
|
|
onAnyChange();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_stabilizeScroll() {
|
|
|
|
|
if (!this.thread) {
|
|
|
|
|
this._stabilizeFrames = 0;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!this._userAtBottom) {
|
|
|
|
|
this._stabilizeFrames = 0;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (this._stabilizeFrames >= STABILIZE_MAX_FRAMES) {
|
|
|
|
|
this._stabilizeFrames = 0;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this._stabilizeFrames++;
|
|
|
|
|
this.thread.scrollTop = this.thread.scrollHeight;
|
|
|
|
|
const atBottom = this.thread.scrollHeight
|
|
|
|
|
- this.thread.scrollTop
|
|
|
|
|
- this.thread.clientHeight < 10;
|
|
|
|
|
if (!atBottom) {
|
|
|
|
|
this._stabilizePending = true;
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
this._stabilizePending = false;
|
|
|
|
|
this._stabilizeScroll();
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
this._stabilizeFrames = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bindAutoScroll() {
|
|
|
|
|
if (!this.thread) return;
|
|
|
|
|
this.thread.addEventListener("scroll", () => {
|
|
|
|
|
if (this._stabilizePending) return;
|
|
|
|
|
const atBottom = this.thread.scrollHeight
|
|
|
|
|
- this.thread.scrollTop
|
|
|
|
|
- this.thread.clientHeight < AUTO_SCROLL_MARGIN_PX;
|
|
|
|
|
this._userAtBottom = atBottom;
|
|
|
|
|
if (atBottom) {
|
|
|
|
|
this._stabilizeScroll();
|
|
|
|
|
}
|
|
|
|
|
}, { passive: true });
|
|
|
|
|
}
|
|
|
|
|
|
feat: enforce hard test-tier requirement across all DevPlace workflow agents and feature-builder docs
Update the feature-builder agent prompt, test-maintainer agent, and all four workflow JS files (devii-tool, endpoint, feature, job-service) to codify the DevPlace test standard as a non-optional project requirement: one test file per endpoint, directory tree mirroring the URL/source path, split into three tiers (unit, api, e2e). Add explicit Test phases to devii-tool, endpoint, feature, and job-service workflows, and embed tier-specific test instructions (path mapping, fixture choice, coverage scope) directly in each workflow's meta description and TESTS constant.
2026-06-15 14:10:14 +02:00
|
|
|
bindViewport() {
|
2026-06-16 17:22:41 +02:00
|
|
|
const page = document.querySelector(".page-messages");
|
|
|
|
|
if (!page || !this.input || !this.form) return;
|
|
|
|
|
|
feat: enforce hard test-tier requirement across all DevPlace workflow agents and feature-builder docs
Update the feature-builder agent prompt, test-maintainer agent, and all four workflow JS files (devii-tool, endpoint, feature, job-service) to codify the DevPlace test standard as a non-optional project requirement: one test file per endpoint, directory tree mirroring the URL/source path, split into three tiers (unit, api, e2e). Add explicit Test phases to devii-tool, endpoint, feature, and job-service workflows, and embed tier-specific test instructions (path mapping, fixture choice, coverage scope) directly in each workflow's meta description and TESTS constant.
2026-06-15 14:10:14 +02:00
|
|
|
const viewport = window.visualViewport;
|
2026-06-16 17:22:41 +02:00
|
|
|
|
|
|
|
|
const ensureInputVisible = () => {
|
|
|
|
|
const vh = viewport ? viewport.height : window.innerHeight;
|
|
|
|
|
const formRect = this.form.getBoundingClientRect();
|
|
|
|
|
const currentInset = parseInt(page.style.getPropertyValue("--kb-inset")) || 0;
|
|
|
|
|
const delta = formRect.bottom - vh;
|
|
|
|
|
const newInset = Math.max(0, currentInset + delta);
|
|
|
|
|
|
|
|
|
|
if (newInset !== currentInset) {
|
|
|
|
|
page.style.setProperty("--kb-inset", `${Math.round(newInset)}px`);
|
2026-07-19 18:57:43 +02:00
|
|
|
this._userAtBottom = true;
|
|
|
|
|
this.scrollThreadToEnd();
|
2026-06-16 17:22:41 +02:00
|
|
|
}
|
feat: enforce hard test-tier requirement across all DevPlace workflow agents and feature-builder docs
Update the feature-builder agent prompt, test-maintainer agent, and all four workflow JS files (devii-tool, endpoint, feature, job-service) to codify the DevPlace test standard as a non-optional project requirement: one test file per endpoint, directory tree mirroring the URL/source path, split into three tiers (unit, api, e2e). Add explicit Test phases to devii-tool, endpoint, feature, and job-service workflows, and embed tier-specific test instructions (path mapping, fixture choice, coverage scope) directly in each workflow's meta description and TESTS constant.
2026-06-15 14:10:14 +02:00
|
|
|
};
|
2026-06-16 17:22:41 +02:00
|
|
|
|
|
|
|
|
if (viewport) {
|
|
|
|
|
let insetTimer = null;
|
|
|
|
|
const onViewportChange = () => {
|
|
|
|
|
cancelAnimationFrame(insetTimer);
|
|
|
|
|
insetTimer = requestAnimationFrame(ensureInputVisible);
|
|
|
|
|
};
|
|
|
|
|
viewport.addEventListener("resize", onViewportChange);
|
|
|
|
|
viewport.addEventListener("scroll", onViewportChange);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ro = new ResizeObserver(() => ensureInputVisible());
|
|
|
|
|
ro.observe(page);
|
|
|
|
|
if (this.layout) ro.observe(this.layout);
|
|
|
|
|
|
|
|
|
|
if (this.form) {
|
|
|
|
|
const io = new IntersectionObserver((entries) => {
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
if (!entry.isIntersecting) ensureInputVisible();
|
|
|
|
|
}
|
|
|
|
|
}, { root: null, threshold: [0, 0.5, 1] });
|
|
|
|
|
io.observe(this.form);
|
|
|
|
|
this._viewportIO = io;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 18:57:43 +02:00
|
|
|
this.input.addEventListener("keydown", (event) => {
|
|
|
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
this.form.requestSubmit();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.input.addEventListener("input", () => {
|
|
|
|
|
this.input.style.height = "auto";
|
|
|
|
|
this.input.style.height = Math.min(this.input.scrollHeight, 160) + "px";
|
|
|
|
|
});
|
|
|
|
|
|
feat: enforce hard test-tier requirement across all DevPlace workflow agents and feature-builder docs
Update the feature-builder agent prompt, test-maintainer agent, and all four workflow JS files (devii-tool, endpoint, feature, job-service) to codify the DevPlace test standard as a non-optional project requirement: one test file per endpoint, directory tree mirroring the URL/source path, split into three tiers (unit, api, e2e). Add explicit Test phases to devii-tool, endpoint, feature, and job-service workflows, and embed tier-specific test instructions (path mapping, fixture choice, coverage scope) directly in each workflow's meta description and TESTS constant.
2026-06-15 14:10:14 +02:00
|
|
|
this.input.addEventListener("focus", () => {
|
2026-07-19 18:57:43 +02:00
|
|
|
this._userAtBottom = true;
|
|
|
|
|
const doScroll = () => {
|
feat: enforce hard test-tier requirement across all DevPlace workflow agents and feature-builder docs
Update the feature-builder agent prompt, test-maintainer agent, and all four workflow JS files (devii-tool, endpoint, feature, job-service) to codify the DevPlace test standard as a non-optional project requirement: one test file per endpoint, directory tree mirroring the URL/source path, split into three tiers (unit, api, e2e). Add explicit Test phases to devii-tool, endpoint, feature, and job-service workflows, and embed tier-specific test instructions (path mapping, fixture choice, coverage scope) directly in each workflow's meta description and TESTS constant.
2026-06-15 14:10:14 +02:00
|
|
|
this.scrollThreadToEnd();
|
2026-06-16 17:22:41 +02:00
|
|
|
if (window.innerWidth < 768) {
|
|
|
|
|
const retry = (delay) => setTimeout(() => {
|
|
|
|
|
ensureInputVisible();
|
|
|
|
|
this.scrollThreadToEnd();
|
|
|
|
|
}, delay);
|
|
|
|
|
retry(100);
|
|
|
|
|
retry(350);
|
|
|
|
|
retry(600);
|
|
|
|
|
}
|
2026-07-19 18:57:43 +02:00
|
|
|
};
|
|
|
|
|
requestAnimationFrame(doScroll);
|
feat: enforce hard test-tier requirement across all DevPlace workflow agents and feature-builder docs
Update the feature-builder agent prompt, test-maintainer agent, and all four workflow JS files (devii-tool, endpoint, feature, job-service) to codify the DevPlace test standard as a non-optional project requirement: one test file per endpoint, directory tree mirroring the URL/source path, split into three tiers (unit, api, e2e). Add explicit Test phases to devii-tool, endpoint, feature, and job-service workflows, and embed tier-specific test instructions (path mapping, fixture choice, coverage scope) directly in each workflow's meta description and TESTS constant.
2026-06-15 14:10:14 +02:00
|
|
|
});
|
2026-06-16 17:22:41 +02:00
|
|
|
|
feat: enforce hard test-tier requirement across all DevPlace workflow agents and feature-builder docs
Update the feature-builder agent prompt, test-maintainer agent, and all four workflow JS files (devii-tool, endpoint, feature, job-service) to codify the DevPlace test standard as a non-optional project requirement: one test file per endpoint, directory tree mirroring the URL/source path, split into three tiers (unit, api, e2e). Add explicit Test phases to devii-tool, endpoint, feature, and job-service workflows, and embed tier-specific test instructions (path mapping, fixture choice, coverage scope) directly in each workflow's meta description and TESTS constant.
2026-06-15 14:10:14 +02:00
|
|
|
this.input.addEventListener("blur", () => {
|
2026-06-16 17:22:41 +02:00
|
|
|
page.style.setProperty("--kb-inset", "0px");
|
feat: enforce hard test-tier requirement across all DevPlace workflow agents and feature-builder docs
Update the feature-builder agent prompt, test-maintainer agent, and all four workflow JS files (devii-tool, endpoint, feature, job-service) to codify the DevPlace test standard as a non-optional project requirement: one test file per endpoint, directory tree mirroring the URL/source path, split into three tiers (unit, api, e2e). Add explicit Test phases to devii-tool, endpoint, feature, and job-service workflows, and embed tier-specific test instructions (path mapping, fixture choice, coverage scope) directly in each workflow's meta description and TESTS constant.
2026-06-15 14:10:14 +02:00
|
|
|
});
|
2026-06-16 17:22:41 +02:00
|
|
|
|
|
|
|
|
ensureInputVisible();
|
feat: enforce hard test-tier requirement across all DevPlace workflow agents and feature-builder docs
Update the feature-builder agent prompt, test-maintainer agent, and all four workflow JS files (devii-tool, endpoint, feature, job-service) to codify the DevPlace test standard as a non-optional project requirement: one test file per endpoint, directory tree mirroring the URL/source path, split into three tiers (unit, api, e2e). Add explicit Test phases to devii-tool, endpoint, feature, and job-service workflows, and embed tier-specific test instructions (path mapping, fixture choice, coverage scope) directly in each workflow's meta description and TESTS constant.
2026-06-15 14:10:14 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-14 16:46:36 +02:00
|
|
|
connect() {
|
|
|
|
|
this.socket = new MessagesSocket({
|
|
|
|
|
onReady: () => this.onReady(),
|
|
|
|
|
onMessage: (frame) => this.onFrame(frame),
|
|
|
|
|
onClose: () => { this._socketReady = false; },
|
|
|
|
|
});
|
|
|
|
|
this.socket.connect();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onReady() {
|
|
|
|
|
this._socketReady = true;
|
|
|
|
|
if (this.otherUid) {
|
|
|
|
|
this.markRead();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onFrame(frame) {
|
|
|
|
|
switch (frame.type) {
|
|
|
|
|
case "message":
|
|
|
|
|
this.handleIncoming(frame);
|
|
|
|
|
break;
|
|
|
|
|
case "typing":
|
|
|
|
|
if (frame.from_uid === this.otherUid) this.showTyping();
|
|
|
|
|
break;
|
|
|
|
|
case "read":
|
|
|
|
|
if (frame.by_uid === this.otherUid) this.markReceipts();
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bindForm() {
|
|
|
|
|
if (!this.form || !this.input) return;
|
|
|
|
|
this.form.addEventListener("submit", (event) => {
|
|
|
|
|
if (!this._socketReady || !this.socket.isOpen()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
this.sendViaSocket();
|
|
|
|
|
});
|
|
|
|
|
if (this.upload) {
|
|
|
|
|
this.upload.addEventListener("dp-upload:busy", (event) => {
|
|
|
|
|
this._uploading = !!(event.detail && event.detail.busy);
|
|
|
|
|
this.refreshSendButton();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
refreshSendButton() {
|
|
|
|
|
if (!this.sendBtn) return;
|
|
|
|
|
const busy = this._uploading || this._pendingSends.size > 0;
|
|
|
|
|
this.sendBtn.disabled = busy;
|
|
|
|
|
this.sendBtn.classList.toggle("is-sending", busy);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sendViaSocket() {
|
|
|
|
|
const content = (this.input.value || "").trim();
|
|
|
|
|
const attachmentUids = this.collectAttachments();
|
|
|
|
|
if (!content && attachmentUids.length === 0) return;
|
|
|
|
|
const clientId = "c" + Date.now() + Math.random().toString(36).slice(2, 8);
|
|
|
|
|
this.appendOptimistic(content, clientId, attachmentUids.length);
|
|
|
|
|
const ok = this.socket.send({
|
|
|
|
|
type: "send",
|
|
|
|
|
receiver_uid: this.otherUid,
|
|
|
|
|
content,
|
|
|
|
|
attachment_uids: attachmentUids,
|
|
|
|
|
client_id: clientId,
|
|
|
|
|
});
|
|
|
|
|
if (!ok) {
|
|
|
|
|
this.form.submit();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this._pendingSends.add(clientId);
|
|
|
|
|
this.refreshSendButton();
|
|
|
|
|
window.setTimeout(() => this.clearPendingSend(clientId), 8000);
|
|
|
|
|
this.input.value = "";
|
|
|
|
|
if (this.upload && typeof this.upload.clear === "function") {
|
|
|
|
|
this.upload.clear();
|
|
|
|
|
}
|
|
|
|
|
this.input.focus({ preventScroll: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearPendingSend(clientId) {
|
|
|
|
|
if (this._pendingSends.delete(clientId)) {
|
|
|
|
|
this.refreshSendButton();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
collectAttachments() {
|
|
|
|
|
const hidden = this.form.querySelector('input[name="attachment_uids"]');
|
|
|
|
|
if (!hidden || !hidden.value) return [];
|
|
|
|
|
return hidden.value.split(",").map((v) => v.trim()).filter(Boolean);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bindTyping() {
|
|
|
|
|
if (!this.input) return;
|
|
|
|
|
this.input.addEventListener("input", () => {
|
|
|
|
|
if (!this._socketReady || !this.otherUid) return;
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (now - this._lastTypingSent < TYPING_THROTTLE_MS) return;
|
|
|
|
|
this._lastTypingSent = now;
|
|
|
|
|
this.socket.send({ type: "typing", receiver_uid: this.otherUid });
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-16 16:18:19 +02:00
|
|
|
|
2026-06-14 16:46:36 +02:00
|
|
|
appendOptimistic(content, clientId, attachmentCount) {
|
2026-06-16 16:18:19 +02:00
|
|
|
// Results in double message. Should be fixed in future..
|
|
|
|
|
/*
|
2026-06-14 16:46:36 +02:00
|
|
|
const bubble = this.buildBubble({
|
|
|
|
|
content,
|
|
|
|
|
mine: true,
|
|
|
|
|
clientId,
|
|
|
|
|
time: "now",
|
2026-06-16 05:32:19 +02:00
|
|
|
iso: new Date().toISOString(),
|
2026-06-14 16:46:36 +02:00
|
|
|
attachmentCount,
|
|
|
|
|
});
|
2026-06-16 16:18:19 +02:00
|
|
|
bubble.classList.add("pending");*/
|
|
|
|
|
//this.insertBubble(bubble);
|
2026-06-14 16:46:36 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handleIncoming(frame) {
|
2026-06-19 10:06:09 +02:00
|
|
|
if (frame.uid && this.thread &&
|
|
|
|
|
this.thread.querySelector(`.message-bubble[data-msg-uid="${frame.uid}"]`)) {
|
|
|
|
|
if (frame.client_id) this.clearPendingSend(frame.client_id);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-14 16:46:36 +02:00
|
|
|
if (frame.sender_uid === this.selfUid && frame.client_id) {
|
|
|
|
|
this.clearPendingSend(frame.client_id);
|
|
|
|
|
const pending = this.thread.querySelector(`.message-bubble[data-client-id="${frame.client_id}"]`);
|
|
|
|
|
if (pending) {
|
|
|
|
|
pending.classList.remove("pending");
|
|
|
|
|
pending.dataset.msgUid = frame.uid;
|
2026-06-16 05:32:19 +02:00
|
|
|
const oldBody = pending.querySelector(".rendered-content");
|
|
|
|
|
if (oldBody && frame.content !== undefined) {
|
2026-07-19 18:57:43 +02:00
|
|
|
const content = document.createElement("dp-content");
|
|
|
|
|
content.setAttribute("no-copy", "");
|
|
|
|
|
if (frame.sender_role === "Admin") content.setAttribute("data-author-admin", "");
|
|
|
|
|
content.textContent = frame.content || "";
|
|
|
|
|
oldBody.replaceWith(content);
|
2026-06-16 05:32:19 +02:00
|
|
|
}
|
2026-06-14 16:46:36 +02:00
|
|
|
const time = pending.querySelector(".message-time");
|
2026-06-16 05:32:19 +02:00
|
|
|
if (time && frame.created_at) {
|
|
|
|
|
time.setAttribute("datetime", frame.created_at);
|
|
|
|
|
time.dataset.dt = "";
|
|
|
|
|
time.dataset.dtMode = "ago";
|
|
|
|
|
if (window.app && window.app.localTime) window.app.localTime.apply(time);
|
|
|
|
|
else time.textContent = frame.time_ago;
|
|
|
|
|
} else if (time) {
|
|
|
|
|
time.textContent = frame.time_ago;
|
|
|
|
|
}
|
2026-06-14 16:46:36 +02:00
|
|
|
const placeholder = pending.querySelector(".attachment-pending");
|
|
|
|
|
if (placeholder) placeholder.remove();
|
|
|
|
|
const gallery = this.renderAttachments(frame.attachments);
|
|
|
|
|
if (gallery) pending.insertBefore(gallery, time || null);
|
|
|
|
|
this.bumpConversation(frame, true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const partnerUid = frame.sender_uid === this.selfUid ? frame.receiver_uid : frame.sender_uid;
|
|
|
|
|
const inThisThread = this.otherUid && partnerUid === this.otherUid;
|
|
|
|
|
|
|
|
|
|
if (inThisThread) {
|
|
|
|
|
const mine = frame.sender_uid === this.selfUid;
|
|
|
|
|
const bubble = this.buildBubble({
|
|
|
|
|
content: frame.content,
|
|
|
|
|
mine,
|
|
|
|
|
time: frame.time_ago,
|
2026-06-16 05:32:19 +02:00
|
|
|
iso: frame.created_at,
|
2026-06-14 16:46:36 +02:00
|
|
|
uid: frame.uid,
|
2026-07-19 18:57:43 +02:00
|
|
|
senderRole: frame.sender_role,
|
2026-06-14 16:46:36 +02:00
|
|
|
attachments: frame.attachments,
|
|
|
|
|
});
|
|
|
|
|
this.insertBubble(bubble);
|
|
|
|
|
if (!mine && this._socketReady) {
|
|
|
|
|
this.socket.send({ type: "read", with_uid: this.otherUid });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
this.bumpConversation(frame, frame.sender_uid === this.selfUid);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 18:57:43 +02:00
|
|
|
buildBubble({ content, mine, time, iso, uid, clientId, senderRole, attachments, attachmentCount }) {
|
2026-06-14 16:46:36 +02:00
|
|
|
const bubble = document.createElement("div");
|
|
|
|
|
bubble.className = "message-bubble " + (mine ? "mine" : "theirs");
|
|
|
|
|
if (uid) bubble.dataset.msgUid = uid;
|
|
|
|
|
if (clientId) bubble.dataset.clientId = clientId;
|
|
|
|
|
|
2026-07-19 18:57:43 +02:00
|
|
|
const el = document.createElement("dp-content");
|
|
|
|
|
el.setAttribute("no-copy", "");
|
|
|
|
|
if (senderRole === "Admin") el.setAttribute("data-author-admin", "");
|
|
|
|
|
el.textContent = content || "";
|
|
|
|
|
bubble.appendChild(el);
|
2026-06-14 16:46:36 +02:00
|
|
|
|
|
|
|
|
const gallery = this.renderAttachments(attachments);
|
|
|
|
|
if (gallery) {
|
|
|
|
|
bubble.appendChild(gallery);
|
|
|
|
|
} else if (attachmentCount > 0) {
|
|
|
|
|
const placeholder = document.createElement("div");
|
|
|
|
|
placeholder.className = "attachment-pending";
|
|
|
|
|
placeholder.textContent = attachmentCount === 1
|
|
|
|
|
? "Uploading attachment..."
|
|
|
|
|
: `Uploading ${attachmentCount} attachments...`;
|
|
|
|
|
bubble.appendChild(placeholder);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 05:32:19 +02:00
|
|
|
const timeEl = document.createElement(iso ? "time" : "span");
|
2026-06-14 16:46:36 +02:00
|
|
|
timeEl.className = "message-time";
|
2026-06-16 05:32:19 +02:00
|
|
|
if (iso) {
|
|
|
|
|
timeEl.setAttribute("datetime", iso);
|
|
|
|
|
timeEl.dataset.dt = "";
|
|
|
|
|
timeEl.dataset.dtMode = "ago";
|
|
|
|
|
if (window.app && window.app.localTime) window.app.localTime.apply(timeEl);
|
|
|
|
|
else timeEl.textContent = time || "";
|
|
|
|
|
} else {
|
|
|
|
|
timeEl.textContent = time || "";
|
|
|
|
|
}
|
2026-06-14 16:46:36 +02:00
|
|
|
bubble.appendChild(timeEl);
|
|
|
|
|
|
|
|
|
|
if (mine) {
|
|
|
|
|
const receipt = document.createElement("span");
|
|
|
|
|
receipt.className = "message-receipt";
|
|
|
|
|
receipt.hidden = true;
|
|
|
|
|
receipt.innerHTML = "✓✓";
|
|
|
|
|
bubble.appendChild(receipt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bubble;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderAttachments(attachments) {
|
|
|
|
|
if (!attachments || !attachments.length) return null;
|
|
|
|
|
const gallery = document.createElement("div");
|
|
|
|
|
gallery.className = "attachment-gallery";
|
|
|
|
|
for (const att of attachments) {
|
|
|
|
|
const item = document.createElement("div");
|
|
|
|
|
item.className = "attachment-gallery-item";
|
|
|
|
|
if (att.is_image) {
|
|
|
|
|
const img = document.createElement("img");
|
|
|
|
|
img.src = att.thumbnail_url || att.url;
|
|
|
|
|
img.alt = att.original_filename || "";
|
|
|
|
|
img.loading = "lazy";
|
|
|
|
|
img.className = "gallery-thumb";
|
|
|
|
|
img.dataset.lightbox = "";
|
|
|
|
|
img.dataset.full = att.url;
|
|
|
|
|
if (att.mime_type) img.dataset.mime = att.mime_type;
|
|
|
|
|
item.appendChild(img);
|
|
|
|
|
} else if (att.is_video) {
|
|
|
|
|
const video = document.createElement("video");
|
|
|
|
|
video.src = att.url;
|
|
|
|
|
video.controls = true;
|
|
|
|
|
video.preload = "metadata";
|
|
|
|
|
video.className = "gallery-video";
|
|
|
|
|
item.appendChild(video);
|
|
|
|
|
} else {
|
|
|
|
|
const link = document.createElement("a");
|
|
|
|
|
link.href = att.url;
|
|
|
|
|
link.target = "_blank";
|
|
|
|
|
link.rel = "noopener";
|
|
|
|
|
link.className = "non-image";
|
|
|
|
|
link.download = att.original_filename || "file";
|
|
|
|
|
link.textContent = att.original_filename || "file";
|
|
|
|
|
item.appendChild(link);
|
|
|
|
|
}
|
|
|
|
|
gallery.appendChild(item);
|
|
|
|
|
}
|
|
|
|
|
return gallery;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
insertBubble(bubble) {
|
|
|
|
|
if (!this.thread) return;
|
|
|
|
|
if (this.typingEl && this.typingEl.parentElement === this.thread) {
|
|
|
|
|
this.thread.insertBefore(bubble, this.typingEl);
|
|
|
|
|
} else {
|
|
|
|
|
this.thread.appendChild(bubble);
|
|
|
|
|
}
|
|
|
|
|
this.scrollThreadToEnd();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
markReceipts() {
|
|
|
|
|
this.thread.querySelectorAll(".message-bubble.mine .message-receipt").forEach((el) => {
|
|
|
|
|
el.hidden = false;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
markRead() {
|
|
|
|
|
if (this._socketReady && this.otherUid) {
|
|
|
|
|
this.socket.send({ type: "read", with_uid: this.otherUid });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
showTyping() {
|
|
|
|
|
if (!this.typingEl) return;
|
|
|
|
|
this.typingEl.hidden = false;
|
|
|
|
|
this.scrollThreadToEnd();
|
|
|
|
|
clearTimeout(this._typingHideTimer);
|
|
|
|
|
this._typingHideTimer = setTimeout(() => {
|
|
|
|
|
this.typingEl.hidden = true;
|
|
|
|
|
}, TYPING_HIDE_MS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bumpConversation(frame, mine) {
|
|
|
|
|
const partnerUid = mine ? frame.receiver_uid : frame.sender_uid;
|
|
|
|
|
const item = document.querySelector(`.conversation-item[data-conv-uid="${partnerUid}"]`);
|
|
|
|
|
if (!item) return;
|
|
|
|
|
const preview = item.querySelector(".conversation-preview");
|
feat: add content_preview helper and apply rendered content across templates
Introduce a new `content_preview()` function in `rendering.py` that strips HTML from rendered content and truncates to a given length with an ellipsis. Add a corresponding `plainText()` and `preview()` method to the `ContentRenderer` JS class. Register `content_preview` as a Jinja2 global in `templating.py`. Update multiple templates (`feed.html`, `gists.html`, `landing.html`, `messages.html`, `news.html`, `notifications.html`, `post.html`, `profile.html`) to use `render_title`, `render_content`, or `content_preview` instead of raw string slicing, ensuring consistent rendering of rich text (e.g., Markdown, HTML) in previews, descriptions, bios, and notification messages. Also fix the conversation preview in `MessagesLayout.js` to use the new JS preview method.
2026-06-22 23:17:54 +02:00
|
|
|
if (preview) preview.textContent = contentRenderer.preview(frame.content, 60);
|
2026-06-14 16:46:36 +02:00
|
|
|
const dot = item.querySelector(".conversation-unread-dot");
|
|
|
|
|
if (dot) {
|
|
|
|
|
dot.hidden = mine || partnerUid === this.otherUid;
|
|
|
|
|
}
|
|
|
|
|
const list = item.parentElement;
|
|
|
|
|
if (list && list.firstElementChild !== item) {
|
|
|
|
|
list.insertBefore(item, list.firstElementChild);
|
|
|
|
|
}
|
2026-06-11 15:14:13 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scrollThreadToEnd() {
|
2026-07-19 18:57:43 +02:00
|
|
|
if (!this.thread) return;
|
|
|
|
|
if (!this._userAtBottom) return;
|
|
|
|
|
this.thread.scrollTop = this.thread.scrollHeight;
|
2026-06-11 15:14:13 +02:00
|
|
|
}
|
|
|
|
|
}
|