@@ -82,6 +95,7 @@ export function ConversationList() {
: conv.type === "group_dm"
? conv.name || conv.members.map((m) => m.username).join(", ")
: otherMemberName(conv, currentUser?.id || "");
+ const unread = hasConvUnread(conv.id, getLatestMessageId(conv.id));
return (
);
})}
diff --git a/web/src/components/DMChat.tsx b/web/src/components/DMChat.tsx
index 9a6e47b..f25fc13 100644
--- a/web/src/components/DMChat.tsx
+++ b/web/src/components/DMChat.tsx
@@ -4,6 +4,7 @@ import { useConversationStore, type ConversationMessage } from "../stores/conver
import { useAuthStore } from "../stores/auth.ts";
import { useTypingStore } from "../stores/typing.ts";
import { useLayoutStore } from "../stores/layout.ts";
+import { useReadStatesStore } from "../stores/readStates.ts";
import { type Gif } from "./GiphyPicker.tsx";
import { EmojiPicker as KaomojiPicker } from "./EmojiPicker.tsx";
import Picker, { Theme } from 'emoji-picker-react';
@@ -31,6 +32,9 @@ function renderDMContent(content: string) {
code: ({ ...props }) =>
,
pre: ({ ...props }) =>
,
blockquote: ({ ...props }) =>
,
+ h1: ({ ...props }) =>
,
+ h2: ({ ...props }) =>
,
+ h3: ({ ...props }) =>
,
p: ({ ...props }) =>
,
img: ({ src, alt, ...props }) => (
@@ -149,6 +153,7 @@ export function DMChat() {
const fetchMessages = useConversationStore((s) => s.fetchMessages);
const fetchOlderMessages = useConversationStore((s) => s.fetchOlderMessages);
const isLoadingOlder = useConversationStore((s) => s.isLoadingOlder);
+ const isLoading = useConversationStore((s) => s.isLoading);
const sendMessage = useConversationStore((s) => s.sendMessage);
const fetchConversations = useConversationStore((s) => s.fetchConversations);
const currentUser = useAuthStore((s) => s.user);
@@ -162,6 +167,7 @@ export function DMChat() {
const scrollContainerRef = useRef
(null);
const inputRef = useRef(null);
const lastTypingRef = useRef(0);
+ const markConvRead = useReadStatesStore((s) => s.markConvRead);
const id = conversationId || activeId;
const conversation = conversations.find((c) => c.id === id);
@@ -207,6 +213,14 @@ export function DMChat() {
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]);
+ useEffect(() => {
+ if (!id || messages.length === 0 || isLoading) return;
+ const lastMsg = messages[messages.length - 1];
+ if (lastMsg?.id) {
+ markConvRead(id, lastMsg.id);
+ }
+ }, [id, messages.length, isLoading]);
+
const handleGifSelect = async (gif: Gif) => {
if (!id) return;
const content = ``;
diff --git a/web/src/stores/readStates.ts b/web/src/stores/readStates.ts
index 6d22004..a40b646 100644
--- a/web/src/stores/readStates.ts
+++ b/web/src/stores/readStates.ts
@@ -3,14 +3,18 @@ import { api } from '../lib/api';
interface ReadStatesState {
states: Record; // channel_id -> last_read_message_id
+ convStates: Record; // conversation_id -> last_read_message_id
loaded: boolean;
fetchStates: () => Promise;
markRead: (channelId: string, messageId: string) => Promise;
- 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()((set, get) => ({
states: {},
+ convStates: {},
loaded: false,
fetchStates: async () => {
@@ -33,9 +37,27 @@ export const useReadStatesStore = create()((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;
},
}));
diff --git a/web/src/stores/ws.ts b/web/src/stores/ws.ts
index ec87e6d..a55f3b5 100644
--- a/web/src/stores/ws.ts
+++ b/web/src/stores/ws.ts
@@ -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((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);