fix(client): 4 defect(s) (OC-0193, OC-0201, OC-0204, OC-0218)

This commit is contained in:
Claude
2026-08-20 17:10:05 +00:00
parent 5dcf18f346
commit 4bab1b4b4b
9 changed files with 472 additions and 29 deletions
+94 -17
View File
@@ -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<number>();
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 /
+16 -2
View File
@@ -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,
@@ -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);
}
+29 -4
View File
@@ -21,18 +21,43 @@ export const BLOCKED_BY_THEM_REASON = "You can't message this user right now.";
export interface BlocksState {
readonly blockedByMe: ReadonlySet<number>;
readonly blockedByThem: ReadonlySet<number>;
/**
* 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<BlocksState>(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 };
});
}
@@ -333,10 +333,19 @@ export function getChannelsByCategory(): Map<string | null, Channel[]> {
});
}
/** 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);
@@ -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);
});
});
});
@@ -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<typeof vi.fn>).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", () => {
@@ -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,
@@ -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<typeof vi.fn>).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", () => {