diff --git a/Client/tauri-client/src/components/DeleteChannelModal.ts b/Client/tauri-client/src/components/DeleteChannelModal.ts index 297d0ba6..1c78b292 100644 --- a/Client/tauri-client/src/components/DeleteChannelModal.ts +++ b/Client/tauri-client/src/components/DeleteChannelModal.ts @@ -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 }, diff --git a/Client/tauri-client/src/components/DmSidebar.ts b/Client/tauri-client/src/components/DmSidebar.ts index 79f0bafc..abcc5903 100644 --- a/Client/tauri-client/src/components/DmSidebar.ts +++ b/Client/tauri-client/src/components/DmSidebar.ts @@ -83,7 +83,10 @@ const STATUS_COLORS: Record = { * 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); }); } diff --git a/Client/tauri-client/src/components/MessageInput.ts b/Client/tauri-client/src/components/MessageInput.ts index 95b9b321..8536eaea 100644 --- a/Client/tauri-client/src/components/MessageInput.ts +++ b/Client/tauri-client/src/components/MessageInput.ts @@ -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) { diff --git a/Client/tauri-client/src/components/MessageList.ts b/Client/tauri-client/src/components/MessageList.ts index 5c085e94..e73ca155 100644 --- a/Client/tauri-client/src/components/MessageList.ts +++ b/Client/tauri-client/src/components/MessageList.ts @@ -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; diff --git a/Client/tauri-client/src/components/channel-sidebar/drag-reorder.ts b/Client/tauri-client/src/components/channel-sidebar/drag-reorder.ts index 9f757889..473e180f 100644 --- a/Client/tauri-client/src/components/channel-sidebar/drag-reorder.ts +++ b/Client/tauri-client/src/components/channel-sidebar/drag-reorder.ts @@ -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(`[data-drag-channel-id="${drag.channelId}"]`); + const container = row?.closest(".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("[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]; diff --git a/Client/tauri-client/src/components/message-list/embeds.ts b/Client/tauri-client/src/components/message-list/embeds.ts index ca7f50fe..324a1a4c 100644 --- a/Client/tauri-client/src/components/message-list/embeds.ts +++ b/Client/tauri-client/src/components/message-list/embeds.ts @@ -165,9 +165,12 @@ function fetchOgMeta(url: string): Promise { log.debug("fetchOgMeta START", url.slice(0, 100)); const promise = (async (): Promise => { + // 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 { // 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 { } ogCache.set(url, EMPTY_OG); return EMPTY_OG; + } finally { + clearTimeout(timer); } })(); diff --git a/Client/tauri-client/src/components/message-list/formatting.ts b/Client/tauri-client/src/components/message-list/formatting.ts index 8ab750ee..5463e2db 100644 --- a/Client/tauri-client/src/components/message-list/formatting.ts +++ b/Client/tauri-client/src/components/message-list/formatting.ts @@ -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}`; diff --git a/Client/tauri-client/src/lib/deviceManager.ts b/Client/tauri-client/src/lib/deviceManager.ts index 88b6ee36..53472393 100644 --- a/Client/tauri-client/src/lib/deviceManager.ts +++ b/Client/tauri-client/src/lib/deviceManager.ts @@ -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 { + // 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()) { diff --git a/Client/tauri-client/src/lib/screenShare.ts b/Client/tauri-client/src/lib/screenShare.ts index 516b0cb8..1de7314b 100644 --- a/Client/tauri-client/src/lib/screenShare.ts +++ b/Client/tauri-client/src/lib/screenShare.ts @@ -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); diff --git a/Client/tauri-client/src/pages/main-page/ChannelController.ts b/Client/tauri-client/src/pages/main-page/ChannelController.ts index 03442664..df7c90c9 100644 --- a/Client/tauri-client/src/pages/main-page/ChannelController.ts +++ b/Client/tauri-client/src/pages/main-page/ChannelController.ts @@ -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 }); diff --git a/Client/tauri-client/src/pages/main-page/SidebarArea.ts b/Client/tauri-client/src/pages/main-page/SidebarArea.ts index 74865cfe..04352a63 100644 --- a/Client/tauri-client/src/pages/main-page/SidebarArea.ts +++ b/Client/tauri-client/src/pages/main-page/SidebarArea.ts @@ -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: () => { diff --git a/Client/tauri-client/src/stores/messages.store.ts b/Client/tauri-client/src/stores/messages.store.ts index 12c4702d..ba928d03 100644 --- a/Client/tauri-client/src/stores/messages.store.ts +++ b/Client/tauri-client/src/stores/messages.store.ts @@ -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); diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index 0a024017..facbe45a 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -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; diff --git a/Client/tauri-client/tests/unit/channel-controller.test.ts b/Client/tauri-client/tests/unit/channel-controller.test.ts index ca20d683..aef22054 100644 --- a/Client/tauri-client/tests/unit/channel-controller.test.ts +++ b/Client/tauri-client/tests/unit/channel-controller.test.ts @@ -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); diff --git a/Client/tauri-client/tests/unit/delete-channel-modal.test.ts b/Client/tauri-client/tests/unit/delete-channel-modal.test.ts index 59af2296..298ae71f 100644 --- a/Client/tauri-client/tests/unit/delete-channel-modal.test.ts +++ b/Client/tauri-client/tests/unit/delete-channel-modal.test.ts @@ -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( diff --git a/Client/tauri-client/tests/unit/device-manager.test.ts b/Client/tauri-client/tests/unit/device-manager.test.ts index 51e470b2..8c120180 100644 --- a/Client/tauri-client/tests/unit/device-manager.test.ts +++ b/Client/tauri-client/tests/unit/device-manager.test.ts @@ -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"); diff --git a/Client/tauri-client/tests/unit/dm-sidebar.test.ts b/Client/tauri-client/tests/unit/dm-sidebar.test.ts index 2b2ba64d..ba79c272 100644 --- a/Client/tauri-client/tests/unit/dm-sidebar.test.ts +++ b/Client/tauri-client/tests/unit/dm-sidebar.test.ts @@ -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 })], diff --git a/Client/tauri-client/tests/unit/drag-reorder.test.ts b/Client/tauri-client/tests/unit/drag-reorder.test.ts index 8042924c..cf4fda63 100644 --- a/Client/tauri-client/tests/unit/drag-reorder.test.ts +++ b/Client/tauri-client/tests/unit/drag-reorder.test.ts @@ -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 } { + const container = document.createElement("div"); + container.className = "category-channels-container"; + document.body.appendChild(container); + + const items = new Map(); + 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 diff --git a/Client/tauri-client/tests/unit/embeds.test.ts b/Client/tauri-client/tests/unit/embeds.test.ts index cf872565..dc751056 100644 --- a/Client/tauri-client/tests/unit/embeds.test.ts +++ b/Client/tauri-client/tests/unit/embeds.test.ts @@ -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((_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("Timely")); + // 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 = ""; diff --git a/Client/tauri-client/tests/unit/message-input.test.ts b/Client/tauri-client/tests/unit/message-input.test.ts index bbbc9a3c..9362f650 100644 --- a/Client/tauri-client/tests/unit/message-input.test.ts +++ b/Client/tauri-client/tests/unit/message-input.test.ts @@ -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", () => { diff --git a/Client/tauri-client/tests/unit/message-list.test.ts b/Client/tauri-client/tests/unit/message-list.test.ts index 410f6db8..e96a5623 100644 --- a/Client/tauri-client/tests/unit/message-list.test.ts +++ b/Client/tauri-client/tests/unit/message-list.test.ts @@ -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 })]); diff --git a/Client/tauri-client/tests/unit/messages-store-detached.test.ts b/Client/tauri-client/tests/unit/messages-store-detached.test.ts index 4b912ac7..a32ddfe3 100644 --- a/Client/tauri-client/tests/unit/messages-store-detached.test.ts +++ b/Client/tauri-client/tests/unit/messages-store-detached.test.ts @@ -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", () => { diff --git a/Client/tauri-client/tests/unit/messages.store.test.ts b/Client/tauri-client/tests/unit/messages.store.test.ts index 1180ef83..aa59f5ee 100644 --- a/Client/tauri-client/tests/unit/messages.store.test.ts +++ b/Client/tauri-client/tests/unit/messages.store.test.ts @@ -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", () => { diff --git a/Client/tauri-client/tests/unit/renderers.test.ts b/Client/tauri-client/tests/unit/renderers.test.ts index d728ca1b..951b4585 100644 --- a/Client/tauri-client/tests/unit/renderers.test.ts +++ b/Client/tauri-client/tests/unit/renderers.test.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/Client/tauri-client/tests/unit/screen-share-tracks.test.ts b/Client/tauri-client/tests/unit/screen-share-tracks.test.ts index c9dd848c..db0a067b 100644 --- a/Client/tauri-client/tests/unit/screen-share-tracks.test.ts +++ b/Client/tauri-client/tests/unit/screen-share-tracks.test.ts @@ -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((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((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 ───────────────────────────────────────────────────── diff --git a/Client/tauri-client/tests/unit/sidebar-area.test.ts b/Client/tauri-client/tests/unit/sidebar-area.test.ts index 282e1b67..2cc6b38f 100644 --- a/Client/tauri-client/tests/unit/sidebar-area.test.ts +++ b/Client/tauri-client/tests/unit/sidebar-area.test.ts @@ -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");