diff --git a/web/src/components/ChannelList.tsx b/web/src/components/ChannelList.tsx index 5f8c698..7151690 100644 --- a/web/src/components/ChannelList.tsx +++ b/web/src/components/ChannelList.tsx @@ -7,6 +7,7 @@ import { InviteModal } from './InviteModal.tsx'; import { ChannelSettingsModal } from './ChannelSettingsModal.tsx'; import { useNotificationSettingsStore } from '../stores/notificationSettings.ts'; import { useReadStatesStore } from '../stores/readStates.ts'; +import { useMessageStore } from '../stores/message.ts'; import { useContextMenu } from './ContextMenu.tsx'; import { api } from '../lib/api.ts'; @@ -79,8 +80,9 @@ export function ChannelList() { const fetchNotifSettings = useNotificationSettingsStore((state) => state.fetchSettings); const setNotifLevel = useNotificationSettingsStore((state) => state.setLevel); - const readStates = useReadStatesStore((state) => state.states); const fetchReadStates = useReadStatesStore((state) => state.fetchStates); + const hasUnread = useReadStatesStore((state) => state.hasUnread); + const messagesByChannel = useMessageStore((state) => state.messagesByChannel); const { showMenu, MenuPortal } = useContextMenu(); @@ -179,8 +181,9 @@ export function ChannelList() { return level === 'mentions' ? '🔔@' : '🔔'; }; - const hasUnread = (channelId: string): boolean => { - return !(channelId in readStates); + const getLatestMessageId = (channelId: string): string | undefined => { + const msgs = messagesByChannel[channelId] || []; + return msgs.length > 0 ? msgs[msgs.length - 1].id : undefined; }; // --- Channel context menu --- @@ -292,7 +295,7 @@ export function ChannelList() { {channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'} {channel.name} - {hasUnread(channel.id) && ( + {hasUnread(channel.id, getLatestMessageId(channel.id)) && ( )} diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index fa175b0..e831f23 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -96,6 +96,9 @@ function renderContent(content: string, memberUsernames: Set) { table: ({ ...props }) => , th: ({ ...props }) =>
, td: ({ ...props }) => , + h1: ({ ...props }) =>

, + h2: ({ ...props }) =>

, + h3: ({ ...props }) =>

, p: ({ ...props }) => , img: ({ src, alt, ...props }) => ( s.activeConversationId); const fetchConversations = useConversationStore((s) => s.fetchConversations); const createConversation = useConversationStore((s) => s.createConversation); + const messagesByConv = useConversationStore((s) => s.messagesByConversation); const currentUser = useAuthStore((s) => s.user); + const hasConvUnread = useReadStatesStore((s) => s.hasConvUnread); + const markConvRead = useReadStatesStore((s) => s.markConvRead); const navigate = useNavigate(); const [showNew, setShowNew] = useState(false); const [notesError, setNotesError] = useState(null); @@ -28,15 +32,19 @@ export function ConversationList() { }, [fetchConversations]); const openConversation = (convId: string) => { + // mark as read when opening + const msgs = messagesByConv[convId] || []; + if (msgs.length > 0) { + markConvRead(convId, msgs[msgs.length - 1].id); + } navigate(`/dm/${convId}`); }; const openNotes = async () => { setNotesError(null); - // Find existing self-DM or create one const existing = conversations.find((c) => isSelfDM(c, currentUser?.id || "")); if (existing) { - navigate(`/dm/${existing.id}`); + openConversation(existing.id); return; } try { @@ -50,6 +58,11 @@ export function ConversationList() { } }; + const getLatestMessageId = (convId: string): string | undefined => { + const msgs = messagesByConv[convId] || []; + return msgs.length > 0 ? msgs[msgs.length - 1].id : undefined; + }; + return (
@@ -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 = `![${gif.title || "GIF"}](${gif.images.fixed_height.url})`; 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);