feat: unread indicators + DM bug fixes + data-driven isDmChannel

- Fix stale message cache: add force param to loadMessages, clearAllMessages action
- Fix reload race condition: URL-based isDmChannel fallback before WS ready
- Add read_states DB table for persistent unread tracking
- Add channel_ack WS event (client→server→echo) with BigInt comparison
- Wire up unread state in chatStore (readStates, unreadChannels, ackChannel)
- Auto-ack channels on MessageList view (200ms debounced)
- Unread pill indicators on server icons in ServerSidebar
- Bold text + white dot on unread channels/DMs in ChannelSidebar
- Replace all showDms reads with data-driven isDmChannel() across 8 files
- Design system, UI polish, and component fixes from previous sessions
This commit is contained in:
Jannis Braun
2026-02-18 20:48:48 +01:00
parent 7168149d98
commit 435d12e5b8
50 changed files with 711 additions and 285 deletions
+59 -10
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { api } from '../api/client';
import { wsSend } from '../hooks/useWebSocket';
import { useUIStore } from './uiStore';
import { isDmChannel } from './serverStore';
export const useChatStore = create((set, get) => ({
messages: new Map(),
currentChannelId: null,
@@ -10,14 +10,17 @@ export const useChatStore = create((set, get) => ({
isLoading: false,
loadError: null,
replyTo: null,
readStates: new Map(),
unreadChannels: new Set(),
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
setReplyTo: (message) => set({ replyTo: message }),
loadMessages: async (channelId) => {
if (get().messages.has(channelId))
clearAllMessages: () => set({ messages: new Map(), hasMore: new Map() }),
loadMessages: async (channelId, force) => {
if (!force && get().messages.has(channelId))
return;
set({ isLoading: true, loadError: null });
try {
const isDm = useUIStore.getState().showDms;
const isDm = isDmChannel(channelId);
const messages = isDm
? await api.dm.messages(channelId)
: await api.channels.messages(channelId);
@@ -43,7 +46,7 @@ export const useChatStore = create((set, get) => ({
if (!oldestMessage)
return false;
try {
const isDm = useUIStore.getState().showDms;
const isDm = isDmChannel(channelId);
const olderMessages = isDm
? await api.dm.messages(channelId, oldestMessage.id)
: await api.channels.messages(channelId, oldestMessage.id);
@@ -63,7 +66,7 @@ export const useChatStore = create((set, get) => ({
},
sendMessage: async (channelId, content, attachmentIds) => {
const replyToId = get().replyTo?.id;
const isDm = useUIStore.getState().showDms;
const isDm = isDmChannel(channelId);
if (isDm) {
await api.dm.sendMessage(channelId, { content });
}
@@ -73,8 +76,8 @@ export const useChatStore = create((set, get) => ({
set({ replyTo: null });
// Message will arrive via WebSocket
},
editMessage: async (messageId, content) => {
const isDm = useUIStore.getState().showDms;
editMessage: async (messageId, content, channelId) => {
const isDm = isDmChannel(channelId);
if (isDm) {
await api.dm.updateMessage(messageId, { content });
} else {
@@ -82,8 +85,8 @@ export const useChatStore = create((set, get) => ({
}
// Update will arrive via WebSocket
},
deleteMessage: async (messageId) => {
const isDm = useUIStore.getState().showDms;
deleteMessage: async (messageId, channelId) => {
const isDm = isDmChannel(channelId);
if (isDm) {
await api.dm.deleteMessage(messageId);
} else {
@@ -200,4 +203,50 @@ export const useChatStore = create((set, get) => ({
const now = Date.now();
return users.filter(t => now - t.timestamp < 5000);
},
setReadStates: (readStates, channelLastMessageIds) => {
const rsMap = new Map();
for (const rs of readStates) {
rsMap.set(rs.channelId, rs.lastReadMessageId);
}
const unread = new Set();
for (const [channelId, lastMsgId] of channelLastMessageIds) {
const lastRead = rsMap.get(channelId);
if (!lastRead || BigInt(lastMsgId) > BigInt(lastRead)) {
unread.add(channelId);
}
}
set({ readStates: rsMap, unreadChannels: unread });
},
markChannelUnread: (channelId) => {
set((state) => {
if (state.unreadChannels.has(channelId)) return state;
const newUnread = new Set(state.unreadChannels);
newUnread.add(channelId);
return { unreadChannels: newUnread };
});
},
ackChannel: (channelId) => {
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;
set((state) => {
const newReadStates = new Map(state.readStates);
newReadStates.set(channelId, messageId);
const newUnread = new Set(state.unreadChannels);
newUnread.delete(channelId);
return { readStates: newReadStates, unreadChannels: newUnread };
});
wsSend({ type: 'channel_ack', channelId, messageId });
},
onChannelAck: (channelId, messageId) => {
set((state) => {
const newReadStates = new Map(state.readStates);
newReadStates.set(channelId, messageId);
const newUnread = new Set(state.unreadChannels);
newUnread.delete(channelId);
return { readStates: newReadStates, unreadChannels: newUnread };
});
},
}));
+79 -14
View File
@@ -1,8 +1,8 @@
import { create } from 'zustand';
import type { MessageWithUser, Reaction } from '@opencord/shared';
import type { MessageWithUser, Reaction, ReadState } from '@opencord/shared';
import { api } from '../api/client';
import { wsSend } from '../hooks/useWebSocket';
import { useUIStore } from './uiStore';
import { isDmChannel } from './serverStore';
interface TypingUser {
userId: string;
@@ -18,13 +18,16 @@ interface ChatState {
isLoading: boolean;
loadError: string | null;
replyTo: MessageWithUser | null;
readStates: Map<string, string>;
unreadChannels: Set<string>;
setCurrentChannel: (channelId: string | null) => void;
setReplyTo: (message: MessageWithUser | null) => void;
loadMessages: (channelId: string) => Promise<void>;
loadMessages: (channelId: string, force?: boolean) => Promise<void>;
clearAllMessages: () => void;
loadMoreMessages: (channelId: string) => Promise<boolean>;
sendMessage: (channelId: string, content: string, attachmentIds?: string[]) => Promise<void>;
editMessage: (messageId: string, content: string) => Promise<void>;
deleteMessage: (messageId: string) => Promise<void>;
editMessage: (messageId: string, content: string, channelId: string) => Promise<void>;
deleteMessage: (messageId: string, channelId: string) => Promise<void>;
addMessage: (channelId: string, message: MessageWithUser) => void;
updateMessage: (message: MessageWithUser) => void;
removeMessage: (messageId: string, channelId: string) => void;
@@ -36,6 +39,10 @@ interface ChatState {
clearTyping: (channelId: string, userId: string) => void;
getMessages: (channelId: string) => MessageWithUser[];
getTypingUsers: (channelId: string) => TypingUser[];
setReadStates: (readStates: ReadState[], channelLastMessageIds: Map<string, string>) => void;
markChannelUnread: (channelId: string) => void;
ackChannel: (channelId: string) => void;
onChannelAck: (channelId: string, messageId: string) => void;
}
export const useChatStore = create<ChatState>((set, get) => ({
@@ -46,15 +53,19 @@ export const useChatStore = create<ChatState>((set, get) => ({
isLoading: false,
loadError: null,
replyTo: null,
readStates: new Map(),
unreadChannels: new Set(),
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
setReplyTo: (message) => set({ replyTo: message }),
loadMessages: async (channelId: string) => {
if (get().messages.has(channelId)) return;
clearAllMessages: () => set({ messages: new Map(), hasMore: new Map() }),
loadMessages: async (channelId: string, force?: boolean) => {
if (!force && get().messages.has(channelId)) return;
set({ isLoading: true, loadError: null });
try {
const isDm = useUIStore.getState().showDms;
const isDm = isDmChannel(channelId);
const messages = isDm
? await api.dm.messages(channelId)
: await api.channels.messages(channelId);
@@ -80,7 +91,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (!oldestMessage) return false;
try {
const isDm = useUIStore.getState().showDms;
const isDm = isDmChannel(channelId);
const olderMessages = isDm
? await api.dm.messages(channelId, oldestMessage.id)
: await api.channels.messages(channelId, oldestMessage.id);
@@ -101,7 +112,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
sendMessage: async (channelId: string, content: string, attachmentIds?: string[]) => {
const replyToId = get().replyTo?.id;
const isDm = useUIStore.getState().showDms;
const isDm = isDmChannel(channelId);
if (isDm) {
await api.dm.sendMessage(channelId, { content });
@@ -113,8 +124,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
// Message will arrive via WebSocket
},
editMessage: async (messageId: string, content: string) => {
const isDm = useUIStore.getState().showDms;
editMessage: async (messageId: string, content: string, channelId: string) => {
const isDm = isDmChannel(channelId);
if (isDm) {
await api.dm.updateMessage(messageId, { content });
} else {
@@ -123,8 +134,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
// Update will arrive via WebSocket
},
deleteMessage: async (messageId: string) => {
const isDm = useUIStore.getState().showDms;
deleteMessage: async (messageId: string, channelId: string) => {
const isDm = isDmChannel(channelId);
if (isDm) {
await api.dm.deleteMessage(messageId);
} else {
@@ -253,4 +264,58 @@ export const useChatStore = create<ChatState>((set, get) => ({
const now = Date.now();
return users.filter(t => now - t.timestamp < 5000);
},
setReadStates: (readStates: ReadState[], channelLastMessageIds: Map<string, string>) => {
const rsMap = new Map<string, string>();
for (const rs of readStates) {
rsMap.set(rs.channelId, rs.lastReadMessageId);
}
const unread = new Set<string>();
for (const [channelId, lastMsgId] of channelLastMessageIds) {
const lastRead = rsMap.get(channelId);
if (!lastRead || BigInt(lastMsgId) > BigInt(lastRead)) {
unread.add(channelId);
}
}
set({ readStates: rsMap, unreadChannels: unread });
},
markChannelUnread: (channelId: string) => {
set((state) => {
if (state.unreadChannels.has(channelId)) return state;
const newUnread = new Set(state.unreadChannels);
newUnread.add(channelId);
return { unreadChannels: newUnread };
});
},
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;
// Update local state immediately
set((state) => {
const newReadStates = new Map(state.readStates);
newReadStates.set(channelId, messageId);
const newUnread = new Set(state.unreadChannels);
newUnread.delete(channelId);
return { readStates: newReadStates, unreadChannels: newUnread };
});
// Send to server
wsSend({ type: 'channel_ack', channelId, messageId });
},
onChannelAck: (channelId: string, messageId: string) => {
set((state) => {
const newReadStates = new Map(state.readStates);
newReadStates.set(channelId, messageId);
const newUnread = new Set(state.unreadChannels);
newUnread.delete(channelId);
return { readStates: newReadStates, unreadChannels: newUnread };
});
},
}));
+33 -1
View File
@@ -8,6 +8,8 @@ export const useServerStore = create((set, get) => ({
roles: [],
folders: [],
dmChannels: [],
channelToServerMap: new Map(),
channelLastMessageIds: new Map(),
setServers: (servers) => set({ servers }),
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
setChannels: (channels) => set({ channels }),
@@ -138,10 +140,40 @@ export const useServerStore = create((set, get) => ({
inviteCode: s.inviteCode,
createdAt: s.createdAt,
}));
const channelToServerMap = new Map();
const channelLastMessageIds = new Map();
for (const srv of servers) {
for (const ch of srv.channels) {
channelToServerMap.set(ch.id, srv.id);
if (ch.lastMessageId) {
channelLastMessageIds.set(ch.id, ch.lastMessageId);
}
}
}
const dms = dmChannels || [];
for (const dm of dms) {
if (dm.lastMessage?.id) {
channelLastMessageIds.set(dm.id, dm.lastMessage.id);
}
}
set({
servers: simpleServers,
folders: folders || [],
dmChannels: dmChannels || []
dmChannels: dms,
channelToServerMap,
channelLastMessageIds,
});
},
}));
export function isDmChannel(channelId) {
const dmChannels = useServerStore.getState().dmChannels;
if (dmChannels.length > 0) {
return dmChannels.some(dm => dm.id === channelId);
}
// Before WS ready populates dmChannels, fall back to URL path
if (typeof window !== 'undefined') {
return window.location.pathname.startsWith('/channels/@me/');
}
return false;
}
+46 -3
View File
@@ -10,6 +10,8 @@ interface ServerState {
roles: Role[];
folders: ServerFolder[];
dmChannels: DmChannel[];
channelToServerMap: Map<string, string>;
channelLastMessageIds: Map<string, string>;
setServers: (servers: Server[]) => void;
setCurrentServer: (serverId: string | null) => void;
setChannels: (channels: Channel[]) => void;
@@ -44,6 +46,8 @@ export const useServerStore = create<ServerState>((set, get) => ({
roles: [],
folders: [],
dmChannels: [],
channelToServerMap: new Map(),
channelLastMessageIds: new Map(),
setServers: (servers) => set({ servers }),
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
@@ -189,10 +193,49 @@ export const useServerStore = create<ServerState>((set, get) => ({
inviteCode: s.inviteCode,
createdAt: s.createdAt,
}));
set({
servers: simpleServers,
// Build channel→server map and channel→lastMessageId map
const channelToServerMap = new Map<string, string>();
const channelLastMessageIds = new Map<string, string>();
for (const srv of servers) {
for (const ch of srv.channels) {
channelToServerMap.set(ch.id, srv.id);
if (ch.lastMessageId) {
channelLastMessageIds.set(ch.id, ch.lastMessageId);
}
}
}
// Also map DM channels
const dms = dmChannels || [];
for (const dm of dms) {
if (dm.lastMessage?.id) {
channelLastMessageIds.set(dm.id, dm.lastMessage.id);
}
}
set({
servers: simpleServers,
folders: folders || [],
dmChannels: dmChannels || []
dmChannels: dms,
channelToServerMap,
channelLastMessageIds,
});
},
}));
/**
* Data-driven DM channel detection. Returns true if the given channelId
* belongs to a DM channel. Authoritative because dmChannels is populated
* from the WS ready event and DM/server channel IDs never overlap.
*/
export function isDmChannel(channelId: string): boolean {
const dmChannels = useServerStore.getState().dmChannels;
if (dmChannels.length > 0) {
return dmChannels.some(dm => dm.id === channelId);
}
// Before WS ready populates dmChannels, fall back to URL path
if (typeof window !== 'undefined') {
return window.location.pathname.startsWith('/channels/@me/');
}
return false;
}