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