mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix(client): 4 defect(s) (OC-0193, OC-0201, OC-0204, OC-0218)
This commit is contained in:
@@ -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 /
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user