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
+28 -6
View File
@@ -57,6 +57,7 @@ interface ChatState {
markChannelUnread: (channelId: string) => void;
ackChannel: (channelId: string) => void;
onChannelAck: (channelId: string, messageId: string) => void;
removeChannelStates: (channelIds: Set<string>) => void;
updateUserInMessages: (user: { id: string; [key: string]: any }) => void;
}
@@ -565,16 +566,22 @@ export const useChatStore = create<ChatState>((set, get) => ({
ackChannel: (channelId: string) => {
const msgs = get().messages.get(channelId);
if (!msgs || msgs.length === 0) return;
const lastMsg = msgs[msgs.length - 1];
if (!lastMsg) return;
const messageId = lastMsg.id;
// Don't ack optimistic/temp messages — wait for the real server ID
if (messageId.startsWith('temp_')) return;
// Walk backward to find the last server-confirmed (non-temp) message
let messageId: string | null = null;
for (let i = msgs.length - 1; i >= 0; i--) {
const msg = msgs[i];
if (msg && !msg.id.startsWith('temp_')) {
messageId = msg.id;
break;
}
}
if (!messageId) return; // All messages are temp — nothing to ack yet
// Update local state immediately
set((state) => {
const newReadStates = new Map(state.readStates);
newReadStates.set(channelId, messageId);
newReadStates.set(channelId, messageId!);
const newUnread = new Set(state.unreadChannels);
newUnread.delete(channelId);
return { readStates: newReadStates, unreadChannels: newUnread };
@@ -595,6 +602,21 @@ export const useChatStore = create<ChatState>((set, get) => ({
});
},
removeChannelStates: (channelIds: Set<string>) => {
if (channelIds.size === 0) return;
set((state) => {
const newUnread = new Set(state.unreadChannels);
const newReadStates = new Map(state.readStates);
const newMessages = new Map(state.messages);
for (const channelId of channelIds) {
newUnread.delete(channelId);
newReadStates.delete(channelId);
newMessages.delete(channelId);
}
return { unreadChannels: newUnread, readStates: newReadStates, messages: newMessages };
});
},
updateUserInMessages: (user: { id: string; homeUserId?: string | null; [key: string]: any }) => {
set((state) => {
const newMessages = new Map(state.messages);