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
+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 };
});
},
}));