@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 = "✓✓";
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -12,4 +12,5 @@ import "./AppLightbox.js";
|
||||
import "./AppDocsChat.js";
|
||||
import "./AppIsslop.js";
|
||||
import "./AppIsslopRun.js";
|
||||
import "./AppChat.js";
|
||||
import "./ContainerTerminal.js";
|
||||
|
||||
Reference in New Issue
Block a user