fix: exclude voice channels from unread computation to eliminate ghost notifications

Voice channels rendered with VoiceChannel component have no text reading/acking
UI, so messages in them created phantom unread indicators on the space sidebar
that users could never clear. Root cause was a message in the counter-strike
voice channel with no read state.

Three-layer fix:
- spaceStore: track voiceChannelIds set, exclude voice channels from
  channelLastMessageIds so setReadStates never marks them unread
- useWebSocket: skip markChannelUnread for voice channels on message_created,
  prune orphaned unreads on every ready event
- chatStore: validate preserved unreads against channelToSpaceMap to drop
  orphans that don't map to any known channel
This commit is contained in:
Jannis Braun
2026-03-16 23:57:24 +01:00
parent bf64c4678b
commit bc4dd81632
3 changed files with 40 additions and 5 deletions
+22 -2
View File
@@ -181,6 +181,21 @@ function handleEvent(origin: string, event: ServerEvent): void {
useChatStore.getState().setReadStates(event.readStates, channelLastMessageIds, originChannelIds); 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<string>();
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 // Clear voice state only for the reconnecting origin before repopulating
clearVoiceUsersForOrigin(origin); clearVoiceUsersForOrigin(origin);
if (event.voiceStates) { if (event.voiceStates) {
@@ -304,8 +319,10 @@ function handleEvent(origin: string, event: ServerEvent): void {
addRealtimeMessage(event.message.channelId, event.message); addRealtimeMessage(event.message.channelId, event.message);
{ {
const { currentChannelId, markChannelUnread } = useChatStore.getState(); const { currentChannelId, markChannelUnread } = useChatStore.getState();
const { voiceChannelIds } = useSpaceStore.getState();
const myId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin); 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); markChannelUnread(event.message.channelId);
} }
} }
@@ -612,7 +629,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
// ─── Channel/space events (all origins) ───────────────────────────────── // ─── Channel/space events (all origins) ─────────────────────────────────
case 'channel_created': { 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 (event.spaceId === curSpaceId) {
if (!curChannels.find(c => c.id === event.channel.id)) { if (!curChannels.find(c => c.id === event.channel.id)) {
setChannels([...curChannels, event.channel].sort((a, b) => a.position - b.position)); 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); channelToSpaceMap.set(event.channel.id, event.spaceId);
channelOriginMap.set(event.channel.id, origin); channelOriginMap.set(event.channel.id, origin);
if (event.channel.type === 'voice') {
voiceChannelIds.add(event.channel.id);
}
if (event.channel.myPermissions) { if (event.channel.myPermissions) {
channelPermissions.set(event.channel.id, event.channel.myPermissions); channelPermissions.set(event.channel.id, event.channel.myPermissions);
} }
+8 -2
View File
@@ -523,12 +523,18 @@ export const useChatStore = create<ChatState>((set, get) => ({
} }
// 2. Rebuild unreadChannels ONLY for channels from this origin // 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 currentChannelId = get().currentChannelId;
const { channelToSpaceMap, dmChannels: knownDms } = useSpaceStore.getState();
const knownDmIds = new Set(knownDms.map(dm => dm.id));
const unread = new Set<string>(); const unread = new Set<string>();
for (const id of get().unreadChannels) { for (const id of get().unreadChannels) {
if (!originChannelIds || !originChannelIds.has(id)) { 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);
}
} }
} }
+10 -1
View File
@@ -38,6 +38,7 @@ interface SpaceState {
spacePermissions: Map<string, string>; // spaceId → myPermissions decimal string spacePermissions: Map<string, string>; // spaceId → myPermissions decimal string
channelPermissions: Map<string, string>; // channelId → myPermissions decimal string channelPermissions: Map<string, string>; // channelId → myPermissions decimal string
channelOriginMap: Map<string, string>; // channelId → instance origin ('' = home) channelOriginMap: Map<string, string>; // channelId → instance origin ('' = home)
voiceChannelIds: Set<string>; // channelIds that are voice channels (excluded from unread)
categoryOriginMap: Map<string, string>; // categoryId → instance origin ('' = home) categoryOriginMap: Map<string, string>; // categoryId → instance origin ('' = home)
_layoutUpdatedAt: number; _layoutUpdatedAt: number;
setSpaces: (spaces: TaggedSpace[]) => void; setSpaces: (spaces: TaggedSpace[]) => void;
@@ -127,6 +128,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
spacePermissions: new Map(), spacePermissions: new Map(),
channelPermissions: new Map(), channelPermissions: new Map(),
channelOriginMap: new Map(), channelOriginMap: new Map(),
voiceChannelIds: new Set(),
categoryOriginMap: new Map(), categoryOriginMap: new Map(),
_layoutUpdatedAt: 0, _layoutUpdatedAt: 0,
@@ -147,6 +149,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
spacePermissions: new Map(), spacePermissions: new Map(),
channelPermissions: new Map(), channelPermissions: new Map(),
channelOriginMap: new Map(), channelOriginMap: new Map(),
voiceChannelIds: new Set(),
categoryOriginMap: new Map(), categoryOriginMap: new Map(),
_layoutUpdatedAt: 0, _layoutUpdatedAt: 0,
}); });
@@ -576,6 +579,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
const spacePermissions = new Map(get().spacePermissions); const spacePermissions = new Map(get().spacePermissions);
const channelPermissions = new Map(get().channelPermissions); const channelPermissions = new Map(get().channelPermissions);
const channelOriginMap = new Map(get().channelOriginMap); const channelOriginMap = new Map(get().channelOriginMap);
const voiceChannelIds = new Set(get().voiceChannelIds);
const categoryOriginMap = new Map(get().categoryOriginMap); const categoryOriginMap = new Map(get().categoryOriginMap);
// If home, clear home-origin entries first to avoid stale data // If home, clear home-origin entries first to avoid stale data
@@ -586,6 +590,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
channelLastMessageIds.delete(key); channelLastMessageIds.delete(key);
channelPermissions.delete(key); channelPermissions.delete(key);
channelOriginMap.delete(key); channelOriginMap.delete(key);
voiceChannelIds.delete(key);
} }
} }
// Also clear server permissions for this origin // Also clear server permissions for this origin
@@ -602,6 +607,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
channelLastMessageIds.delete(key); channelLastMessageIds.delete(key);
channelPermissions.delete(key); channelPermissions.delete(key);
channelOriginMap.delete(key); channelOriginMap.delete(key);
voiceChannelIds.delete(key);
} }
} }
for (const s of get().spaces) { for (const s of get().spaces) {
@@ -619,7 +625,9 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
for (const ch of srv.channels) { for (const ch of srv.channels) {
channelToSpaceMap.set(ch.id, srv.id); channelToSpaceMap.set(ch.id, srv.id);
channelOriginMap.set(ch.id, origin); 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); channelLastMessageIds.set(ch.id, ch.lastMessageId);
} }
if (ch.myPermissions) { if (ch.myPermissions) {
@@ -663,6 +671,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
spacePermissions, spacePermissions,
channelPermissions, channelPermissions,
channelOriginMap, channelOriginMap,
voiceChannelIds,
categoryOriginMap, categoryOriginMap,
}; };