diff --git a/Client/tauri-client/src/lib/dispatcher.ts b/Client/tauri-client/src/lib/dispatcher.ts index b39d9bd0..c4558451 100644 --- a/Client/tauri-client/src/lib/dispatcher.ts +++ b/Client/tauri-client/src/lib/dispatcher.ts @@ -31,6 +31,7 @@ import { invalidateLoadedMessageWindows, setChannelLoading, setChannelLoadError, + isWindowDetached, } from "@stores/messages.store"; import { setMembers, @@ -65,7 +66,12 @@ import { updateDmParticipant, } from "@stores/dm.store"; import type { DmChannel } from "@stores/dm.store"; -import { setBlockedByMe, setUserBlockedByThem, clearBlockedByThem } from "@stores/blocks.store"; +import { + blocksStore, + setBlockedByMe, + setUserBlockedByThem, + clearBlockedByThem, +} from "@stores/blocks.store"; import { setCustomEmoji } from "@stores/emoji.store"; import type { DmChannelPayload } from "./types"; import { isTextLikeChannel } from "./types"; @@ -267,6 +273,16 @@ export function wireDispatcher( unsubs.push( ws.on(S.READY, (payload) => { + // OC-0201: snapshot the current voice channel's peer roster BEFORE the + // wholesale replace below, so the reconciliation branch further down + // can tell who left while the socket was down. Must run before + // setVoiceStates() overwrites voiceUsers with the fresh payload. + const prevVoiceChannelId = voiceStore.getState().currentChannelId; + const prevVoicePeerIds = + prevVoiceChannelId !== null + ? new Set(voiceStore.getState().voiceUsers.get(prevVoiceChannelId)?.keys() ?? []) + : new Set(); + setChannels(payload.channels); setRoles(payload.roles ?? []); setMembers(payload.members); @@ -303,6 +319,31 @@ export function wireDispatcher( selfVoiceState.server_muted === true, selfVoiceState.server_deafened === true, ); + + // OC-0201: same gap, for E2EE. A full resync never replays the + // voice_leave for anyone who departed our voice channel during the + // outage — handleParticipantLeft (the only path that prunes a + // departed peer's key, rotates for membership forward secrecy, and + // re-runs the lowest-uid key-holder election) is otherwise only ever + // driven by a live voice_leave frame. Without this, a departed peer + // keeps a working room key indefinitely, and a client the server + // just elected key holder on reconnect (Server/ws hub.go + // registerNow -> updateKeyHolder) never self-elects. Only reconcile + // when the resync's self voice state is for the SAME channel the + // snapshot above was taken from — a channel change is out of scope + // here and comparing rosters across two different channels would + // misfire. + if (prevVoiceChannelId === selfVoiceState.channel_id) { + const currentVoicePeerIds = new Set( + payload.voice_states + .filter((vs) => vs.channel_id === selfVoiceState.channel_id) + .map((vs) => vs.user_id), + ); + for (const uid of prevVoicePeerIds) { + if (uid === currentUserId || currentVoicePeerIds.has(uid)) continue; + void livekitSession().then(({ handleParticipantLeft }) => handleParticipantLeft(uid)); + } + } } // F3: publish our long-term identity public key so peers can pin+verify @@ -463,9 +504,17 @@ export function wireDispatcher( // clear it and re-fetch our own outgoing blocks authoritatively. clearBlockedByThem(); if (api !== undefined) { + // OC-0218: snapshot the revision blocksStore was at right before + // issuing this fetch. If the user blocks/unblocks someone (via + // SidebarMemberSection's onToggleBlock -> setUserBlockedByMe) while + // this GET is in flight, that per-user delta bumps the revision; + // setBlockedByMe then sees the mismatch and skips applying this + // reply instead of clobbering the fresher local truth with a stale + // full-set snapshot. + const blockedByMeRevAtFetch = blocksStore.getState().blockedByMeRev ?? 0; api .listBlocks() - .then((r) => setBlockedByMe(r.blocked_user_ids)) + .then((r) => setBlockedByMe(r.blocked_user_ids, blockedByMeRevAtFetch)) .catch((err) => log.warn("Failed to load block list", { error: String(err) })); } @@ -566,30 +615,46 @@ export function wireDispatcher( const currentUserId = authStore.getState().user?.id ?? null; const isOwnMessage = currentUserId !== null && payload.user.id === currentUserId; - // Increment channel-level unread for non-active, non-own-message channels. - // Replayed frames increment unread counts like live ones — the burst - // is exactly the messages missed while away (a full-ready resume sends - // no burst at all; ready's unread_count values are authoritative - // there). DM channel IDs are not in channelsStore (they use dmStore), - // so incrementUnread is a no-op for DMs, but the own-message guard is - // applied here for defence-in-depth. + // Increment channel-level unread for non-active, non-own-message + // channels — OR for the active channel when its loaded window is + // detached from the live tail (OC-0204). "Active" normally means "the + // user is watching the live tail", which is why it is otherwise + // excluded here, but a jump to an old permalink/reply/search hit can + // leave the active channel showing a detached around-window + // (messages.store's detachedChannels) — addMessage already refuses to + // append a live broadcast onto that window, so without this a message + // (an @mention included) that arrives while the user reads + // back-history leaves no row AND no badge, with nothing to tell them + // it ever arrived. Replayed frames increment unread counts like live + // ones — the burst is exactly the messages missed while away (a + // full-ready resume sends no burst at all; ready's unread_count values + // are authoritative there). DM channel IDs are not in channelsStore + // (they use dmStore), so incrementUnread is a no-op for DMs, but the + // own-message guard is applied here for defence-in-depth. const isMention = highlightsCurrentUser(payload.content, { mentions: payload.mentions, mentionsEveryone: payload.mentions_everyone, }); + const isDetached = isWindowDetached(payload.channel_id); - if (payload.channel_id !== activeId && !isOwnMessage) { - incrementUnread(payload.channel_id); + if ((payload.channel_id !== activeId || isDetached) && !isOwnMessage) { + // incrementUnread/incrementMention skip the active channel by + // default — evenIfActive (isDetached here) is a no-op for a + // genuinely non-active channel, since their internal guard only + // fires when channelId IS the active one. + incrementUnread(payload.channel_id, isDetached); // A mention is an unread too — the mention badge just outranks it. if (isMention) { - incrementMention(payload.channel_id); + incrementMention(payload.channel_id, isDetached); } } // Update DM store last message if this message belongs to a DM channel. - // Skip unread increment for own messages and the currently focused DM. + // Skip unread increment for own messages and the currently focused DM + // — unless that DM's window is detached from the live tail (OC-0204), + // the same exception the channel-level increment above makes. if (isDm) { - const isDmActive = payload.channel_id === activeId; + const isDmActive = payload.channel_id === activeId && !isDetached; if (isOwnMessage || isDmActive) { // Update last message preview but don't increment unread count. updateDmLastMessagePreview( @@ -1111,10 +1176,22 @@ export function wireDispatcher( // that voice_leave's channel no longer matches the already-updated // currentChannelId, so it must not tear down the NEW channel's // optimistic state either), so voiceStatus is still "joining" when - // this error lands and the guard clears it here instead. An - // already-established session is never in "joining", so this never - // touches a live voice call. + // this error lands and the guard clears it here instead. A plain + // store rollback is safe for an already-established session (never + // "joining") and for a first-time join refusal (no prior session to + // tear down) — but a channel *switch* refused at precheck (RATE_LIMITED, + // FORBIDDEN, NOT_FOUND, archived-channel BAD_REQUEST) never reaches + // voiceJoinLeaveCurrent server-side, so no voice_leave is broadcast and + // the OLD channel's LiveKit room is still connected (mic still + // published) while the store already points at the NEW channel + // (OC-0193). isVoiceConnected() distinguishes that live-session case + // from the first-time-join refusal; tearing it down here also sends + // voice_leave so the server/SFU state for the OLD channel matches the + // now-cleared store. if (voiceStore.getState().voiceStatus === "joining") { + void livekitSession().then(({ isVoiceConnected, leaveVoice }) => { + if (isVoiceConnected()) leaveVoice(true); + }); leaveVoiceChannel(); } // Voice capacity refusals. The server owns the limits (voice_max_users / diff --git a/Client/tauri-client/src/lib/notifications.ts b/Client/tauri-client/src/lib/notifications.ts index fdb082c8..7fdd001d 100644 --- a/Client/tauri-client/src/lib/notifications.ts +++ b/Client/tauri-client/src/lib/notifications.ts @@ -9,6 +9,7 @@ import { loadUserStatus } from "./userStatus"; import { authStore } from "@stores/auth.store"; import { channelsStore } from "@stores/channels.store"; import { dmStore, dmDisplayName } from "@stores/dm.store"; +import { isWindowDetached } from "@stores/messages.store"; import type { ChatMessagePayload } from "./types"; import { mentionsCurrentUser } from "./mentions"; import { createLogger } from "./logger"; @@ -53,9 +54,22 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void { // Don't notify for own messages if (currentUser !== null && payload.user.id === currentUser.id) return; - // Don't notify if the window is focused AND the message is in the active channel + // Don't notify if the window is focused AND the message is in the active + // channel — UNLESS that channel is showing a detached around-window + // (OC-0204). "Active" only means this is the channel on screen; a jump to + // an old permalink/reply/search hit can leave it detached from the live + // tail (messages.store's detachedChannels), in which case the user is + // reading back-history and cannot see the new message at all — addMessage + // silently refuses to append it. Without this check that combination + // suppresses the one thing that would have told the user anything arrived. const activeChannelId = channelsStore.getState().activeChannelId; - if (isWindowFocused() && payload.channel_id === activeChannelId) return; + if ( + isWindowFocused() && + payload.channel_id === activeChannelId && + !isWindowDetached(payload.channel_id) + ) { + return; + } const mentionInfo = { mentions: payload.mentions, diff --git a/Client/tauri-client/src/pages/main-page/ChannelController.ts b/Client/tauri-client/src/pages/main-page/ChannelController.ts index 28284b6d..4413d999 100644 --- a/Client/tauri-client/src/pages/main-page/ChannelController.ts +++ b/Client/tauri-client/src/pages/main-page/ChannelController.ts @@ -223,6 +223,13 @@ export function createChannelController(opts: ChannelControllerOptions): Channel // onJumpToPresent — reattach clears "loaded" so the tail is refetched. if (isWindowDetached(channelId)) { reattachToPresent(channelId); + // OC-0204: while detached, this (already-active) channel could have + // picked up an unread/mention badge for messages that arrived below + // the gap (dispatcher.ts's evenIfActive path) — nothing else clears + // it, since incrementUnread's usual "active channel" skip is exactly + // what a detached window opts out of. Jumping to present is reading + // it, so mark it read the same way leaving a channel does. + markChannelRead(channelId); if (channelAbort !== null) { void msgCtrl.loadMessages(channelId, channelAbort.signal); } @@ -288,6 +295,11 @@ export function createChannelController(opts: ChannelControllerOptions): Channel // Dropping the detached flag also clears "loaded", so loadMessages // refetches the live tail instead of short-circuiting. reattachToPresent(channelId); + // OC-0204: see performSend's identical call above — a detached + // active channel's badge (from dispatcher.ts's evenIfActive path) + // must be cleared here too, or it lingers after the user has jumped + // back to present and is looking straight at the live tail. + markChannelRead(channelId); if (channelAbort !== null) { void msgCtrl.loadMessages(channelId, channelAbort.signal); } diff --git a/Client/tauri-client/src/stores/blocks.store.ts b/Client/tauri-client/src/stores/blocks.store.ts index fec91125..ac12a302 100644 --- a/Client/tauri-client/src/stores/blocks.store.ts +++ b/Client/tauri-client/src/stores/blocks.store.ts @@ -21,18 +21,43 @@ export const BLOCKED_BY_THEM_REASON = "You can't message this user right now."; export interface BlocksState { readonly blockedByMe: ReadonlySet; readonly blockedByThem: ReadonlySet; + /** + * Bumped by every accepted setUserBlockedByMe delta (OC-0218). Optional — + * absent/undefined reads as revision 0 — so state literals that predate + * this field (tests, a full setState replace) do not need updating. + * + * Lets a ready-time GET /blocks snapshot the revision it observed just + * before issuing the request and pass it back to setBlockedByMe: if a + * setUserBlockedByMe delta landed (bumping the revision) while that fetch + * was in flight, the fetch's reply is answering a question that is no + * longer current and must not clobber the fresher local truth. + */ + readonly blockedByMeRev?: number; } const INITIAL: BlocksState = { blockedByMe: new Set(), blockedByThem: new Set(), + blockedByMeRev: 0, }; export const blocksStore = createStore(INITIAL); -/** Replace the blocked-by-me set (from GET /blocks). */ -export function setBlockedByMe(userIds: readonly number[]): void { - blocksStore.setState((prev) => ({ ...prev, blockedByMe: new Set(userIds) })); +/** + * Replace the blocked-by-me set (from GET /blocks). + * + * `rev`, when given, must match the store's current blockedByMeRev — the + * revision the caller observed right before starting the fetch this reply + * answers (OC-0218). A mismatch means a fresher setUserBlockedByMe delta + * landed after the fetch was issued, so this reply is stale and is skipped + * rather than reverting that delta. Omit `rev` to always apply (existing + * direct callers, tests). + */ +export function setBlockedByMe(userIds: readonly number[], rev?: number): void { + blocksStore.setState((prev) => { + if (rev !== undefined && rev !== (prev.blockedByMeRev ?? 0)) return prev; + return { ...prev, blockedByMe: new Set(userIds) }; + }); } /** Mark (or unmark) a user as blocked by the local user (after PUT/DELETE /blocks). */ @@ -42,7 +67,7 @@ export function setUserBlockedByMe(userId: number, blocked: boolean): void { const next = new Set(prev.blockedByMe); if (blocked) next.add(userId); else next.delete(userId); - return { ...prev, blockedByMe: next }; + return { ...prev, blockedByMe: next, blockedByMeRev: (prev.blockedByMeRev ?? 0) + 1 }; }); } diff --git a/Client/tauri-client/src/stores/channels.store.ts b/Client/tauri-client/src/stores/channels.store.ts index f8b9b536..d9001da7 100644 --- a/Client/tauri-client/src/stores/channels.store.ts +++ b/Client/tauri-client/src/stores/channels.store.ts @@ -333,10 +333,19 @@ export function getChannelsByCategory(): Map { }); } -/** Increment unread count for a channel, unless it is the active channel. */ -export function incrementUnread(channelId: number): void { +/** + * Increment unread count for a channel, unless it is the active channel. + * + * `evenIfActive` (OC-0204) opts out of that skip: "active" normally means + * "the user is watching the live tail" — the reason a badge would be + * redundant there — but the active channel's loaded window can be detached + * from the live tail (a jump to an old permalink/reply/search hit), in which + * case the message is genuinely unseen and must still count. Callers own + * deciding when that applies; this still always skips an unknown channel id. + */ +export function incrementUnread(channelId: number, evenIfActive = false): void { channelsStore.setState((prev) => { - if (prev.activeChannelId === channelId) { + if (prev.activeChannelId === channelId && !evenIfActive) { return prev; } const existing = prev.channels.get(channelId); @@ -357,10 +366,12 @@ export function incrementUnread(channelId: number): void { * Increment the mention count for a channel, unless it is the active channel. * Callers also call incrementUnread — a mention is always an unread too, and * the two counters are kept independent so the badge can outrank. + * + * `evenIfActive` mirrors incrementUnread's escape hatch — see its doc for why. */ -export function incrementMention(channelId: number): void { +export function incrementMention(channelId: number, evenIfActive = false): void { channelsStore.setState((prev) => { - if (prev.activeChannelId === channelId) { + if (prev.activeChannelId === channelId && !evenIfActive) { return prev; } const existing = prev.channels.get(channelId); diff --git a/Client/tauri-client/tests/unit/blocks-store.test.ts b/Client/tauri-client/tests/unit/blocks-store.test.ts index 4fd32e66..f1ee9977 100644 --- a/Client/tauri-client/tests/unit/blocks-store.test.ts +++ b/Client/tauri-client/tests/unit/blocks-store.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { blocksStore, setBlockedByMe, + setUserBlockedByMe, setUserBlockedByThem, clearBlockedByThem, dmComposerBlockReason, @@ -80,4 +81,46 @@ describe("blocksStore", () => { expect(blocksStore.getState()).toBe(before); }); }); + + // OC-0218: a ready-time GET /blocks and a user-initiated block/unblock can + // race. The GET is issued before the user's own action but its reply can + // land after — a stale full-set reply must not clobber a fresher per-user + // delta. + describe("setBlockedByMe staleness guard (OC-0218)", () => { + it("applies when no revision is given (direct/legacy caller)", () => { + setBlockedByMe([5]); + expect(dmComposerBlockReason(blocksStore.getState(), 5)).toBe(BLOCKED_BY_ME_REASON); + }); + + it("a reply carrying the revision observed before a fresher local delta must not re-add it", () => { + // Local user 42 starts blocked (seeded, as if from a previous ready). + setBlockedByMe([42]); + // A reconnect fires a fresh GET /blocks — the caller snapshots the + // revision it observed right before issuing the request. Real callers + // (dispatcher.ts) default the optional field to 0, exactly like + // setBlockedByMe's own internal comparison does. + const revBeforeFetch = blocksStore.getState().blockedByMeRev ?? 0; + + // While that GET is in flight, the user clicks "Unblock" — this is the + // fresher, authoritative local truth. + setUserBlockedByMe(42, false); + expect(dmComposerBlockReason(blocksStore.getState(), 42)).toBeNull(); + + // The GET's reply lands late, still carrying the stale pre-unblock + // snapshot and the revision observed before the unblock. It must be + // ignored, not re-add 42. + setBlockedByMe([42], revBeforeFetch); + + expect(dmComposerBlockReason(blocksStore.getState(), 42)).toBeNull(); + }); + + it("a reply carrying the current revision still applies", () => { + setBlockedByMe([1]); + const rev = blocksStore.getState().blockedByMeRev; + // No local delta happened since — the snapshot is still current. + setBlockedByMe([1, 2], rev); + expect(dmComposerBlockReason(blocksStore.getState(), 1)).toBe(BLOCKED_BY_ME_REASON); + expect(dmComposerBlockReason(blocksStore.getState(), 2)).toBe(BLOCKED_BY_ME_REASON); + }); + }); }); diff --git a/Client/tauri-client/tests/unit/channel-controller.test.ts b/Client/tauri-client/tests/unit/channel-controller.test.ts index 257634f7..d6734b6b 100644 --- a/Client/tauri-client/tests/unit/channel-controller.test.ts +++ b/Client/tauri-client/tests/unit/channel-controller.test.ts @@ -518,6 +518,7 @@ describe("createChannelController", () => { // After a jump into history the composer stays enabled; sending must // land the optimistic row in the live tail, not mid-history. mockIsWindowDetached.mockReturnValueOnce(true); + mockMarkChannelRead.mockClear(); const opts = makeOpts(); const ctrl = createChannelController(opts); ctrl.mountChannel(42, "general"); @@ -530,6 +531,10 @@ describe("createChannelController", () => { expect(opts.msgCtrl.loadMessages).toHaveBeenCalledWith(42, expect.any(AbortSignal)); // The send itself still goes out. expect(opts.ws.send).toHaveBeenCalledWith(expect.objectContaining({ type: "chat_send" })); + // OC-0204: a detached-but-active channel can carry an unread/mention + // badge dispatcher.ts left behind for messages missed below the gap — + // jumping to present (which sending here implies) must clear it. + expect(mockMarkChannelRead).toHaveBeenCalledWith(42); }); it("onSend while disconnected records a failed optimistic row (no silent drop)", () => { @@ -582,6 +587,7 @@ describe("createChannelController", () => { }); it("onJumpToPresent reattaches the channel and refetches the live tail", () => { + mockMarkChannelRead.mockClear(); const opts = makeOpts(); const ctrl = createChannelController(opts); ctrl.mountChannel(42, "general"); @@ -596,6 +602,10 @@ describe("createChannelController", () => { expect(mockReattachToPresent.mock.invocationCallOrder[0]).toBeLessThan( (opts.msgCtrl.loadMessages as ReturnType).mock.invocationCallOrder[0]!, ); + // OC-0204: clicking "Jump to Present" is reading whatever arrived + // below the gap — clear the badge it may have left, the same way + // leaving a channel does. + expect(mockMarkChannelRead).toHaveBeenCalledWith(42); }); it("onRetry re-sends the failed draft with a fresh correlation id", () => { diff --git a/Client/tauri-client/tests/unit/dispatcher.test.ts b/Client/tauri-client/tests/unit/dispatcher.test.ts index 354e31d9..e9e38df5 100644 --- a/Client/tauri-client/tests/unit/dispatcher.test.ts +++ b/Client/tauri-client/tests/unit/dispatcher.test.ts @@ -20,7 +20,7 @@ import { import { membersStore } from "../../src/stores/members.store"; import { voiceStore } from "../../src/stores/voice.store"; import { dmStore } from "../../src/stores/dm.store"; -import { blocksStore } from "../../src/stores/blocks.store"; +import { blocksStore, setUserBlockedByMe } from "../../src/stores/blocks.store"; import { emojiStore, setCustomEmoji, @@ -85,6 +85,8 @@ import { leaveVoice as mockLeaveVoice, disableCamera as mockDisableCamera, disableScreenshare as mockDisableScreenshare, + isVoiceConnected as mockIsVoiceConnected, + handleParticipantLeft as mockHandleParticipantLeft, } from "@lib/livekitSession"; import { rollbackPendingVideo as mockRollbackPendingVideo } from "@lib/screenShare"; @@ -376,6 +378,53 @@ describe("WS Dispatcher", () => { expect(ch?.unreadCount).toBe(1); }); + // OC-0204: "active channel" normally means "the user is watching the live + // tail", so skipping the unread bump there is correct — until a jump to an + // old permalink/reply/search hit leaves the SAME active channel showing a + // detached around-window (messages.store's detachedChannels). addMessage + // already refuses to append a live broadcast onto a detached window, so + // without also bumping the badge here, a message arriving while the user + // reads back-history leaves no row AND no badge — nothing records it ever + // arrived. + it("wires chat_message to increment unread for the active channel when its window is detached", () => { + channelsStore.setState((prev) => { + const ch = new Map(prev.channels); + ch.set(5, { + id: 5, + name: "general", + type: "text" as const, + category: null, + position: 0, + unreadCount: 0, + mentionCount: 0, + lastMessageId: null, + canSend: true, + topic: "", + slowMode: 0, + nsfw: false, + voiceMaxUsers: 0, + voiceMaxVideo: 0, + }); + return { ...prev, channels: ch, activeChannelId: 5 }; // channel 5 IS active... + }); + // ...but its loaded window is detached from the live tail (viewing + // back-history via a jump). + messagesStore.setState((prev) => ({ ...prev, detachedChannels: new Set([5]) })); + + mock.dispatch("chat_message", { + id: 200, + channel_id: 5, + user: { id: 2, username: "bob", avatar: null }, + content: "ping", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + }); + + const ch = channelsStore.getState().channels.get(5); + expect(ch?.unreadCount).toBe(1); + }); + describe("chat_message notifications during a reconnect replay burst", () => { // The server writes auth_ok before the replay burst, so by the time // replayed chat_message frames arrive the client is already "connected" @@ -2980,6 +3029,44 @@ describe("WS Dispatcher", () => { expect([...blocksStore.getState().blockedByMe]).toEqual([11, 22]); }); + // OC-0218: the ready-time GET /blocks and a user-initiated block/unblock + // (SidebarMemberSection's onToggleBlock -> setUserBlockedByMe, after its + // own await api.blockUser/unblockUser) can race. The GET is issued first + // but its reply can land after the user's own fresher action — applying it + // unconditionally reverts what the user just did. + it("does not let a slow-to-resolve ready-time listBlocks revert a fresher local unblock", async () => { + cleanup(); // tear down the no-api dispatcher wired in beforeEach + let resolveListBlocks!: (v: { blocked_user_ids: number[] }) => void; + const listBlocks = vi.fn( + () => + new Promise<{ blocked_user_ids: number[] }>((resolve) => { + resolveListBlocks = resolve; + }), + ); + cleanup = wireDispatcher(mock.ws, { listBlocks }); + + // Local user 42 is blocked from a previous session. + blocksStore.setState(() => ({ blockedByMe: new Set([42]), blockedByThem: new Set() })); + + // Reconnect: ready fires the GET, which does not resolve yet. + mock.dispatch("ready", { channels: [], members: [], voice_states: [], roles: [] }); + expect(listBlocks).toHaveBeenCalled(); + + // While it's in flight, the user clicks "Unblock" on 42 — the same + // sequence SidebarMemberSection's onToggleBlock performs once its own + // await api.unblockUser resolves. + setUserBlockedByMe(42, false); + expect([...blocksStore.getState().blockedByMe]).toEqual([]); + + // The GET finally resolves with the stale pre-unblock snapshot. + resolveListBlocks({ blocked_user_ids: [42] }); + await Promise.resolve(); + await Promise.resolve(); + + // The user's unblock must win — 42 must not be silently re-added. + expect([...blocksStore.getState().blockedByMe]).toEqual([]); + }); + it("wires a local transport send failure to mark the pending row failed", () => { uiStore.setState((prev) => ({ ...prev, transientError: null })); @@ -3138,6 +3225,34 @@ describe("WS Dispatcher", () => { expect(dm?.unreadCount).toBe(0); }); + // OC-0204's DM-path sibling: the same "active means watching the live + // tail" assumption governs isDmActive here, and breaks the same way when + // the active DM's loaded window is detached (a jump to an old permalink/ + // search hit inside the conversation). + it("updates DM last message WITH unread when the active DM's window is detached", () => { + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 50 })); + authStore.setState((prev) => ({ + ...prev, + user: { id: 5, username: "me", avatar: null, role: "member" }, + })); + messagesStore.setState((prev) => ({ ...prev, detachedChannels: new Set([50]) })); + + mock.dispatch("chat_message", { + id: 503, + channel_id: 50, + user: { id: 10, username: "bob", avatar: "" }, + content: "arrived while reading back-history", + reply_to: null, + attachments: [], + timestamp: "2026-03-15T10:00:00Z", + }); + + const dms = dmStore.getState().channels; + const dm = dms.find((c) => c.channelId === 50); + expect(dm?.lastMessage).toBe("arrived while reading back-history"); + expect(dm?.unreadCount).toBe(1); + }); + it("increments the DM mention badge for an incoming @mention", () => { channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 })); authStore.setState((prev) => ({ @@ -3676,6 +3791,83 @@ describe("WS Dispatcher", () => { expect(voiceLeaveSent).toBe(false); }); + // OC-0201: a full-ready resync that preserves a live voice session (the + // LiveKit room outlived a WS drop) never replays voice_leave for anyone + // who departed the channel while the socket was down — `ready` rebuilds + // voiceUsers wholesale and stops. Without reconciliation, a departed peer + // keeps a working room key forever (no rotation ever runs for them) and a + // client newly elected key holder by the server-side re-registration never + // self-elects, since only handleParticipantLeft runs the election. + it("reconciles E2EE state for peers who left during a full-ready resync with a live voice session", async () => { + vi.mocked(mockHandleParticipantLeft).mockClear(); + + authStore.setState(() => ({ + token: "test-token", + user: { id: 42, username: "me", avatar: null, role: "member" }, + serverName: "Test", + motd: "", + isAuthenticated: true, + })); + + // Before the resync: self (42) and peer (7) are both in channel 10 — the + // live LiveKit session survived the WS drop. + voiceStore.setState((prev) => ({ + ...prev, + voiceStatus: "connected", + currentChannelId: 10, + voiceUsers: new Map([ + [ + 10, + new Map([ + [ + 42, + { + userId: 42, + username: "me", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + serverMuted: false, + serverDeafened: false, + }, + ], + [ + 7, + { + userId: 7, + username: "departed", + muted: false, + deafened: false, + speaking: false, + camera: false, + screenshare: false, + serverMuted: false, + serverDeafened: false, + }, + ], + ]), + ], + ]), + })); + + // The full resync's voice_states shows peer 7 has left channel 10 while + // we were disconnected — only self remains. + mock.dispatch("ready", { + channels: [{ id: 1, name: "general", type: "text", category: "", position: 0 }], + members: [], + voice_states: [{ user_id: 42, channel_id: 10, muted: false, deafened: false }], + roles: [], + dm_channels: [], + }); + await vi.runAllTimersAsync(); + + expect(mockHandleParticipantLeft).toHaveBeenCalledWith(7); + // Must never be called for ourselves. + expect(mockHandleParticipantLeft).not.toHaveBeenCalledWith(42); + }); + it("unknown event type does not throw", () => { expect(() => { mock.dispatch("totally_unknown_server_event", { some: "data" }); @@ -3803,6 +3995,31 @@ describe("WS Dispatcher", () => { expect(voiceStore.getState().currentChannelId).toBeNull(); expect(voiceStore.getState().voiceStatus).toBe("idle"); }); + + // OC-0193: a channel *switch* refusal (precheck FORBIDDEN/BAD_REQUEST/ + // RATE_LIMITED — anything that lands before the server's self voice_leave + // for the OLD channel) optimistically moved currentChannelId to the NEW + // channel and voiceStatus to "joining", but the LiveKit room from the OLD + // channel is still live — connected, mic published. The store-only + // leaveVoiceChannel() rollback used to leave that session dangling: the + // widget disappears (currentChannelId null hides it entirely) while audio + // keeps flowing and the server still lists us in the old channel. The + // rollback must also tear down the live LiveKit session so the media + // state and the store agree. + it("tears down a still-live LiveKit session when a channel-switch join is refused", async () => { + vi.mocked(mockLeaveVoice).mockClear(); + vi.mocked(mockIsVoiceConnected).mockReturnValue(true); + voiceStore.setState((prev) => ({ ...prev, currentChannelId: 7, voiceStatus: "joining" })); + + mock.dispatch("error", { code: "FORBIDDEN", message: "missing CONNECT_VOICE permission" }); + await vi.runAllTimersAsync(); + + expect(mockLeaveVoice).toHaveBeenCalledWith(true); + expect(voiceStore.getState().currentChannelId).toBeNull(); + expect(voiceStore.getState().voiceStatus).toBe("idle"); + + vi.mocked(mockIsVoiceConnected).mockReturnValue(false); + }); }); // A server refusal of voice_camera/voice_screenshare (FORBIDDEN, diff --git a/Client/tauri-client/tests/unit/notifications.test.ts b/Client/tauri-client/tests/unit/notifications.test.ts index 3ff8dc78..89c8e990 100644 --- a/Client/tauri-client/tests/unit/notifications.test.ts +++ b/Client/tauri-client/tests/unit/notifications.test.ts @@ -5,6 +5,7 @@ import { channelsStore } from "../../src/stores/channels.store"; import { dmStore } from "../../src/stores/dm.store"; import type { DmChannel } from "../../src/stores/dm.store"; import { membersStore } from "../../src/stores/members.store"; +import { messagesStore } from "../../src/stores/messages.store"; import type { ChatMessagePayload } from "../../src/lib/types"; // vi.hoisted ensures testPrefs is available when vi.mock factory runs @@ -152,6 +153,11 @@ describe("notifyIncomingMessage", () => { roleRevision: 0, })); + // A channel marked detached by one test (viewing a back-history + // around-window) must not leak into another that expects the plain + // active-channel suppression. + messagesStore.setState((prev) => ({ ...prev, detachedChannels: new Set() })); + // Ensure document.hasFocus returns false (simulating unfocused window) vi.spyOn(document, "hasFocus").mockReturnValue(false); }); @@ -1179,6 +1185,34 @@ describe("notifyIncomingMessage", () => { expect(sendNotification).toHaveBeenCalled(); }); }); + + // OC-0204: "active channel" is not the same thing as "the user is + // watching the live tail". A jump to an old permalink/reply/search hit + // in the active channel opens a detached around-window (messages.store's + // detachedChannels) — addMessage refuses to append a live broadcast onto + // it, and dispatcher.ts skips the unread bump because the channel is + // "active". If this guard also suppresses the notification, an @mention + // that arrives while the user reads back-history reaches them through + // literally nothing — not even a popup — even though the window is + // focused and they are looking at #general. + it("proceeds when window focused AND channel matches BUT the window is detached (reading back-history)", async () => { + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + (sendNotification as ReturnType).mockClear(); + + vi.spyOn(document, "hasFocus").mockReturnValue(true); + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 })); + messagesStore.setState((prev) => ({ ...prev, detachedChannels: new Set([1]) })); + + testPrefs.set("desktopNotifications", true); + testPrefs.set("flashTaskbar", false); + testPrefs.set("notificationSounds", false); + + notifyIncomingMessage(makePayload({ channel_id: 1 })); + + await vi.waitFor(() => { + expect(sendNotification).toHaveBeenCalled(); + }); + }); }); describe("notification toggles independently control each action", () => {