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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user