feat: unread markers for channels and DMs

- Read states store: smarter hasUnread compares against latest message
- ConversationList: orange dot + bold name for unread DMs
- DMChat: auto-mark-read when viewing conversation
- WS handler: mark DM read on new message while active+focused
This commit is contained in:
2026-07-06 17:56:19 +00:00
parent c9a213ab8a
commit 4e429ca920
6 changed files with 76 additions and 11 deletions
+26 -4
View File
@@ -3,14 +3,18 @@ import { api } from '../lib/api';
interface ReadStatesState {
states: Record<string, string>; // channel_id -> last_read_message_id
convStates: Record<string, string>; // conversation_id -> last_read_message_id
loaded: boolean;
fetchStates: () => Promise<void>;
markRead: (channelId: string, messageId: string) => Promise<void>;
hasUnread: (channelId: string) => boolean; // true if no read state (never viewed)
markConvRead: (conversationId: string, messageId: string) => void;
hasUnread: (channelId: string, latestMessageId?: string) => boolean;
hasConvUnread: (conversationId: string, latestMessageId?: string) => boolean;
}
export const useReadStatesStore = create<ReadStatesState>()((set, get) => ({
states: {},
convStates: {},
loaded: false,
fetchStates: async () => {
@@ -33,9 +37,27 @@ export const useReadStatesStore = create<ReadStatesState>()((set, get) => ({
}
},
hasUnread: (channelId: string): boolean => {
markConvRead: (conversationId: string, messageId: string) => {
set((state) => ({
convStates: { ...state.convStates, [conversationId]: messageId },
}));
},
hasUnread: (channelId: string, latestMessageId?: string): boolean => {
const state = get().states;
// No read state means the user has never viewed this channel
return !(channelId in state);
const lastRead = state[channelId];
// never viewed: unread if there are messages
if (!lastRead) return !!latestMessageId;
// viewed but newer messages exist
if (latestMessageId && lastRead !== latestMessageId) return true;
return false;
},
hasConvUnread: (conversationId: string, latestMessageId?: string): boolean => {
const state = get().convStates;
const lastRead = state[conversationId];
if (!lastRead) return !!latestMessageId;
if (latestMessageId && lastRead !== latestMessageId) return true;
return false;
},
}));
+6
View File
@@ -7,6 +7,7 @@ import { useTypingStore } from './typing.ts';
import { useVoiceStore } from './voice.ts';
import { useAuthStore } from './auth.ts';
import { useConversationStore } from './conversation.ts';
import { useReadStatesStore } from './readStates.ts';
import type { Message } from './message.ts';
import type { ConversationMessage } from './conversation.ts';
import type { Channel } from './channel.ts';
@@ -132,6 +133,11 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
if (payload.conversation_id) {
const msg = payload as unknown as ConversationMessage;
useConversationStore.getState().addMessage(msg);
// auto-mark DM as read if this conversation is active
const activeConvId = useConversationStore.getState().activeConversationId;
if (msg.conversation_id === activeConvId && document.hasFocus()) {
useReadStatesStore.getState().markConvRead(msg.conversation_id, msg.id);
}
} else {
const msg = payload as unknown as Message;
addMessage(msg);