fix(client): batch of 15 client correctness fixes (#1366)

* fix(message-list): rebuild virtual window when scroll leaves the rendered range

Scroll-driven renderWindow calls previously never rebuilt the DOM, so
scrolling past the overscan showed only spacer blank space until an
unrelated data change forced a full re-render. The window now rebuilds
whenever the computed visible range is not fully contained in the
rendered one, keeping the no-op (and the existing rebuild rate limiter)
for ranges that are already rendered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(messages): refetch channel tail when revisiting a previously loaded channel

The server only delivers live message broadcasts for the focused channel,
so a channel's loaded window stops updating once the user switches away.
Switching channels now drops the left channel's loaded flag so the next
visit refetches the live tail, while keeping the old rows rendered until
the refetch merges in (pending/failed rows are preserved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(channel-sidebar): assign distinct slots when reordering channels with tied positions

Categories whose channels share a position value (the server does not
enforce uniqueness, and new channels default to position 0) previously
produced an empty or partial reorder on drop, leaving the final order
ambiguous. Tied slots are now nudged into a strictly increasing sequence
before being reassigned, while already-distinct groups keep their range.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(embeds): keep the link-preview abort timer armed until the body is read

The link-preview fetch previously cleared its 5 s abort timer as soon as
response headers arrived, so reading the response body was unbounded in
time and size. The timer is now cleared in a finally after the body read,
so the timeout covers the whole request and the 50 KB parse cap applies
as documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(message-list): anchor scroll-to-bottom and jump-to-present controls outside the scroller

The two floating controls were appended inside the overflow scroller, so
they were part of its scrollable overflow and translated away with the
content whenever the user scrolled up — precisely when they become
visible. They now anchor to a position:relative frame that wraps the
scroller, keeping them pinned to the viewport edge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(voice): reset the pinned input device before cycling the mic to default

Selecting the default microphone (or losing the selected one to a hot
unplug) only muted and unmuted the existing track, which kept capturing
from the previously pinned device. The shared cycle now resets the
capture device to the system default first so both paths actually reach
it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(voice): re-check supersession after camera publish before announcing camera on

A camera disable that completes while the enable's publishTrack call is
still in flight now causes the enable to unpublish and stop its track and
skip the enabled announcement, so the server's last word matches the local
state instead of reporting a stopped camera as on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(voice): re-check supersession across the screenshare publish loop

A screenshare disable that completes while a publish in the enable loop is
still in flight now stops the loop before the remaining tracks are
published; the enable attempt unpublishes and stops all of its tracks and
skips the enabled announcement, so tracks held only by that attempt are
released and the server's last word matches the local state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(sidebar): rethrow channel modal API failures so modals can recover

The create/edit/delete channel callbacks caught API errors and only
showed a toast, so the awaiting modal never saw the failure and left its
submit button disabled with the in-flight label. The callbacks now
rethrow after toasting, letting each modal re-enable its button and
render its inline error so the user can retry without losing the form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(messages): carry pending and failed rows across the prepend trim

When a scroll-up page pushed a channel past the per-channel cap,
prependMessages trimmed the tail wholesale, deleting pending/failed
optimistic rows that hold the only copy of the user's composed text.
The trim now carries those rows across, matching the other
window-replacing writers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(channel-sidebar): re-resolve the drop container when the sidebar re-renders mid-drag

A store-driven sidebar re-render while a drag is in flight rebuilds the
channel rows, detaching the container captured at mousedown; detached
rows report all-zero rects, so the drop and the hover indicator could
never resolve. The global handlers now re-target the live row, its
container, and the store's current group snapshot before hit-testing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(delete-channel-modal): re-arm the confirm button when a delete fails

The confirm button was only restored from the catch block, so a caller
that handled the failure itself and resolved left the button disabled on
'Deleting...' with no way to retry. Restoration now runs in a finally
block whenever the modal is still open, regardless of how the callback
settled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(dm-sidebar): keep the presence dot when an avatar image loads

The avatar swap cleared the whole circle before inserting the fetched
image, which also removed the online/idle/dnd/offline dot on 1:1 rows.
The initial now lives in its own node and only that node is replaced,
so the presence dot survives the image arriving.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(formatting): compute the yesterday boundary from the calendar date

The relative-day fence post was derived by subtracting a fixed 24 hours
from local midnight, which lands inside the wrong calendar day when a
DST transition makes the local day 23 or 25 hours long. It is now built
from the calendar date directly, so hover and expanded message
timestamps keep the correct Yesterday label around transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

* fix(message-input): keep the empty-edit guard active while attachments are queued

Edits are text-only, so a queued attachment no longer bypasses the
empty-content guard while editing. Submitting an edit whose text was
cleared is now refused with edit mode intact, matching the behavior
when nothing is attached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-08-14 05:55:00 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent fa50d85413
commit b1fb56511d
26 changed files with 1046 additions and 66 deletions
@@ -93,8 +93,14 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
} catch (err) {
errorEl.style.display = "block";
setText(errorEl, err instanceof Error ? err.message : "Failed to delete channel");
deleteBtn.removeAttribute("disabled");
setText(deleteBtn, "Delete Channel");
} finally {
// Re-arm the button whether the caller rejected or handled the
// failure itself and resolved. A successful delete destroys the
// modal inside onConfirm, so the overlay is gone and this no-ops.
if (overlay?.isConnected === true) {
deleteBtn.removeAttribute("disabled");
setText(deleteBtn, "Delete Channel");
}
}
},
{ signal: ac.signal },
@@ -83,7 +83,10 @@ const STATUS_COLORS: Record<string, string> = {
* directly.
*/
function paintAvatar(el: HTMLElement, avatar: string | null, label: string): void {
setText(el, label.charAt(0).toUpperCase());
// The letter lives in its own node so the swap below can remove just it —
// anything else in the circle (the 1:1 presence dot) must survive the image.
const letter = document.createTextNode(label.charAt(0).toUpperCase());
el.appendChild(letter);
if (!isRenderableAvatar(avatar)) return;
const resolved = resolveServerUrl(avatar);
void fetchImageAsDataUrl(resolved).then((dataUrl) => {
@@ -92,8 +95,8 @@ function paintAvatar(el: HTMLElement, avatar: string | null, label: string): voi
img.style.width = "100%";
img.style.height = "100%";
img.style.borderRadius = "50%";
el.textContent = "";
el.appendChild(img);
letter.remove();
el.insertBefore(img, el.firstChild);
});
}
@@ -469,7 +469,10 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
if (textarea === null) return;
const content = textarea.value.trim();
const hasAttachments = pendingAttachments.length > 0;
if (content.length === 0 && !hasAttachments) return;
// Edits are text-only, so a queued attachment must not unlock submitting
// an edit whose text was cleared -- that would tear down edit mode for a
// send the host refuses anyway.
if (content.length === 0 && (state.editing !== null || !hasAttachments)) return;
// Block send while uploads are still in flight
if (pendingUploadCount > 0) {
@@ -243,6 +243,11 @@ export type MessageListComponent = MountableComponent & {
export function createMessageList(options: MessageListOptions): MessageListComponent {
const ac = new AbortController();
const unsubscribers: Array<() => void> = [];
/** Non-scrolling frame around the scroller; what is actually appended to
* the parent. The floating controls anchor to this box — an absolutely
* positioned box whose containing block is the scroller itself sits in
* its scrollable overflow and translates with the content. */
let region: HTMLDivElement | null = null;
let root: HTMLDivElement | null = null;
let wasAtBottom = true;
@@ -492,12 +497,15 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
const start = Math.max(0, firstVisible - OVERSCAN);
const end = Math.min(virtualItems.length, lastVisible + OVERSCAN + 1);
// Only rebuild DOM if explicitly requested by renderAll (which sets
// renderedStart to -1). Scroll-driven renderWindow calls only update
// spacers — never rebuild content. This prevents the height oscillation
// loop where images loading → height change → range recalculation →
// DOM rebuild → images reload → repeat forever.
if (renderedStart < 0) {
// Rebuild the DOM when explicitly requested by renderAll (which sets
// renderedStart to -1) or when the target range has left the rendered
// window — scrolling past the overscan must materialize the rows the
// spacers are standing in for. When the range is already fully rendered
// this is a no-op, which (together with the rebuild rate limiter below)
// prevents the height oscillation loop where images loading → height
// change → range recalculation → DOM rebuild → images reload → repeat.
const rangeAlreadyRendered = renderedStart >= 0 && start >= renderedStart && end <= renderedEnd;
if (!rangeAlreadyRendered) {
// Rate-limit DOM rebuilds only (expensive path).
// Scroll-driven spacer updates are cheap and don't need limiting.
renderWindowCount++;
@@ -512,7 +520,8 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
}, 2000);
}
// Full rebuild requested by renderAll
// Full rebuild: requested by renderAll, or the window is following a
// scroll into a region that is not rendered yet.
log.debug("renderWindow REBUILD", { start, end });
// Measure current elements before replacing.
@@ -534,9 +543,10 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
measureRendered();
updateSpacers();
} else {
// Scroll-driven: no-op. The ResizeObserver handles measurement and
// spacer updates when element sizes change. Calling measureRendered +
// updateSpacers here creates an infinite feedback loop:
// Target range already fully rendered: no-op. The ResizeObserver
// handles measurement and spacer updates when element sizes change.
// Calling measureRendered + updateSpacers here creates an infinite
// feedback loop:
// spacer change → scrollHeight change → scroll event → renderWindow
// → spacer change → ...
}
@@ -776,6 +786,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
// ---------------------------------------------------------------------------
function mount(parentContainer: Element): void {
region = createElement("div", { class: "messages-region" });
root = createElement("div", { class: "messages-container" });
topSpacer = createElement("div", { class: "virtual-spacer-top" });
@@ -807,8 +818,9 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
root.appendChild(contentContainer);
root.appendChild(bottomSpacer);
root.appendChild(scrollAnchor);
root.appendChild(scrollToBottomBtn);
root.appendChild(jumpToPresentPill);
region.appendChild(root);
region.appendChild(scrollToBottomBtn);
region.appendChild(jumpToPresentPill);
root.addEventListener("scroll", handleScroll, {
signal: ac.signal,
@@ -847,7 +859,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
});
resizeObserver.observe(contentContainer);
parentContainer.appendChild(root);
parentContainer.appendChild(region);
renderAll();
updateJumpToPresentPill();
@@ -933,10 +945,11 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
heightCache.clear();
tree = null;
releaseTrackedMedia();
if (root !== null) {
root.remove();
root = null;
if (region !== null) {
region.remove();
region = null;
}
root = null;
contentContainer = null;
topSpacer = null;
bottomSpacer = null;
@@ -4,7 +4,7 @@
* Gated on MANAGE_CHANNELS, like every other channel-management affordance.
*/
import { updateChannelPosition } from "@stores/channels.store";
import { channelsStore, updateChannelPosition } from "@stores/channels.store";
import type { Channel } from "@stores/channels.store";
import type { ChannelReorderData } from "../ChannelSidebar";
import { canManageChannels } from "@lib/permissions";
@@ -53,6 +53,39 @@ function releaseOwner(owner: AbortSignal): void {
}
}
/** A sidebar re-render replaces the channel rows mid-drag
* (ChannelSidebar.renderChannels() clears the list and rebuilds every
* group), leaving the captured container detached — and detached rows
* report all-zero rects, so no hit-test against them can succeed. Re-point
* the drag at the dragged channel's live row (found by its stamped id), its
* live container, and the store's current snapshot of that group, so the
* drop still resolves. Returns false when no live row exists (e.g. the
* channel was deleted or its category collapsed mid-drag). */
function retargetDetachedDrag(drag: DragState): boolean {
if (drag.containerEl.isConnected) {
return true;
}
const row = document.querySelector<HTMLElement>(`[data-drag-channel-id="${drag.channelId}"]`);
const container = row?.closest<HTMLElement>(".category-channels-container") ?? null;
if (row === null || container === null) {
return false;
}
const byId = channelsStore.getState().channels;
const channels: Channel[] = [];
for (const item of container.querySelectorAll<HTMLElement>("[data-drag-channel-id]")) {
const ch = byId.get(Number(item.dataset.dragChannelId));
if (ch !== undefined) {
channels.push(ch);
}
}
drag.sourceEl.classList.remove("dragging");
drag.sourceEl = row;
drag.sourceEl.classList.add("dragging");
drag.containerEl = container;
drag.channels = channels;
return true;
}
export function ensureGlobalDragListeners(owner: AbortSignal): void {
if (owner.aborted || listenerOwners.has(owner)) {
return;
@@ -70,6 +103,9 @@ export function ensureGlobalDragListeners(owner: AbortSignal): void {
if (activeDrag === null) {
return;
}
if (!retargetDetachedDrag(activeDrag)) {
return;
}
// Clear old indicators
activeDrag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => {
x.classList.remove("channel-drop-indicator");
@@ -100,6 +136,10 @@ export function ensureGlobalDragListeners(owner: AbortSignal): void {
const drag = activeDrag;
activeDrag = null;
// Re-target before cleanup so the classes are cleared from the live
// rows, not a detached subtree.
const retargeted = retargetDetachedDrag(drag);
// Clean up visual state
drag.sourceEl.classList.remove("dragging");
document.body.classList.remove("channel-reordering");
@@ -107,6 +147,10 @@ export function ensureGlobalDragListeners(owner: AbortSignal): void {
x.classList.remove("channel-drop-indicator");
});
if (!retargeted) {
return;
}
// Find drop target
const items = drag.containerEl.querySelectorAll("[data-drag-channel-id]");
let dropTargetId: number | null = null;
@@ -149,6 +193,17 @@ export function ensureGlobalDragListeners(owner: AbortSignal): void {
// non-contiguous positions (interleaved with other categories), and
// renumbering from 0 would stomp another category's slots.
const slots = drag.channels.map((c) => c.position).sort((a, b) => a - b);
// The server does not enforce unique positions (newly created channels
// commonly all sit at 0), and zipping tied slots onto the new order
// would drop some or all of the moves. Nudge ties upward so every slot
// is distinct; already-distinct groups keep their exact range.
for (let i = 1; i < slots.length; i++) {
const prev = slots[i - 1];
const cur = slots[i];
if (prev !== undefined && cur !== undefined && cur <= prev) {
slots[i] = prev + 1;
}
}
const reorders: ChannelReorderData[] = [];
for (let i = 0; i < reorderedIds.length; i++) {
const id = reorderedIds[i];
@@ -165,9 +165,12 @@ function fetchOgMeta(url: string): Promise<OgMeta> {
log.debug("fetchOgMeta START", url.slice(0, 100));
const promise = (async (): Promise<OgMeta> => {
// The abort timer stays armed until the body is fully read (cleared in the
// finally below), so the 5 s timeout bounds the body download as well as
// the header phase — an unbounded stream is aborted, not buffered.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
const fetchOpts: RequestInit = {
signal: controller.signal,
headers: {
@@ -179,7 +182,6 @@ function fetchOgMeta(url: string): Promise<OgMeta> {
// Self-signed servers are handled by the Rust TLS proxy for WebSocket;
// OG preview fetches should respect standard certificate validation.
const res = await tauriFetch(url, fetchOpts);
clearTimeout(timer);
if (!res.ok) {
if (generation !== embedCacheGeneration) {
@@ -213,6 +215,8 @@ function fetchOgMeta(url: string): Promise<OgMeta> {
}
ogCache.set(url, EMPTY_OG);
return EMPTY_OG;
} finally {
clearTimeout(timer);
}
})();
@@ -78,7 +78,9 @@ export function formatMessageTimestamp(iso: string): string {
const timeStr = CLOCK_TIME_FORMAT.format(date);
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterdayStart = new Date(todayStart.getTime() - 86_400_000);
// Built from the calendar date, not todayStart - 24h: a DST-transition day
// is 23 or 25 hours long, and Date normalizes day 0 / negative days.
const yesterdayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
if (date >= todayStart) {
return `Today at ${timeStr}`;
+13 -5
View File
@@ -63,12 +63,20 @@ export class DeviceManager {
this.onToast = cb;
}
/** Toggle the mic off/on to force a fresh capture after a device change,
* skipping the re-enable when a mute/deafen/server-mute/PTT gate is
* active. Shared by handleDeviceChange's device-removed fallback and
* switchInputDevice('') — both drive the exact same false/true cycle, and
* both were unconditionally republishing a gated mic before this guard. */
/** Reset the capture device to the system default, then toggle the mic
* off/on to force a fresh capture, skipping the re-enable when a
* mute/deafen/server-mute/PTT gate is active. Shared by
* handleDeviceChange's device-removed fallback and switchInputDevice('')
* both drive the exact same reset + false/true cycle, and both were
* unconditionally republishing a gated mic before this guard. */
private async cycleMicForDeviceSwitch(room: Room): Promise<void> {
// A previous switchActiveDevice pins audioCaptureDefaults.deviceId as an
// exact constraint that survives the off/on cycle, so the cycle alone
// re-acquires the old device. Reset the pin to the system default first;
// exact=false keeps the constraint ideal so this degrades gracefully
// where no "default" device id exists.
await room.switchActiveDevice("audioinput", "default", false);
if (this.room !== room) return;
await room.localParticipant.setMicrophoneEnabled(false);
if (this.room !== room) return;
if (isMicPolicyGated()) {
@@ -248,6 +248,21 @@ export async function enableCamera(state: CameraTrackState, deps: VideoTrackDeps
maxFramerate: quality === "low" ? 15 : 30,
},
});
if ((state.generation ?? 0) !== generation) {
// A disableCamera ran to completion while publishTrack was in flight —
// it already reset localCamera and sent voice_camera(false). The publish
// may have landed after its unpublish, so undo it again, and stay silent:
// announcing voice_camera(true) now would override the disable's final
// word on the server.
try {
void room.localParticipant.unpublishTrack(videoTrack.mediaStreamTrack);
} catch {
/* already unpublished */
}
videoTrack.stop();
if (state.manualCameraTrack === videoTrack) state.manualCameraTrack = null;
return;
}
const sendId = ws.send({ type: "voice_camera", payload: { enabled: true } });
registerPendingVideoEnable(sendId, "camera");
deps.reapplyAudioPipeline();
@@ -354,6 +369,26 @@ export async function enableScreenshare(
}
: {}),
});
if ((state.generation ?? 0) !== generation) {
// A disableScreenshare ran to completion while that publish was in
// flight — it already reset localScreenshare, sent voice_screenshare
// (false) and emptied state.manualScreenTracks, so the tracks still
// held by this attempt are unreachable from any later disable. Undo
// every one of them here (a publish may have landed after the
// disable's unpublish), and stay silent: announcing
// voice_screenshare(true) now would override the disable's final
// word on the server.
for (const t of screenTracks) {
try {
void room.localParticipant.unpublishTrack(t.mediaStreamTrack);
} catch {
/* already unpublished */
}
t.stop();
}
if (state.manualScreenTracks === screenTracks) state.manualScreenTracks = [];
return;
}
}
// BUG-101: Listen for OS "Stop sharing" so the app runs the full disable path.
const videoTrack = screenTracks.find((t) => t.kind === Track.Kind.Video);
@@ -25,6 +25,7 @@ import {
removeOptimistic,
reattachToPresent,
isWindowDetached,
invalidateChannelMessageWindow,
} from "@stores/messages.store";
import { jumpToMessage } from "@lib/message-navigation";
import { authStore } from "@stores/auth.store";
@@ -174,6 +175,12 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
// open) — it only repairs the server's view.
if (previousChannelId !== null) {
markChannelRead(previousChannelId);
// The server only delivers live broadcasts for the focused channel, so
// the window being left stops updating the moment focus moves here.
// Drop its loaded flag so the next visit refetches the live tail
// instead of rendering the old snapshot as current — setMessages'
// merge preserves any pending/failed rows across that refetch.
invalidateChannelMessageWindow(previousChannelId);
}
log.info("Switching channel", { channelId, channelName });
@@ -280,6 +280,9 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to create channel";
getToast()?.show(msg, "error");
// The modal's own catch re-enables its submit button and renders
// the inline error, so the failure must propagate to it.
throw err;
}
},
onClose: () => {
@@ -314,6 +317,9 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to update channel";
getToast()?.show(msg, "error");
// Propagate so the modal re-enables its save button and shows
// the inline error.
throw err;
}
},
onClose: () => {
@@ -337,6 +343,9 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to delete channel";
getToast()?.show(msg, "error");
// Propagate so the modal re-enables its confirm button and shows
// the inline error.
throw err;
}
},
onClose: () => {
@@ -559,6 +559,27 @@ export function invalidateLoadedMessageWindows(): void {
});
}
/**
* Drop one channel's loaded flag so the next history fetch reloads the live
* tail. The server only delivers live broadcasts for the focused channel, so
* a window left behind on a channel switch stops updating the moment focus
* moves away — the next visit must refetch instead of short-circuiting on
* "already loaded". The rows themselves are kept (the old window stays
* rendered until the refetch lands) and setMessages' merge carries
* pending/failed rows across that refetch. Like reattachToPresent, this
* leaves detachedChannels alone: setMessages clears it once the tail has
* actually landed, and until then a detached window must keep refusing live
* broadcasts.
*/
export function invalidateChannelMessageWindow(channelId: number): void {
messagesStore.setState((prev) => {
if (!prev.loadedChannels.has(channelId)) return prev;
const updatedLoaded = new Set(prev.loadedChannels);
updatedLoaded.delete(channelId);
return { ...prev, loadedChannels: updatedLoaded };
});
}
/**
* Drop a channel's loaded flag so the next history fetch reloads the live
* tail — otherwise MessageController short-circuits on "already loaded" and
@@ -594,12 +615,16 @@ export function prependMessages(
// Keep the OLDEST rows (start of array) when the cap is exceeded: the
// user is scrolling up, so the fetched page must survive — trimming it
// would make every cap-hit prepend a content-identical no-op that
// refetches the same page forever. The dropped live tail is restored via
// refetches the same page forever. Dropped "sent" rows are restored via
// the detached-window machinery ("Jump to Present"), mirroring
// setAroundMessages' window semantics.
// setAroundMessages' window semantics — but pending/failed rows in the
// tail are the only copy of the user's composed text, so they are carried
// across the trim exactly as every other window-replacing writer does.
const wasTrimmed = combined.length > MAX_MESSAGES_PER_CHANNEL;
if (wasTrimmed) {
combined = combined.slice(0, MAX_MESSAGES_PER_CHANNEL);
const kept = combined.slice(0, MAX_MESSAGES_PER_CHANNEL);
const carried = combined.slice(MAX_MESSAGES_PER_CHANNEL).filter((m) => m.status !== "sent");
combined = carried.length > 0 ? [...kept, ...carried] : kept;
}
const updatedMessages = new Map(prev.messagesByChannel);
updatedMessages.set(channelId, combined);
+13
View File
@@ -893,6 +893,19 @@
}
/* ── Messages ── */
/* Non-scrolling frame around the message scroller. The floating controls
(scroll-to-bottom button, Jump to Present pill) anchor to this box: an
absolutely positioned box whose containing block is the scroller itself is
part of its scrollable overflow and translates with the content, so the
controls need a positioned ancestor outside the scroller to stay pinned to
the viewport edge. */
.messages-region {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
}
.messages-container {
flex: 1;
overflow-y: auto;
@@ -130,11 +130,13 @@ vi.mock("@lib/read-state", () => ({
const { mockRole } = vi.hoisted(() => ({ mockRole: { value: "member" } }));
const { mockReattachToPresent, mockJumpToMessage, mockIsWindowDetached } = vi.hoisted(() => ({
mockReattachToPresent: vi.fn(),
mockJumpToMessage: vi.fn(),
mockIsWindowDetached: vi.fn(() => false),
}));
const { mockReattachToPresent, mockJumpToMessage, mockIsWindowDetached, mockInvalidateWindow } =
vi.hoisted(() => ({
mockReattachToPresent: vi.fn(),
mockJumpToMessage: vi.fn(),
mockIsWindowDetached: vi.fn(() => false),
mockInvalidateWindow: vi.fn(),
}));
vi.mock("@stores/messages.store", () => ({
getChannelMessages: mockGetChannelMessages,
@@ -144,6 +146,7 @@ vi.mock("@stores/messages.store", () => ({
removeOptimistic: mockRemoveOptimistic,
reattachToPresent: mockReattachToPresent,
isWindowDetached: mockIsWindowDetached,
invalidateChannelMessageWindow: mockInvalidateWindow,
}));
vi.mock("@lib/message-navigation", () => ({
@@ -402,6 +405,36 @@ describe("createChannelController", () => {
expect(mockMarkChannelRead).not.toHaveBeenCalled();
});
it("invalidates the previous channel's loaded window when switching away, so a revisit refetches the tail", () => {
// The server only delivers live broadcasts for the focused channel, so
// the window being left stops updating the moment focus moves away.
// loadMessages short-circuits on the loaded flag — leaving must drop it
// or the revisit renders the old snapshot as if it were current.
const opts = makeOpts();
const ctrl = createChannelController(opts);
ctrl.mountChannel(42, "general");
expect(mockInvalidateWindow).not.toHaveBeenCalled();
ctrl.mountChannel(99, "random");
expect(mockInvalidateWindow).toHaveBeenCalledWith(42);
ctrl.mountChannel(42, "general");
expect(mockInvalidateWindow).toHaveBeenCalledWith(99);
// Each mount still asks for the tail exactly once.
expect(opts.msgCtrl.loadMessages).toHaveBeenCalledTimes(3);
});
it("does not invalidate any window on the very first mount, which fetches exactly once", () => {
const opts = makeOpts();
const ctrl = createChannelController(opts);
ctrl.mountChannel(42, "general");
expect(mockInvalidateWindow).not.toHaveBeenCalled();
expect(opts.msgCtrl.loadMessages).toHaveBeenCalledTimes(1);
});
it("updates chat header name", () => {
const opts = makeOpts();
const ctrl = createChannelController(opts);
@@ -123,6 +123,29 @@ describe("DeleteChannelModal", () => {
modal.destroy?.();
});
it("re-arms the confirm button when onConfirm resolves without closing the modal", async () => {
// A caller may handle the failure itself (toast) and resolve instead of
// rejecting; the modal must not stay stuck disabled on "Deleting...".
const onConfirm = vi.fn(async () => {});
const { modal } = makeModal({ onConfirm });
const deleteBtn = container.querySelector(
"[data-testid='delete-channel-confirm']",
) as HTMLButtonElement;
deleteBtn.click();
await vi.waitFor(() => {
expect(deleteBtn.hasAttribute("disabled")).toBe(false);
});
expect(deleteBtn.textContent).toBe("Delete Channel");
// No rejection reached the modal, so no inline error either.
const error = container.querySelector("[data-testid='delete-channel-error']") as HTMLElement;
expect(error.style.display).toBe("none");
modal.destroy?.();
});
it("disables button and shows 'Deleting...' during delete", async () => {
let resolveDelete: (() => void) | undefined;
const onConfirm = vi.fn<any>(
@@ -232,6 +232,33 @@ describe("DeviceManager", () => {
it("re-enables microphone for empty deviceId (default fallback)", async () => {
dm.setRoom(mockRoom);
await dm.switchInputDevice("");
// The pinned capture-device constraint must be reset to the system
// default before the cycle — otherwise the off/on toggle re-acquires
// whatever device a previous switchActiveDevice pinned.
expect(mockRoom.switchActiveDevice).toHaveBeenCalledWith("audioinput", "default", false);
expect(mockRoom.switchActiveDevice.mock.invocationCallOrder[0]).toBeLessThan(
mockRoom.localParticipant.setMicrophoneEnabled.mock.invocationCallOrder[0],
);
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true);
});
it("waits for the default-device reset before cycling the mic", async () => {
let resolveSwitch: ((switched: boolean) => void) | null = null;
mockRoom.switchActiveDevice.mockImplementation(
() =>
new Promise((resolve) => {
resolveSwitch = resolve;
}),
);
dm.setRoom(mockRoom);
const done = dm.switchInputDevice("");
expect(mockRoom.switchActiveDevice).toHaveBeenCalledWith("audioinput", "default", false);
expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled();
resolveSwitch!(true);
await done;
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true);
});
@@ -396,6 +423,9 @@ describe("DeviceManager", () => {
await vi.advanceTimersByTimeAsync(600);
expect(mockSavePref).toHaveBeenCalledWith("audioInputDevice", "");
// The removed device's pinned constraint must be reset so the cycle
// actually reaches the system default.
expect(mockRoom.switchActiveDevice).toHaveBeenCalledWith("audioinput", "default", false);
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true);
expect(onToast).toHaveBeenCalledWith("Audio device disconnected — switched to default");
@@ -266,6 +266,68 @@ describe("DmSidebar", () => {
sidebar.destroy?.();
});
it("keeps the presence dot when the fetched avatar image is swapped in", async () => {
fetchImageAsDataUrl.mockResolvedValue("data:image/png;base64,CCC");
const sidebar = createDmSidebar({
conversations: [makeConvo({ avatar: "/api/v1/files/42", status: "online" })],
onSelectConversation: vi.fn(),
onNewDm: vi.fn(),
});
sidebar.mount(container);
const avatar = container.querySelector(".dm-avatar") as HTMLDivElement;
// Dot and letter are both there before the bytes arrive.
expect(avatar.querySelector(".dm-status")).not.toBeNull();
expect(avatar.textContent).toBe("A");
await vi.waitFor(() => {
expect(avatar.querySelector("img")).not.toBeNull();
});
// The image replaces the letter only — presence survives the swap.
const dot = avatar.querySelector(".dm-status") as HTMLSpanElement;
expect(dot).not.toBeNull();
expect(dot.style.background).toBe("var(--green)");
expect(avatar.textContent).toBe("");
sidebar.destroy?.();
});
it("swaps fetched images into group faces, keeping the letter for members without one", async () => {
fetchImageAsDataUrl.mockResolvedValue("data:image/png;base64,DDD");
const sidebar = createDmSidebar({
conversations: [
makeConvo({
channelId: 9,
isGroup: true,
username: "Weekend Plans",
participants: [
{ id: 2, username: "Bob", avatar: "/api/v1/files/7" },
{ id: 3, username: "Carol", avatar: null },
],
}),
],
onSelectConversation: vi.fn(),
onNewDm: vi.fn(),
});
sidebar.mount(container);
const faces = container.querySelectorAll(".dm-avatar-face");
expect(faces.length).toBe(2);
expect(faces[0]!.textContent).toBe("B");
await vi.waitFor(() => {
expect(faces[0]!.querySelector("img")).not.toBeNull();
});
expect(faces[0]!.textContent).toBe("");
// Carol has no avatar: her letter stays and nothing was fetched for her.
expect(faces[1]!.textContent).toBe("C");
expect(faces[1]!.querySelector("img")).toBeNull();
expect(fetchImageAsDataUrl).toHaveBeenCalledTimes(1);
sidebar.destroy?.();
});
it("marks active conversation with active class", () => {
const sidebar = createDmSidebar({
conversations: [makeConvo({ active: true })],
@@ -68,6 +68,37 @@ interface Rig {
* listeners are fully torn down between tests. */
const rigAborts: AbortController[] = [];
/** jsdom does not lay out, so stub the geometry the module reads: a 20px-tall
* row at y = idx*20 while attached, and — exactly like a real browser — an
* all-zero rect once the row is detached (e.g. a re-render replaced it). */
function stubRowRect(el: HTMLElement, idx: number): void {
const top = idx * 20;
el.getBoundingClientRect = () =>
el.isConnected
? {
top,
bottom: top + 20,
height: 20,
left: 0,
right: 100,
width: 100,
x: 0,
y: top,
toJSON: () => ({}),
}
: {
top: 0,
bottom: 0,
height: 0,
left: 0,
right: 0,
width: 0,
x: 0,
y: 0,
toJSON: () => ({}),
};
}
/** Builds a container with one 20px-tall row per channel, stacked vertically. */
function buildRig(channels: Channel[]): Rig {
const container = document.createElement("div");
@@ -81,20 +112,7 @@ function buildRig(channels: Channel[]): Rig {
channels.forEach((ch, idx) => {
const el = document.createElement("div");
container.appendChild(el);
// jsdom does not lay out, so stub the geometry the module reads.
const top = idx * 20;
el.getBoundingClientRect = () =>
({
top,
bottom: top + 20,
height: 20,
left: 0,
right: 100,
width: 100,
x: 0,
y: top,
toJSON: () => ({}),
}) as DOMRect;
stubRowRect(el, idx);
attachDragHandlers(el, ch, container, channels, abort.signal, onReorder);
items.set(ch.id, el);
});
@@ -102,6 +120,29 @@ function buildRig(channels: Channel[]): Rig {
return { container, items, channels, onReorder, abort };
}
/** Simulates ChannelSidebar.renderChannels() rebuilding a category group:
* fresh rows in a fresh `.category-channels-container` under the same
* sidebar owner, while the previous container sits detached. */
function rebuildContainer(
rig: Rig,
channels: Channel[],
): { container: HTMLElement; items: Map<number, HTMLElement> } {
const container = document.createElement("div");
container.className = "category-channels-container";
document.body.appendChild(container);
const items = new Map<number, HTMLElement>();
channels.forEach((ch, idx) => {
const el = document.createElement("div");
container.appendChild(el);
stubRowRect(el, idx);
attachDragHandlers(el, ch, container, channels, rig.abort.signal, rig.onReorder);
items.set(ch.id, el);
});
return { container, items };
}
/** Row `idx` spans y = idx*20 .. idx*20+20; its midpoint is +10. */
function yInRow(idx: number, half: "top" | "bottom"): number {
return idx * 20 + (half === "top" ? 4 : 16);
@@ -371,6 +412,35 @@ describe("reorder index arithmetic", () => {
expect(positionsOf(reorders)).toEqual({ 3: 5, 1: 7, 2: 9 });
});
it("still reorders when every channel in the group shares one position", () => {
// The server does not enforce unique positions, and newly created
// channels commonly all sit at position 0. Reassigning the group's own
// slots must still produce distinct positions, or the drop is a no-op.
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 0), makeCh(3, 0)]);
drag(rig, 3, 0, "top"); // ch3 before ch1 → [3, 1, 2]
expect(rig.onReorder).toHaveBeenCalledTimes(1);
const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
// ch3 keeps position 0 (unchanged, so unreported); ch1 and ch2 move up.
expect(positionsOf(reorders)).toEqual({ 1: 1, 2: 2 });
});
it("gives partially tied positions a deterministic order after the drop", () => {
signIn("owner");
// Two channels tied at 0, one at 5.
const rig = buildRig([makeCh(1, 0), makeCh(2, 0), makeCh(3, 5)]);
drag(rig, 3, 0, "top"); // ch3 before ch1 → [3, 1, 2]
expect(rig.onReorder).toHaveBeenCalledTimes(1);
const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
// Slots [0, 0, 5] become the strictly increasing [0, 1, 5]: every channel
// ends at a distinct position, so the rendered order matches the drop.
expect(positionsOf(reorders)).toEqual({ 3: 0, 1: 1, 2: 5 });
});
it("does not fire when dropped on itself", () => {
signIn("owner");
const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
@@ -463,6 +533,90 @@ describe("drop indicator", () => {
});
});
// ── mid-drag re-render ─────────────────────────────────────────────────────
//
// ChannelSidebar.renderChannels() clears the channel list and rebuilds every
// category group, so any store-driven re-render while the mouse button is
// down detaches the container the drag captured at mousedown. Detached rows
// report all-zero rects, so the drag must re-resolve the live rows or every
// hit-test silently fails.
describe("mid-drag sidebar re-render", () => {
function setStoreChannels(channels: Channel[]): void {
channelsStore.setState(() => ({
channels: new Map(channels.map((c) => [c.id, c])),
activeChannelId: null,
roles: [],
}));
}
it("resolves the drop against the live rows after a re-render replaces the container", () => {
signIn("owner");
const channels = [makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)];
setStoreChannels(channels);
const rig = buildRig(channels);
const source = rig.items.get(1)!;
// Start the drag on the original rows.
source.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
source.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
expect(source.classList.contains("dragging")).toBe(true);
// A store-driven re-render rebuilds the sidebar mid-drag.
rig.container.remove();
const live = rebuildContainer(rig, channels);
// Release over the bottom half of row 1 (ch2): ch1 lands after ch2.
document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "bottom")));
expect(rig.onReorder).toHaveBeenCalledTimes(1);
const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
expect(positionsOf(reorders)).toEqual({ 2: 0, 1: 1 });
expect(channelsStore.select((s) => s.channels.get(1)?.position)).toBe(1);
expect(channelsStore.select((s) => s.channels.get(2)?.position)).toBe(0);
expect(live.container.querySelectorAll(".channel-drop-indicator")).toHaveLength(0);
expect(document.body.classList.contains("channel-reordering")).toBe(false);
});
it("moves the drop indicator to the live rows after a re-render replaces the container", () => {
signIn("owner");
const channels = [makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)];
setStoreChannels(channels);
const rig = buildRig(channels);
const source = rig.items.get(1)!;
source.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
source.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
rig.container.remove();
const live = rebuildContainer(rig, channels);
document.dispatchEvent(mouse("mousemove", 0, yInRow(2, "top")));
expect(live.items.get(3)?.classList.contains("channel-drop-indicator")).toBe(true);
});
it("discards the drop when the dragged channel has no live row after the re-render", () => {
signIn("owner");
const channels = [makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)];
setStoreChannels(channels);
const rig = buildRig(channels);
const source = rig.items.get(1)!;
source.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
source.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
// The re-render drops ch1 entirely (deleted mid-drag).
rig.container.remove();
rebuildContainer(rig, [makeCh(2, 1), makeCh(3, 2)]);
document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "bottom")));
expect(rig.onReorder).not.toHaveBeenCalled();
expect(document.body.classList.contains("channel-reordering")).toBe(false);
});
});
// ── listener lifecycle ─────────────────────────────────────────────────────
//
// Ownership of the shared document listeners is per sidebar AbortSignal, not
@@ -842,6 +842,91 @@ describe("applyOgMeta", () => {
});
});
describe("renderGenericLinkPreview — fetch timeout covers the body read", () => {
beforeEach(() => {
document.body.innerHTML = "";
fetchMock.mockReset();
clearEmbedCaches();
setServerHost("example.com");
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = "";
});
function mockSlowBodyResponse() {
let capturedSignal: AbortSignal | undefined;
fetchMock.mockImplementationOnce(((_url: string, opts: RequestInit) => {
capturedSignal = opts.signal as AbortSignal;
return Promise.resolve({
ok: true,
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "text/html; charset=utf-8" : null,
},
// Body that never finishes on its own; rejects if the signal aborts,
// mirroring fetch semantics for an aborted in-progress body read.
text: () =>
new Promise<string>((_resolve, reject) => {
const fail = () => reject(new DOMException("The operation was aborted.", "AbortError"));
if (capturedSignal!.aborted) fail();
else capturedSignal!.addEventListener("abort", fail, { once: true });
}),
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any);
return () => capturedSignal;
}
it("aborts when the body never finishes within the 5 s timeout", async () => {
const getSignal = mockSlowBodyResponse();
const card = renderGenericLinkPreview("https://slow-body.example.com/page");
document.body.appendChild(card);
// Let the header phase resolve and the body read begin.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5000);
expect(getSignal()?.aborted).toBe(true);
// The aborted fetch settles as an empty result: the card keeps its
// hostname fallback and the URL is cached without a refetch loop.
await Promise.resolve();
await Promise.resolve();
expect(card.querySelector(".msg-embed-link-title")?.textContent).toBe("slow-body.example.com");
const again = renderGenericLinkPreview("https://slow-body.example.com/page");
document.body.appendChild(again);
expect(again.querySelector(".msg-embed-link-title")?.textContent).toBe("slow-body.example.com");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("does not abort when the body arrives before the timeout", async () => {
let capturedSignal: AbortSignal | undefined;
fetchMock.mockImplementationOnce(((_url: string, opts: RequestInit) => {
capturedSignal = opts.signal as AbortSignal;
return Promise.resolve(mockHtmlResponse("<html><head><title>Timely</title></head></html>"));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any);
const card = renderGenericLinkPreview("https://timely.example.com/page");
document.body.appendChild(card);
await vi.advanceTimersByTimeAsync(0);
expect(card.querySelector(".msg-embed-link-title")?.textContent).toBe("Timely");
await vi.advanceTimersByTimeAsync(10_000);
expect(capturedSignal?.aborted).toBe(false);
});
});
describe("renderGenericLinkPreview — cache stale during non-HTML response", () => {
beforeEach(() => {
document.body.innerHTML = "";
@@ -312,6 +312,101 @@ describe("MessageInput", () => {
comp.destroy?.();
});
// ── Emptied edits are refused, never submitted ──
it("does not submit an emptied edit while an attachment is queued", async () => {
const uploadResult = { id: "srv-9", url: "http://server/pic.png", filename: "pic.png" };
const onUploadFile = vi.fn(async () => uploadResult);
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
// Queue an attachment first, then enter edit mode.
const testFile = new File(["image data"], "pic.png", { type: "image/png" });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(fileInput, "files", { value: [testFile], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
await vi.waitFor(() => {
expect(onUploadFile).toHaveBeenCalledWith(testFile);
});
// Wait for the upload to fully settle so the send is not blocked by the
// uploads-in-flight guard instead of the empty-content one.
const previewBar = container.querySelector(".attachment-preview-bar");
await vi.waitFor(() => {
expect(previewBar!.querySelector(".uploading")).toBeNull();
});
comp.startEdit(77, "old content");
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "";
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
sendBtn.click();
// Same as with no attachment queued: the empty edit is refused and edit
// mode survives.
expect(opts.onEditMessage).not.toHaveBeenCalled();
expect(opts.onSend).not.toHaveBeenCalled();
const bars = container.querySelectorAll(".reply-bar");
const editBar = bars[1] as HTMLDivElement;
expect(editBar.classList.contains("visible")).toBe(true);
// Typing real content and sending again still submits the edit.
textarea.value = "fixed content";
sendBtn.click();
expect(opts.onEditMessage).toHaveBeenCalledWith(77, "fixed content");
comp.destroy?.();
});
it("emptied edit with no attachment queued is a no-op that stays in edit mode", () => {
const opts = makeOptions();
const comp = createMessageInput(opts);
comp.mount(container);
comp.startEdit(88, "old content");
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "";
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
sendBtn.click();
expect(opts.onEditMessage).not.toHaveBeenCalled();
expect(opts.onSend).not.toHaveBeenCalled();
const bars = container.querySelectorAll(".reply-bar");
const editBar = bars[1] as HTMLDivElement;
expect(editBar.classList.contains("visible")).toBe(true);
comp.destroy?.();
});
it("sends an attachment-only message with no text", async () => {
const uploadResult = { id: "srv-7", url: "http://server/pic.png", filename: "pic.png" };
const onUploadFile = vi.fn(async () => uploadResult);
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
const testFile = new File(["image data"], "pic.png", { type: "image/png" });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(fileInput, "files", { value: [testFile], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
await vi.waitFor(() => {
expect(onUploadFile).toHaveBeenCalledWith(testFile);
});
const previewBar = container.querySelector(".attachment-preview-bar");
await vi.waitFor(() => {
expect(previewBar!.querySelector(".uploading")).toBeNull();
});
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
sendBtn.click();
expect(opts.onSend).toHaveBeenCalledWith("", null, ["srv-7"]);
comp.destroy?.();
});
// ── Reply context is included in send ──
it("sending with reply includes replyTo messageId", () => {
@@ -252,6 +252,32 @@ describe("MessageList", () => {
expect(container.querySelector('[data-testid="message-150"]')).not.toBeNull();
});
it("rebuilds the virtual window when scrolling outside the rendered range", async () => {
setHasMore(1, false);
const many = Array.from({ length: 300 }, (_, i) => makeMessage({ id: i + 1 }));
setMessages(1, many);
msgList.mount(container);
// renderAll positions the window at the tail; rows near the top are
// virtualized away behind the top spacer.
expect(container.querySelector('[data-testid="message-1"]')).toBeNull();
expect(container.querySelector('[data-testid="message-300"]')).not.toBeNull();
// mount's trailing scrollToBottom leaves scrollTop at 0 in jsdom
// (scrollHeight is 0 without layout), so the scroll position now sits at
// the very top of the list while the rendered window is still the tail —
// exactly the state a user scrolling far past the overscan produces.
const root = container.querySelector(".messages-container") as HTMLDivElement;
expect(root.scrollTop).toBe(0);
root.dispatchEvent(new Event("scroll"));
await new Promise((resolve) => requestAnimationFrame(resolve));
// The window must follow the scroll: rows at the top render, and the old
// tail rows are released back to the spacers.
expect(container.querySelector('[data-testid="message-1"]')).not.toBeNull();
expect(container.querySelector('[data-testid="message-300"]')).toBeNull();
});
it("renders day dividers between messages on different days", () => {
const messages = [
makeMessage({ id: 1, timestamp: "2024-01-15T12:00:00Z" }),
@@ -331,6 +357,40 @@ describe("MessageList", () => {
expect(btn?.textContent).toBe("\u2193");
});
it("anchors the floating controls outside the scroller so they cannot scroll away", () => {
setMessages(1, [makeMessage({ id: 1 })]);
msgList.mount(container);
const scroller = container.querySelector(".messages-container") as HTMLDivElement;
const btn = container.querySelector(".scroll-to-bottom-btn") as HTMLButtonElement;
const pill = container.querySelector('[data-testid="jump-to-present"]') as HTMLButtonElement;
expect(scroller).not.toBeNull();
expect(btn).not.toBeNull();
expect(pill).not.toBeNull();
// Anything inside the overflow scroller is part of its scrollable
// overflow and translates with the content, so the controls must not be
// descendants of it.
expect(scroller.contains(btn)).toBe(false);
expect(scroller.contains(pill)).toBe(false);
// They anchor to the component's non-scrolling frame around the scroller
// (the positioned containing block that keeps them pinned to the
// viewport edge).
const region = scroller.parentElement as HTMLDivElement;
expect(region.classList.contains("messages-region")).toBe(true);
expect(container.contains(region)).toBe(true);
expect(btn.parentElement).toBe(region);
expect(pill.parentElement).toBe(region);
// destroy removes the frame — and with it the controls — not just the
// scroller.
msgList.destroy?.();
expect(container.querySelector(".messages-region")).toBeNull();
expect(container.querySelector(".scroll-to-bottom-btn")).toBeNull();
expect(container.querySelector('[data-testid="jump-to-present"]')).toBeNull();
});
it("calls onScrollTop when scrolling near the top and there are more messages", () => {
setHasMore(1, true);
setMessages(1, [makeMessage({ id: 1 })]);
@@ -285,6 +285,49 @@ describe("prependMessages at the message cap", () => {
// Nothing above was dropped, so "more above" is what the server said.
expect(hasMoreMessages(1)).toBe(false);
});
it("carries pending and failed rows across the trim instead of destroying them", () => {
// Fill to the 500-row cap with sent history: ids 101..600 (newest-first).
const initial: MessageResponse[] = [];
for (let id = 600; id >= 101; id--) initial.push(response(id));
setMessages(1, initial, true);
// Two optimistic rows sit at the live end of the array. Their text has no
// server copy, so the detached-window machinery cannot restore them.
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: USER,
content: "still sending",
replyTo: null,
timestamp: "2026-03-15T10:00:00Z",
});
addOptimisticMessage({
correlationId: "c2",
channelId: 1,
user: USER,
content: "went nowhere",
replyTo: null,
timestamp: "2026-03-15T10:00:01Z",
});
markSendFailed("c2", "OFFLINE");
prependMessages(1, [response(100), response(99)], false);
const loaded = getChannelMessages(1);
// Sent rows obey the cap exactly as before: the fetched page survives at
// the head, the sent tail is dropped, and the window detaches.
const sent = loaded.filter((m) => m.status === "sent");
expect(sent).toHaveLength(500);
expect(loaded[0]!.id).toBe(99);
expect(loaded[1]!.id).toBe(100);
expect(isWindowDetached(1)).toBe(true);
// The pending/failed rows survive the trim, in order, at the live end.
expect(loaded.at(-2)!.correlationId).toBe("c1");
expect(loaded.at(-2)!.status).toBe("pending");
expect(loaded.at(-1)!.correlationId).toBe("c2");
expect(loaded.at(-1)!.status).toBe("failed");
});
});
describe("hasMessageLoaded", () => {
@@ -25,6 +25,8 @@ import {
setChannelLoadError,
getHistoryLoadState,
invalidateLoadedMessageWindows,
invalidateChannelMessageWindow,
setAroundMessages,
} from "../../src/stores/messages.store";
import type {
ChatMessagePayload,
@@ -1363,6 +1365,68 @@ describe("messages store", () => {
});
});
describe("invalidateChannelMessageWindow", () => {
it("drops only that channel's loaded flag and keeps its rows for instant re-render", () => {
setMessages(1, [makeMessageResponse({ id: 10 })], false);
setMessages(2, [makeMessageResponse({ id: 20, channel_id: 2 })], false);
invalidateChannelMessageWindow(1);
expect(isChannelLoaded(1)).toBe(false);
expect(isChannelLoaded(2)).toBe(true);
// The old rows stay rendered until the refetched tail lands and merges.
expect(getChannelMessages(1).map((m) => m.id)).toEqual([10]);
});
it("lets the next tail fetch land: setMessages refreshes the window and re-marks it loaded", () => {
setMessages(1, [makeMessageResponse({ id: 10 })], false);
invalidateChannelMessageWindow(1);
// Wire order is newest-first; the refetched tail carries a message
// posted while the channel was not focused.
setMessages(1, [makeMessageResponse({ id: 11 }), makeMessageResponse({ id: 10 })], false);
expect(isChannelLoaded(1)).toBe(true);
expect(getChannelMessages(1).map((m) => m.id)).toEqual([10, 11]);
});
it("keeps a failed optimistic row across the invalidate-then-refetch cycle", () => {
setMessages(1, [makeMessageResponse({ id: 10 })], false);
addOptimisticMessage({
correlationId: "c1",
channelId: 1,
user: TEST_USER,
content: "refused",
replyTo: null,
timestamp: "2026-03-15T10:00:01Z",
});
markSendFailed("c1", "SLOW_MODE");
invalidateChannelMessageWindow(1);
setMessages(1, [makeMessageResponse({ id: 11 }), makeMessageResponse({ id: 10 })], false);
const msgs = getChannelMessages(1);
expect(msgs.map((m) => m.id)).toEqual([10, 11, 0]);
expect(msgs[2]!.status).toBe("failed");
});
it("leaves the detached flag alone (setMessages clears it once the tail lands)", () => {
setAroundMessages(1, [makeMessageResponse({ id: 10 })], true, true);
expect(isWindowDetached(1)).toBe(true);
invalidateChannelMessageWindow(1);
expect(isChannelLoaded(1)).toBe(false);
expect(isWindowDetached(1)).toBe(true);
});
it("is a no-op for a channel that is not loaded", () => {
const before = messagesStore.getState();
invalidateChannelMessageWindow(1);
expect(messagesStore.getState()).toBe(before);
});
});
// 10. First-page history load state
describe("history load state", () => {
it("is idle (null) by default", () => {
@@ -1196,6 +1196,65 @@ describe("renderers", () => {
});
});
describe("formatMessageTimestamp — DST day boundaries", () => {
// These cases only exist in a DST-observing zone, so pin one for the
// duration of this block. Node honors runtime TZ changes on Linux; the
// precondition assertion in each test proves the pin took effect.
const originalTZ = process.env.TZ;
beforeEach(() => {
process.env.TZ = "America/New_York";
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
if (originalTZ === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTZ;
}
});
function assertEasternTime(): void {
// EST is UTC-5 (offset 300), EDT is UTC-4 (offset 240). If these differ
// the runtime is genuinely observing DST transitions.
expect(new Date(2026, 0, 15).getTimezoneOffset()).toBe(300);
expect(new Date(2026, 6, 15).getTimezoneOffset()).toBe(240);
}
it("does not label a two-day-old message 'Yesterday' across spring forward", () => {
assertEasternTime();
// DST starts Sun Mar 8, 2026 (23-hour local day). Now: Mon Mar 9, 11:00 EDT.
vi.setSystemTime(new Date("2026-03-09T15:00:00Z"));
// Sat Mar 7, 23:30 EST — two calendar days before "today".
const result = formatMessageTimestamp("2026-03-08T04:30:00Z");
expect(result).not.toMatch(/^Yesterday/);
expect(result).toMatch(/^03\/07\/2026 /);
// Sun Mar 8, 08:00 EDT really is yesterday.
expect(formatMessageTimestamp("2026-03-08T12:00:00Z")).toMatch(/^Yesterday at /);
});
it("labels the whole previous calendar day 'Yesterday' across fall back", () => {
assertEasternTime();
// DST ends Sun Nov 1, 2026 (25-hour local day). Now: Mon Nov 2, 10:00 EST.
vi.setSystemTime(new Date("2026-11-02T15:00:00Z"));
// Sun Nov 1, 00:30 EDT — inside the previous calendar day.
expect(formatMessageTimestamp("2026-11-01T04:30:00Z")).toMatch(/^Yesterday at /);
// Sat Oct 31, 23:30 EDT — two calendar days back stays absolute.
expect(formatMessageTimestamp("2026-11-01T03:30:00Z")).toMatch(/^10\/31\/2026 /);
});
it("keeps Today/Yesterday/absolute labels on an ordinary day", () => {
assertEasternTime();
// Wed Jun 17, 2026, 11:00 EDT — nowhere near a DST transition.
vi.setSystemTime(new Date("2026-06-17T15:00:00Z"));
expect(formatMessageTimestamp("2026-06-17T14:00:00Z")).toMatch(/^Today at /);
expect(formatMessageTimestamp("2026-06-17T03:30:00Z")).toMatch(/^Yesterday at /);
expect(formatMessageTimestamp("2026-06-15T16:00:00Z")).toMatch(/^06\/15\/2026 /);
});
});
// ---------------------------------------------------------------------------
// getUserRole / roleColorVar
// ---------------------------------------------------------------------------
@@ -324,6 +324,42 @@ describe("enableCamera", () => {
// resurrect it.
expect(voiceStore.getState().localCamera).toBe(false);
});
it("does not announce the camera when disableCamera runs during the publish round-trip", async () => {
const rig = fakeRoom();
const deps = fakeDeps(rig.room);
const track = fakeVideoTrack();
createLocalVideoTrack.mockResolvedValue(track);
let resolvePublish!: () => void;
rig.publishTrack.mockReturnValue(
new Promise<void>((resolve) => {
resolvePublish = resolve;
}),
);
const state = { manualCameraTrack: null as LocalVideoTrack | null };
const enabling = enableCamera(state, deps);
await vi.waitFor(() => {
expect(rig.publishTrack).toHaveBeenCalled();
});
// A concurrent disable runs to completion while publishTrack is still in
// flight — it announces voice_camera(false) and stops the track.
await disableCamera(state, deps);
resolvePublish();
await enabling;
// The superseded enable must not announce voice_camera(true) after the
// disable's voice_camera(false), or every peer renders a camera tile for
// a stopped track while the local store says off.
expect(deps.wsSend).not.toHaveBeenCalledWith({
type: "voice_camera",
payload: { enabled: true },
});
expect(rig.unpublishTrack).toHaveBeenCalledWith(track.mediaStreamTrack);
expect(track.stop).toHaveBeenCalled();
expect(state.manualCameraTrack).toBeNull();
expect(voiceStore.getState().localCamera).toBe(false);
});
});
// ── disableCamera ──────────────────────────────────────────────────────────
@@ -591,6 +627,48 @@ describe("enableScreenshare", () => {
expect(state.manualScreenTracks).toEqual([]);
expect(voiceStore.getState().localScreenshare).toBe(false);
});
it("does not announce the share when disableScreenshare runs during the publish loop", async () => {
const rig = fakeRoom();
const deps = fakeDeps(rig.room);
const video = fakeVideoTrack();
const audio = fakeAudioTrack();
createLocalScreenTracks.mockResolvedValue([video, audio]);
let resolveFirstPublish!: () => void;
rig.publishTrack
.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveFirstPublish = resolve;
}),
)
.mockResolvedValue(undefined);
const state = { manualScreenTracks: [] as LocalTrack[] };
const enabling = enableScreenshare(state, deps);
await vi.waitFor(() => {
expect(rig.publishTrack).toHaveBeenCalledTimes(1);
});
// A concurrent disable runs to completion while the first publish is
// still in flight — it announces voice_screenshare(false), stops both
// tracks and empties state.manualScreenTracks.
await disableScreenshare(state, deps);
resolveFirstPublish();
await enabling;
// The superseded enable must not publish the remaining track — after the
// disable emptied the state, only this attempt can still reach it — and
// must not announce voice_screenshare(true) after the disable's false.
expect(rig.publishTrack).toHaveBeenCalledTimes(1);
expect(deps.wsSend).not.toHaveBeenCalledWith({
type: "voice_screenshare",
payload: { enabled: true },
});
expect(rig.unpublishTrack).toHaveBeenCalledWith(video.mediaStreamTrack);
expect(video.stop).toHaveBeenCalled();
expect(audio.stop).toHaveBeenCalled();
expect(state.manualScreenTracks).toEqual([]);
expect(voiceStore.getState().localScreenshare).toBe(false);
});
});
// ── disableScreenshare ─────────────────────────────────────────────────────
@@ -1758,7 +1758,10 @@ describe("SidebarArea", () => {
cleanup(result);
});
it("onCreate callback shows error toast on API failure", async () => {
// The modal's own catch is what re-enables the submit button and renders
// the inline error, so the callback must reject on failure — a swallowed
// error leaves the modal disabled on "Creating..." with no way to retry.
it("onCreate rejects and shows error toast on API failure", async () => {
const mockShow = vi.fn();
const opts = defaultOpts();
(opts.api.adminCreateChannel as MockedFn).mockRejectedValue(new Error("Create failed"));
@@ -1771,14 +1774,18 @@ describe("SidebarArea", () => {
channelCallArgs.onCreateChannel("General");
const modalCallArgs = (createCreateChannelModal as MockedFn).mock.calls[0]![0];
await modalCallArgs.onCreate({ name: "test", type: "text" });
await expect(modalCallArgs.onCreate({ name: "test", type: "text" })).rejects.toThrow(
"Create failed",
);
expect(mockShow).toHaveBeenCalledWith("Create failed", "error");
// The modal stays open so the user can fix the input and retry.
expect(getMockDestroy(createCreateChannelModal as MockedFn)).not.toHaveBeenCalled();
cleanup(result);
});
it("onCreate shows generic error for non-Error exceptions", async () => {
it("onCreate rejects with the original value and shows generic toast for non-Error exceptions", async () => {
const mockShow = vi.fn();
const opts = defaultOpts();
(opts.api.adminCreateChannel as MockedFn).mockRejectedValue("string error");
@@ -1791,7 +1798,9 @@ describe("SidebarArea", () => {
channelCallArgs.onCreateChannel("General");
const modalCallArgs = (createCreateChannelModal as MockedFn).mock.calls[0]![0];
await modalCallArgs.onCreate({ name: "test", type: "text" });
await expect(modalCallArgs.onCreate({ name: "test", type: "text" })).rejects.toBe(
"string error",
);
expect(mockShow).toHaveBeenCalledWith("Failed to create channel", "error");
@@ -1831,7 +1840,7 @@ describe("SidebarArea", () => {
cleanup(result);
});
it("onSave shows error toast on edit API failure", async () => {
it("onSave rejects and shows error toast on edit API failure", async () => {
const mockShow = vi.fn();
const opts = defaultOpts();
(opts.api.adminUpdateChannel as MockedFn).mockRejectedValue(new Error("Update failed"));
@@ -1844,14 +1853,15 @@ describe("SidebarArea", () => {
channelCallArgs.onEditChannel({ id: 1, name: "general", type: "text" });
const modalCallArgs = (createEditChannelModal as MockedFn).mock.calls[0]![0];
await modalCallArgs.onSave({ name: "updated" });
await expect(modalCallArgs.onSave({ name: "updated" })).rejects.toThrow("Update failed");
expect(mockShow).toHaveBeenCalledWith("Update failed", "error");
expect(getMockDestroy(createEditChannelModal as MockedFn)).not.toHaveBeenCalled();
cleanup(result);
});
it("onSave shows generic error for non-Error exceptions on edit", async () => {
it("onSave rejects with the original value and shows generic toast for non-Error exceptions on edit", async () => {
const mockShow = vi.fn();
const opts = defaultOpts();
(opts.api.adminUpdateChannel as MockedFn).mockRejectedValue(42);
@@ -1864,7 +1874,7 @@ describe("SidebarArea", () => {
channelCallArgs.onEditChannel({ id: 1, name: "general", type: "text" });
const modalCallArgs = (createEditChannelModal as MockedFn).mock.calls[0]![0];
await modalCallArgs.onSave({ name: "updated" });
await expect(modalCallArgs.onSave({ name: "updated" })).rejects.toBe(42);
expect(mockShow).toHaveBeenCalledWith("Failed to update channel", "error");
@@ -1903,7 +1913,7 @@ describe("SidebarArea", () => {
cleanup(result);
});
it("onConfirm shows error toast on delete API failure", async () => {
it("onConfirm rejects and shows error toast on delete API failure", async () => {
const mockShow = vi.fn();
const opts = defaultOpts();
(opts.api.adminDeleteChannel as MockedFn).mockRejectedValue(new Error("Delete failed"));
@@ -1916,14 +1926,15 @@ describe("SidebarArea", () => {
channelCallArgs.onDeleteChannel({ id: 1, name: "general" });
const modalCallArgs = (createDeleteChannelModal as MockedFn).mock.calls[0]![0];
await modalCallArgs.onConfirm();
await expect(modalCallArgs.onConfirm()).rejects.toThrow("Delete failed");
expect(mockShow).toHaveBeenCalledWith("Delete failed", "error");
expect(getMockDestroy(createDeleteChannelModal as MockedFn)).not.toHaveBeenCalled();
cleanup(result);
});
it("onConfirm shows generic error for non-Error exceptions on delete", async () => {
it("onConfirm rejects with the original value and shows generic toast for non-Error exceptions on delete", async () => {
const mockShow = vi.fn();
const opts = defaultOpts();
(opts.api.adminDeleteChannel as MockedFn).mockRejectedValue(42);
@@ -1936,7 +1947,7 @@ describe("SidebarArea", () => {
channelCallArgs.onDeleteChannel({ id: 1, name: "general" });
const modalCallArgs = (createDeleteChannelModal as MockedFn).mock.calls[0]![0];
await modalCallArgs.onConfirm();
await expect(modalCallArgs.onConfirm()).rejects.toBe(42);
expect(mockShow).toHaveBeenCalledWith("Failed to delete channel", "error");