fix: eliminate phantom notifications across the entire read-state pipeline

Root cause: own messages echoed by the server marked channels unread when
the user had already navigated away. Seven related bugs compounded the
problem — stale read states, missing cleanup on space/DM removal, REST
broadcast ignoring VIEW_CHANNEL, and no validation on channel_ack writes.

Frontend:
- Skip markChannelUnread for the user's own messages (federation-aware)
- Walk backward past temp_ IDs in ackChannel instead of bailing
- Re-fire ack timer when temp message is replaced by server-confirmed ID
- Add removeChannelStates to clean up unread/read/message caches
- Clean up chatStore on removeSpace, removeDmChannel, removeInstanceSpaces

Server:
- Use sendToChannel instead of sendToSpace for REST message creation
- Clean up read_states on space deletion, member kick/leave, and ban
- Validate channel membership before accepting channel_ack writes
- Clean up read_states on DM leave and DM channel deletion
This commit is contained in:
Jannis Braun
2026-03-16 20:20:25 +01:00
parent 0815963e1b
commit e9c43f21f1
8 changed files with 118 additions and 27 deletions
+37 -16
View File
@@ -4,6 +4,7 @@ import { api, BackspaceApiClient } from '../api/client';
import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
import { isSelf } from '../utils/identity';
import { useAuthStore } from './authStore';
import { useChatStore } from './chatStore';
// ─── Instance-aware types ─────────────────────────────────────────────────────
@@ -147,9 +148,13 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
};
}),
removeDmChannel: (id) => set((state) => ({
dmChannels: state.dmChannels.filter(c => c.id !== id)
})),
removeDmChannel: (id) => {
set((state) => ({
dmChannels: state.dmChannels.filter(c => c.id !== id)
}));
// Clean up unread/read state for the closed DM
useChatStore.getState().removeChannelStates(new Set([id]));
},
addDmMember: (dmChannelId, user) => set((state) => ({
dmChannels: state.dmChannels.map(dm =>
@@ -408,13 +413,14 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
},
removeSpace: (spaceId: string) => {
set((state) => {
// Collect channel IDs belonging to this space for map cleanup
const channelIdsToRemove = new Set<string>();
for (const [channelId, sid] of state.channelToSpaceMap) {
if (sid === spaceId) channelIdsToRemove.add(channelId);
}
// Collect channel IDs before set() so we can clean up chatStore after
const currentState = get();
const channelIdsToRemove = new Set<string>();
for (const [channelId, sid] of currentState.channelToSpaceMap) {
if (sid === spaceId) channelIdsToRemove.add(channelId);
}
set((state) => {
const channelToSpaceMap = new Map(state.channelToSpaceMap);
const channelPermissions = new Map(state.channelPermissions);
const channelOriginMap = new Map(state.channelOriginMap);
@@ -439,6 +445,11 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
spacePermissions,
};
});
// Clean up orphaned unread/read states and cached messages in chatStore
if (channelIdsToRemove.size > 0) {
useChatStore.getState().removeChannelStates(channelIdsToRemove);
}
},
updateMemberPresence: (userId: string, status: string) => {
@@ -739,6 +750,13 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
},
removeInstanceSpaces: (origin: string) => {
// Collect channel IDs before set() for chatStore cleanup
const currentState = get();
const channelIdsToRemove = new Set<string>();
for (const [channelId, chOrigin] of currentState.channelOriginMap) {
if (chOrigin === origin) channelIdsToRemove.add(channelId);
}
set((state) => {
const remainingSpaces = state.spaces.filter(s => s._instanceOrigin !== origin);
@@ -749,13 +767,11 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
const channelOriginMap = new Map(state.channelOriginMap);
const spacePermissions = new Map(state.spacePermissions);
for (const [channelId, chOrigin] of state.channelOriginMap) {
if (chOrigin === origin) {
channelToSpaceMap.delete(channelId);
channelLastMessageIds.delete(channelId);
channelPermissions.delete(channelId);
channelOriginMap.delete(channelId);
}
for (const channelId of channelIdsToRemove) {
channelToSpaceMap.delete(channelId);
channelLastMessageIds.delete(channelId);
channelPermissions.delete(channelId);
channelOriginMap.delete(channelId);
}
for (const s of state.spaces) {
if (s._instanceOrigin === origin) {
@@ -775,6 +791,11 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
: null,
};
});
// Clean up orphaned unread/read states and cached messages in chatStore
if (channelIdsToRemove.size > 0) {
useChatStore.getState().removeChannelStates(channelIdsToRemove);
}
},
}));