Updpdate
DevPlace CI / test (push) Failing after 7m3s

This commit is contained in:
2026-07-23 00:02:43 +02:00
parent 34fa56a836
commit 64c3983c9f
37 changed files with 2105 additions and 824 deletions
@@ -8,16 +8,37 @@
--kb-inset: 0px;
}
dp-chat {
display: grid;
flex: 1;
min-width: 0;
min-height: 0;
width: 100%;
}
.messages-layout {
display: grid;
grid-template-columns: minmax(0, 320px) 1fr;
grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 0;
flex: 1;
min-width: 0;
min-height: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
--kb-inset: 0px;
}
dp-chat[mode="embed"] {
display: flex;
width: 100%;
height: 100%;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
--kb-inset: 0px;
}
.messages-list {
@@ -29,7 +50,7 @@
}
.messages-list-header {
padding: 1rem;
padding: var(--space-sm) var(--space-lg);
border-bottom: 1px solid var(--border);
position: relative;
}
@@ -56,8 +77,8 @@
.search-dropdown-item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 1rem;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg);
font-size: 0.875rem;
color: var(--text-primary);
text-decoration: none;
@@ -84,8 +105,8 @@
.conversation-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
gap: var(--space-md);
padding: var(--space-sm) var(--space-lg);
cursor: pointer;
}
@@ -125,15 +146,16 @@
.messages-main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.messages-main-header {
padding: 1rem;
padding: var(--space-lg);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 0.75rem;
gap: var(--space-md);
flex-shrink: 0;
}
@@ -154,7 +176,7 @@
}
.messages-presence.online {
color: var(--success, var(--accent));
color: var(--success);
}
.messages-presence.online::before {
@@ -163,7 +185,7 @@
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success, var(--accent));
background: var(--success);
margin-right: 0.3125rem;
vertical-align: middle;
}
@@ -181,78 +203,14 @@
display: none;
}
.message-receipt {
font-size: 0.6875rem;
color: rgba(255, 255, 255, 0.85);
margin-left: 0.375rem;
letter-spacing: -2px;
}
.message-receipt[hidden] {
display: none;
}
.message-bubble.pending {
opacity: 0.6;
}
.attachment-pending {
margin-top: 0.35rem;
font-size: 0.8rem;
font-style: italic;
color: var(--text-secondary);
}
.typing-indicator {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: var(--bg-card-hover);
border-radius: var(--radius-lg);
border-bottom-left-radius: 4px;
}
.typing-indicator[hidden] {
display: none;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: typing-bounce 1.2s infinite ease-in-out;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing-bounce {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-4px);
opacity: 1;
}
}
.messages-thread {
flex: 1;
overflow-y: auto;
padding: 1rem;
padding-bottom: 0.5rem;
padding: var(--space-lg);
padding-bottom: var(--space-sm);
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: var(--space-md);
min-height: 0;
}
@@ -264,6 +222,11 @@
line-height: 1.5;
position: relative;
overflow-wrap: anywhere;
outline: none;
}
.message-bubble.grouped {
margin-top: calc(var(--space-xs) - var(--space-md));
}
.message-bubble.mine {
@@ -280,24 +243,134 @@
border-bottom-left-radius: 4px;
}
.message-bubble.pending {
opacity: 0.6;
}
.message-bubble.failed {
outline: 1px solid var(--danger);
cursor: pointer;
}
.retry-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.6875rem;
color: var(--danger);
}
.message-bubble.mine .content-copy-btn,
.message-bubble.theirs .content-copy-btn {
opacity: 0;
}
.message-bubble:hover .content-copy-btn,
.message-bubble:focus-within .content-copy-btn,
.message-bubble .content-copy-btn:focus {
opacity: 1;
}
.message-time {
font-size: 0.6875rem;
color: var(--text-muted);
margin-top: 0.25rem;
display: block;
transition: opacity 0.1s ease;
}
.message-bubble.grouped .message-time {
opacity: 0;
}
.message-bubble.grouped:hover .message-time,
.message-bubble.grouped:focus-within .message-time {
opacity: 1;
}
.message-bubble.mine .message-time {
color: rgba(255, 255, 255, 0.7);
}
.message-receipt {
font-size: 0.6875rem;
color: rgba(255, 255, 255, 0.85);
margin-left: 0.375rem;
letter-spacing: -2px;
}
.message-receipt[hidden] {
display: none;
}
.ai-adjusted-note {
margin-top: 0.25rem;
font-size: 0.6875rem;
color: var(--text-muted);
font-style: italic;
opacity: 1;
transition: opacity 0.4s ease;
}
.ai-adjusted-note.fading {
opacity: 0;
}
.attachment-pending {
margin-top: 0.35rem;
font-size: 0.8rem;
font-style: italic;
color: var(--text-secondary);
}
.typing-indicator {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
background: var(--bg-card-hover);
border-radius: var(--radius-lg);
border-bottom-left-radius: 4px;
}
.typing-indicator[hidden] {
display: none;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: chat-typing-bounce 1.2s infinite ease-in-out;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes chat-typing-bounce {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-4px);
opacity: 1;
}
}
.messages-input-area {
padding: 0.75rem 1rem;
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom));
padding: var(--space-md) var(--space-lg);
padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
display: flex;
align-items: flex-end;
gap: 0.5rem;
gap: var(--space-sm);
flex-shrink: 0;
min-height: 56px;
background: var(--bg-card);
@@ -311,7 +384,7 @@
overflow-y: auto;
max-height: 160px;
line-height: 1.5;
padding: 0.5rem 0.75rem;
padding: var(--space-sm) var(--space-md);
font-family: inherit;
font-size: 0.875rem;
border-radius: var(--radius);
@@ -321,6 +394,13 @@
outline: none;
}
@supports (field-sizing: content) {
.messages-input-area textarea {
field-sizing: content;
min-height: 1lh;
}
}
.messages-input-area textarea:focus {
border-color: var(--accent);
}
@@ -340,7 +420,7 @@
flex: 0 0 100%;
order: -1;
margin-top: 0;
margin-bottom: 0.5rem;
margin-bottom: var(--space-sm);
}
.messages-send-btn {
@@ -368,7 +448,7 @@
border: 2px solid var(--white);
border-right-color: transparent;
border-radius: 50%;
animation: messages-send-spin 0.6s linear infinite;
animation: chat-send-spin 0.6s linear infinite;
}
.messages-send-btn.is-sending {
@@ -388,7 +468,7 @@
cursor: default;
}
@keyframes messages-send-spin {
@keyframes chat-send-spin {
to {
transform: rotate(360deg);
}
@@ -401,7 +481,7 @@
justify-content: center;
height: 100%;
color: var(--text-muted);
gap: 0.5rem;
gap: var(--space-sm);
}
.messages-empty p {
@@ -414,27 +494,28 @@
border: none;
color: var(--text-primary);
font-size: 1.25rem;
padding: 0.25rem;
padding: var(--space-xs);
cursor: pointer;
line-height: 1;
margin-right: 0.25rem;
margin-right: var(--space-xs);
}
.messages-back-btn:hover {
color: var(--accent);
}
/* ── Mobile (≤ 768px) ───────────────────────────────────── */
/* Switches layout from grid to flex column so a single visible
child (conversation list OR chat pane) always fills the viewport.
The --kb-inset is applied here so the keyboard never obscures
the input bar. */
/* ── Mobile (<= 768px) ─────────────────────────────────── */
@media (max-width: 768px) {
.page-messages {
padding-bottom: max(env(safe-area-inset-bottom), var(--kb-inset, 0px));
}
.messages-layout,
dp-chat[mode="embed"] {
padding-bottom: max(env(safe-area-inset-bottom), var(--kb-inset, 0px));
}
.messages-layout {
display: flex;
flex-direction: column;
@@ -472,17 +553,33 @@
.messages-input-area textarea {
font-size: 16px;
}
.message-bubble.grouped .message-time {
opacity: 1;
}
}
/* ── Small mobile (≤ 360px) ─────────────────────────────── */
/* ── Small mobile (<= 480px) ────────────────────────────── */
@media (max-width: 480px) {
.message-bubble {
max-width: 82%;
}
.messages-main-header {
padding: var(--space-md);
}
}
/* ── Smallest mobile (<= 360px) ─────────────────────────── */
@media (max-width: 360px) {
.messages-thread {
padding: 0.5rem;
padding: var(--space-sm);
}
.messages-input-area {
padding: 0.375rem 0.5rem;
padding: 0.375rem var(--space-sm);
padding-bottom: calc(0.375rem + env(safe-area-inset-bottom));
min-height: 48px;
}
@@ -493,7 +590,7 @@
.message-bubble {
max-width: 90%;
padding: 0.5rem 0.625rem;
padding: var(--space-sm) 0.625rem;
font-size: 0.8125rem;
}
@@ -502,11 +599,30 @@
}
}
/* ── Touch devices: larger send target ──────────────────── */
/* ── Touch devices: larger tap targets (min 44x44px) ────── */
@media (hover: none) and (pointer: coarse) {
.messages-send-btn {
width: 44px;
height: 44px;
}
.messages-back-btn {
min-width: 44px;
min-height: 44px;
}
.conversation-item {
min-height: 44px;
}
.message-bubble .content-copy-btn {
opacity: 1;
min-width: 44px;
min-height: 32px;
}
.message-bubble.grouped .message-time {
opacity: 1;
}
}
-4
View File
@@ -5,8 +5,6 @@ import { ModalManager } from "./ModalManager.js";
import { FormManager } from "./FormManager.js";
import { VoteManager } from "./VoteManager.js";
import { NotificationManager } from "./NotificationManager.js";
import { MessageSearch } from "./MessageSearch.js";
import { MessagesLayout } from "./MessagesLayout.js";
import { ProfileEditor } from "./ProfileEditor.js";
import { MobileNav } from "./MobileNav.js";
import { CommentManager } from "./CommentManager.js";
@@ -74,8 +72,6 @@ class Application {
this.forms = new FormManager();
this.votes = new VoteManager();
this.notifications = new NotificationManager();
this.messageSearch = new MessageSearch();
this.messagesLayout = new MessagesLayout();
this.profile = new ProfileEditor();
this.mobileNav = new MobileNav();
this.comments = new CommentManager();
+5 -1
View File
@@ -14,7 +14,11 @@ This file documents JS module organization, custom web components, and shared fr
## Custom web components (`static/js/components/`)
Self-contained, presentational UI is built as custom elements with the `dp-` prefix:
`dp-avatar`, `dp-code`, `dp-content`, `dp-title`, `dp-toast`, `dp-dialog`, `dp-context-menu`, `dp-upload`, `dp-lightbox`.
`dp-avatar`, `dp-code`, `dp-content`, `dp-title`, `dp-toast`, `dp-dialog`, `dp-context-menu`, `dp-upload`, `dp-lightbox`, `dp-chat`.
`dp-chat` (`AppChat.js`) is the Slack-like DM chat widget that fully replaced the old page-controller trio `MessagesLayout.js`/`MessagesSocket.js`/`MessageSearch.js` (deleted) and `static/css/messages.css` (superseded by `static/css/chat.css`) on `/messages`. Unlike every other component here, it does NOT build its DOM from scratch on `connectedCallback` when server-rendered light-DOM children already exist (`mode="page"`) - it **adopts** the existing `.messages-list`/`.messages-main`/`.messages-thread`/`.message-bubble` markup `templates/messages.html` still renders (so no-JS and crawler fallback both keep working) and only builds a from-scratch skeleton when none is present (`mode="embed"`, a standalone `<dp-chat mode="embed" self-uid="..." with-uid="..." send-url="..." ws-url="...">` usable outside `/messages`). It owns its own `ChatSocket` instance, the conversation search dropdown (absorbed from the old `MessageSearch.js`, still built on the shared `ListNav` utility), consecutive-message grouping (`chat/MessageGrouping.js`), a working optimistic send with `.pending`/`.failed` (tap-to-retry) bubble states, and an opt-in (`ai-indicator` attribute) "Adjusted by AI" caption shown when a reconciled echo's `ai_processed` frame flag is true and the content changed from what was locally typed - never a diff/revert UI, the backend keeps no pre-correction copy to diff against. Presence for `mode="page"` needs no component code at all: the adopted markup keeps its `data-presence-uid` attributes, so the page-global `PresenceManager` (below) already drives it; `dp-chat` opens its own scoped `PubSubClient` subscription only in `mode="embed"`, where no page-global instance exists. The message body itself keeps rendering through `<dp-content>` (see "CLIENT-only now" below) - the redesign only removed the historical `no-copy` attribute on message bubbles so `dp-content`'s own existing `.content-copy-btn` doubles as the hover/focus-reveal action toolbar's Copy button (§6.3 of the design doc it was built from), instead of a second copy mechanism being hand-rolled.
`static/js/chat/ChatSocket.js` and `static/js/chat/MessageGrouping.js` are chat-scoped helpers used only by `AppChat.js` - `ChatSocket` is modeled on `PubSubClient.js`'s real exponential backoff (200ms doubling to a 5000ms cap, reset on a successful connect) rather than the flat-delay retry the deleted `MessagesSocket.js` used, and preserves the same `4013` "wrong worker" fast-retry special case. `MessageGrouping.shouldGroup(previous, current)` is the one pure predicate (<=300s gap, same sender) shared by both the initial-history grouping pass and the live-append path, so the two can never disagree. Neither file is a general-purpose "do not reimplement" utility for the rest of the app (see the out-of-scope note in the design document this shipped from) - they are not added to the do-not-reimplement list below; a future non-chat WebSocket feature should keep hand-rolling its own client (as `DeviiSocket.js`/`DeepsearchProgressSocket.js`/`SeoProgressSocket.js`/`AppDeepsearchChat.js`'s inline socket already do) rather than reaching into `chat/`.
Conventions:
-81
View File
@@ -1,81 +0,0 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Avatar } from "./Avatar.js";
import { DomUtils } from "./DomUtils.js";
import { ListNav } from "./ListNav.js";
export class MessageSearch {
constructor() {
this.initMessageSearch();
}
initMessageSearch() {
const searchInput = document.getElementById("message-search");
if (!searchInput) {
return;
}
const wrap = searchInput.parentElement;
const dropdown = document.createElement("div");
dropdown.className = "search-dropdown";
dropdown.setAttribute("role", "listbox");
wrap.appendChild(dropdown);
const hide = () => {
DomUtils.hide(dropdown);
nav.closed();
};
const nav = new ListNav(searchInput, dropdown, {
itemSelector: ".search-dropdown-item",
activeClass: "active",
chooseOnTab: false,
isOpen: () => DomUtils.isShown(dropdown),
onChoose: (item) => { window.location.href = item.href; },
onEscape: hide,
});
let debounceTimer = null;
searchInput.addEventListener("input", () => {
clearTimeout(debounceTimer);
const q = searchInput.value.trim();
if (q.length < 1) {
dropdown.innerHTML = "";
hide();
return;
}
debounceTimer = setTimeout(async () => {
try {
const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`);
const results = data.results || [];
if (results.length === 0) {
hide();
return;
}
dropdown.innerHTML = "";
for (const r of results) {
const item = document.createElement("a");
item.className = "search-dropdown-item";
item.href = `/messages?with_uid=${r.uid}`;
const label = document.createElement("span");
label.textContent = r.username;
item.append(Avatar.imgElement(r.username), label);
dropdown.appendChild(item);
}
DomUtils.show(dropdown);
nav.opened();
} catch (e) {
hide();
}
}, 200);
});
document.addEventListener("click", (e) => {
if (!wrap.contains(e.target)) {
hide();
}
});
}
}
-527
View File
@@ -1,527 +0,0 @@
// retoor <retoor@molodetz.nl>
import { MessagesSocket } from "./MessagesSocket.js";
import { contentRenderer } from "./ContentRenderer.js";
const TYPING_THROTTLE_MS = 1500;
const TYPING_HIDE_MS = 4000;
const AUTO_SCROLL_MARGIN_PX = 100;
const STABILIZE_MAX_FRAMES = 300;
export class MessagesLayout {
constructor() {
this.layout = document.querySelector(".messages-layout");
if (!this.layout) {
return;
}
this.thread = document.querySelector(".messages-thread");
this.form = document.querySelector(".messages-input-area");
this.input = this.form ? this.form.querySelector('input[name="content"]') : null;
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;
this._userAtBottom = true;
this._stabilizeFrames = 0;
this._stabilizePending = false;
// Expose for diagnostics
window.__messagesLayout = this;
this.scrollThreadToEnd();
if (this.input) {
this.input.focus({ preventScroll: true });
}
this.connect();
this.bindForm();
this.bindTyping();
this.bindViewport();
this.bindAutoScroll();
this._startScrollWatcher();
this.markRead();
}
_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 });
}
bindViewport() {
const page = document.querySelector(".page-messages");
if (!page || !this.input || !this.form) return;
const viewport = window.visualViewport;
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`);
this._userAtBottom = true;
this.scrollThreadToEnd();
}
};
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;
}
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";
});
this.input.addEventListener("focus", () => {
this._userAtBottom = true;
const doScroll = () => {
this.scrollThreadToEnd();
if (window.innerWidth < 768) {
const retry = (delay) => setTimeout(() => {
ensureInputVisible();
this.scrollThreadToEnd();
}, delay);
retry(100);
retry(350);
retry(600);
}
};
requestAnimationFrame(doScroll);
});
this.input.addEventListener("blur", () => {
page.style.setProperty("--kb-inset", "0px");
});
ensureInputVisible();
}
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 });
});
}
appendOptimistic(content, clientId, attachmentCount) {
// Results in double message. Should be fixed in future..
/*
const bubble = this.buildBubble({
content,
mine: true,
clientId,
time: "now",
iso: new Date().toISOString(),
attachmentCount,
});
bubble.classList.add("pending");*/
//this.insertBubble(bubble);
}
handleIncoming(frame) {
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;
}
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;
const oldBody = pending.querySelector(".rendered-content");
if (oldBody && frame.content !== undefined) {
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);
}
const time = pending.querySelector(".message-time");
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;
}
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,
iso: frame.created_at,
uid: frame.uid,
senderRole: frame.sender_role,
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);
}
buildBubble({ content, mine, time, iso, uid, clientId, senderRole, attachments, attachmentCount }) {
const bubble = document.createElement("div");
bubble.className = "message-bubble " + (mine ? "mine" : "theirs");
if (uid) bubble.dataset.msgUid = uid;
if (clientId) bubble.dataset.clientId = clientId;
const el = document.createElement("dp-content");
el.setAttribute("no-copy", "");
if (senderRole === "Admin") el.setAttribute("data-author-admin", "");
el.textContent = content || "";
bubble.appendChild(el);
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);
}
const timeEl = document.createElement(iso ? "time" : "span");
timeEl.className = "message-time";
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 || "";
}
bubble.appendChild(timeEl);
if (mine) {
const receipt = document.createElement("span");
receipt.className = "message-receipt";
receipt.hidden = true;
receipt.innerHTML = "&#x2713;&#x2713;";
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");
if (preview) preview.textContent = contentRenderer.preview(frame.content, 60);
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);
}
}
scrollThreadToEnd() {
if (!this.thread) return;
if (!this._userAtBottom) return;
this.thread.scrollTop = this.thread.scrollHeight;
}
}
-70
View File
@@ -1,70 +0,0 @@
// retoor <retoor@molodetz.nl>
const RECONNECT_DELAY_MS = 1500;
const WRONG_WORKER_RETRY_MS = 200;
const WRONG_WORKER_CODE = 4013;
export class MessagesSocket {
constructor(handlers) {
this.handlers = handlers || {};
this.ws = null;
this.url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/messages/ws";
this._shouldRun = false;
this._settled = false;
}
connect() {
this._shouldRun = true;
this._open();
}
isOpen() {
return this.ws && this.ws.readyState === WebSocket.OPEN;
}
_open() {
this._settled = false;
this.ws = new WebSocket(this.url);
this.ws.addEventListener("open", () => this._emit("onOpen"));
this.ws.addEventListener("close", (event) => {
const transient = !this._settled && event.code === WRONG_WORKER_CODE;
if (!transient) this._emit("onClose");
if (this._shouldRun) {
const delay = transient ? WRONG_WORKER_RETRY_MS : RECONNECT_DELAY_MS;
window.setTimeout(() => this._open(), delay);
}
});
this.ws.addEventListener("error", () => this._emit("onError"));
this.ws.addEventListener("message", (event) => {
let payload;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
if (!this._settled) {
this._settled = true;
this._emit("onReady", payload);
}
this._emit("onMessage", payload);
});
}
send(message) {
if (this.isOpen()) {
this.ws.send(JSON.stringify(message));
return true;
}
return false;
}
close() {
this._shouldRun = false;
if (this.ws) this.ws.close();
}
_emit(name, payload) {
const handler = this.handlers[name];
if (typeof handler === "function") handler(payload);
}
}
+84
View File
@@ -0,0 +1,84 @@
// retoor <retoor@molodetz.nl>
const WRONG_WORKER_CODE = 4013;
const WRONG_WORKER_RETRY_MS = 200;
const INITIAL_BACKOFF_MS = 200;
const MAX_BACKOFF_MS = 5000;
export class ChatSocket {
constructor(url, handlers) {
this.url = url;
this.handlers = handlers || {};
this.socket = null;
this._shouldRun = false;
this._settled = false;
this._backoff = INITIAL_BACKOFF_MS;
}
connect() {
this._shouldRun = true;
this._open();
}
isOpen() {
return !!this.socket && this.socket.readyState === WebSocket.OPEN;
}
_open() {
this._settled = false;
const socket = new WebSocket(this.url);
this.socket = socket;
socket.addEventListener("message", (event) => {
let payload;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
if (!this._settled) {
this._settled = true;
this._backoff = INITIAL_BACKOFF_MS;
this._emit("onReady", payload);
}
this._emit("onMessage", payload);
});
socket.addEventListener("close", (event) => {
const wrongWorker = !this._settled && event.code === WRONG_WORKER_CODE;
this._settled = false;
if (wrongWorker) {
if (this._shouldRun) {
window.setTimeout(() => this._open(), WRONG_WORKER_RETRY_MS);
}
return;
}
this._emit("onClose", event);
if (this._shouldRun) {
const delay = this._backoff;
this._backoff = Math.min(this._backoff * 2, MAX_BACKOFF_MS);
window.setTimeout(() => this._open(), delay);
}
});
socket.addEventListener("error", (event) => this._emit("onError", event));
}
send(frame) {
if (this.isOpen()) {
this.socket.send(JSON.stringify(frame));
return true;
}
return false;
}
close() {
this._shouldRun = false;
if (this.socket) this.socket.close();
}
_emit(name, payload) {
const handler = this.handlers[name];
if (typeof handler === "function") handler(payload);
}
}
@@ -0,0 +1,21 @@
// retoor <retoor@molodetz.nl>
export const GROUP_GAP_SECONDS = 300;
export function toEpochMs(value) {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (!value) return null;
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? null : parsed;
}
export function shouldGroup(previousMessage, currentMessage) {
if (!previousMessage || !currentMessage) return false;
if (previousMessage.senderUid !== currentMessage.senderUid) return false;
const previousMs = toEpochMs(previousMessage.createdAt);
const currentMs = toEpochMs(currentMessage.createdAt);
if (previousMs === null || currentMs === null) return false;
const gapSeconds = (currentMs - previousMs) / 1000;
if (gapSeconds > GROUP_GAP_SECONDS) return false;
return true;
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -12,4 +12,5 @@ import "./AppLightbox.js";
import "./AppDocsChat.js";
import "./AppIsslop.js";
import "./AppIsslopRun.js";
import "./AppChat.js";
import "./ContainerTerminal.js";