diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 6c251f7f..b2612a9f 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -181,6 +181,21 @@ function handleEvent(origin: string, event: ServerEvent): void { useChatStore.getState().setReadStates(event.readStates, channelLastMessageIds, originChannelIds); } + // Prune orphaned unreads: channels in unreadChannels that don't map to + // any known space channel or DM (e.g. deleted channels, revoked permissions) + { + const { unreadChannels: uc } = useChatStore.getState(); + const { channelToSpaceMap: ctsMap, dmChannels: dms } = useSpaceStore.getState(); + const dmIds = new Set(dms.map(d => d.id)); + const orphanIds = new Set(); + for (const id of uc) { + if (!ctsMap.has(id) && !dmIds.has(id)) orphanIds.add(id); + } + if (orphanIds.size > 0) { + useChatStore.getState().removeChannelStates(orphanIds); + } + } + // Clear voice state only for the reconnecting origin before repopulating clearVoiceUsersForOrigin(origin); if (event.voiceStates) { @@ -304,8 +319,10 @@ function handleEvent(origin: string, event: ServerEvent): void { addRealtimeMessage(event.message.channelId, event.message); { const { currentChannelId, markChannelUnread } = useChatStore.getState(); + const { voiceChannelIds } = useSpaceStore.getState(); const myId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin); - if (event.message.channelId !== currentChannelId && event.message.userId !== myId) { + // Skip voice channels — they have no text reading/acking UI + if (event.message.channelId !== currentChannelId && event.message.userId !== myId && !voiceChannelIds.has(event.message.channelId)) { markChannelUnread(event.message.channelId); } } @@ -612,7 +629,7 @@ function handleEvent(origin: string, event: ServerEvent): void { // ─── Channel/space events (all origins) ───────────────────────────────── case 'channel_created': { - const { currentSpaceId: curSpaceId, channels: curChannels, setChannels, channelToSpaceMap, channelPermissions, channelOriginMap } = useSpaceStore.getState(); + const { currentSpaceId: curSpaceId, channels: curChannels, setChannels, channelToSpaceMap, channelPermissions, channelOriginMap, voiceChannelIds } = useSpaceStore.getState(); if (event.spaceId === curSpaceId) { if (!curChannels.find(c => c.id === event.channel.id)) { setChannels([...curChannels, event.channel].sort((a, b) => a.position - b.position)); @@ -620,6 +637,9 @@ function handleEvent(origin: string, event: ServerEvent): void { } channelToSpaceMap.set(event.channel.id, event.spaceId); channelOriginMap.set(event.channel.id, origin); + if (event.channel.type === 'voice') { + voiceChannelIds.add(event.channel.id); + } if (event.channel.myPermissions) { channelPermissions.set(event.channel.id, event.channel.myPermissions); } diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index cb564cc0..3ee4637a 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -523,12 +523,18 @@ export const useChatStore = create((set, get) => ({ } // 2. Rebuild unreadChannels ONLY for channels from this origin - // Keep existing unread entries from other origins untouched + // Keep existing unread entries from other origins untouched, + // but prune orphans that don't map to any known channel const currentChannelId = get().currentChannelId; + const { channelToSpaceMap, dmChannels: knownDms } = useSpaceStore.getState(); + const knownDmIds = new Set(knownDms.map(dm => dm.id)); const unread = new Set(); for (const id of get().unreadChannels) { if (!originChannelIds || !originChannelIds.has(id)) { - unread.add(id); // preserve other-origin unreads + // Only preserve if the channel still maps to a known space or DM + if (channelToSpaceMap.has(id) || knownDmIds.has(id)) { + unread.add(id); + } } } diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index a2d71ae1..886ac846 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -38,6 +38,7 @@ interface SpaceState { spacePermissions: Map; // spaceId → myPermissions decimal string channelPermissions: Map; // channelId → myPermissions decimal string channelOriginMap: Map; // channelId → instance origin ('' = home) + voiceChannelIds: Set; // channelIds that are voice channels (excluded from unread) categoryOriginMap: Map; // categoryId → instance origin ('' = home) _layoutUpdatedAt: number; setSpaces: (spaces: TaggedSpace[]) => void; @@ -127,6 +128,7 @@ export const useSpaceStore = create((set, get) => ({ spacePermissions: new Map(), channelPermissions: new Map(), channelOriginMap: new Map(), + voiceChannelIds: new Set(), categoryOriginMap: new Map(), _layoutUpdatedAt: 0, @@ -147,6 +149,7 @@ export const useSpaceStore = create((set, get) => ({ spacePermissions: new Map(), channelPermissions: new Map(), channelOriginMap: new Map(), + voiceChannelIds: new Set(), categoryOriginMap: new Map(), _layoutUpdatedAt: 0, }); @@ -576,6 +579,7 @@ export const useSpaceStore = create((set, get) => ({ const spacePermissions = new Map(get().spacePermissions); const channelPermissions = new Map(get().channelPermissions); const channelOriginMap = new Map(get().channelOriginMap); + const voiceChannelIds = new Set(get().voiceChannelIds); const categoryOriginMap = new Map(get().categoryOriginMap); // If home, clear home-origin entries first to avoid stale data @@ -586,6 +590,7 @@ export const useSpaceStore = create((set, get) => ({ channelLastMessageIds.delete(key); channelPermissions.delete(key); channelOriginMap.delete(key); + voiceChannelIds.delete(key); } } // Also clear server permissions for this origin @@ -602,6 +607,7 @@ export const useSpaceStore = create((set, get) => ({ channelLastMessageIds.delete(key); channelPermissions.delete(key); channelOriginMap.delete(key); + voiceChannelIds.delete(key); } } for (const s of get().spaces) { @@ -619,7 +625,9 @@ export const useSpaceStore = create((set, get) => ({ for (const ch of srv.channels) { channelToSpaceMap.set(ch.id, srv.id); channelOriginMap.set(ch.id, origin); - if (ch.lastMessageId) { + if (ch.type === 'voice') { + voiceChannelIds.add(ch.id); + } else if (ch.lastMessageId) { channelLastMessageIds.set(ch.id, ch.lastMessageId); } if (ch.myPermissions) { @@ -663,6 +671,7 @@ export const useSpaceStore = create((set, get) => ({ spacePermissions, channelPermissions, channelOriginMap, + voiceChannelIds, categoryOriginMap, };