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 2b45b11ea8
commit 88194b17c4
6 changed files with 76 additions and 11 deletions
+7 -4
View File
@@ -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' ? '☑' : '#'}
</span>
<span className="truncate flex-1">{channel.name}</span>
{hasUnread(channel.id) && (
{hasUnread(channel.id, getLatestMessageId(channel.id)) && (
<span className="text-gb-orange text-xs font-bold" title="Unread messages"></span>
)}
<span className="flex items-center gap-1 text-xs pointer-events-none group-hover:pointer-events-auto">
+3
View File
@@ -96,6 +96,9 @@ function renderContent(content: string, memberUsernames: Set<string>) {
table: ({ ...props }) => <table {...props} className="border-collapse border border-gb-bg-t my-1" />,
th: ({ ...props }) => <th {...props} className="border border-gb-bg-t px-2 py-1 bg-gb-bg-s" />,
td: ({ ...props }) => <td {...props} className="border border-gb-bg-t px-2 py-1" />,
h1: ({ ...props }) => <h1 {...props} className="text-lg font-bold my-1" />,
h2: ({ ...props }) => <h2 {...props} className="text-base font-bold my-1" />,
h3: ({ ...props }) => <h3 {...props} className="text-sm font-bold my-0.5" />,
p: ({ ...props }) => <span {...props} className="inline" />,
img: ({ src, alt, ...props }) => (
<ExpandableImage
+20 -3
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useConversationStore } from "../stores/conversation.ts";
import { useAuthStore } from "../stores/auth.ts";
import { useReadStatesStore } from "../stores/readStates.ts";
import { NewConversationModal } from "./NewConversationModal.tsx";
function otherMemberName(conv: { members: { id: string; username: string }[] }, currentUserId: string) {
@@ -18,7 +19,10 @@ export function ConversationList() {
const activeId = useConversationStore((s) => 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<string | null>(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 (
<div className="h-full w-56 bg-gb-bg-s border-r border-gb-bg-t flex flex-col">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg truncate flex items-center justify-between">
@@ -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 (
<button
key={conv.id}
@@ -93,7 +107,10 @@ export function ConversationList() {
}`}
>
<span className="text-gb-fg-f">{self ? "📝" : "@"}</span>
<span className="truncate">{name}</span>
<span className={`truncate flex-1 ${unread ? "text-gb-fg font-bold" : ""}`}>{name}</span>
{unread && (
<span className="text-gb-orange text-xs font-bold shrink-0" title="Unread messages"></span>
)}
</button>
);
})}
+14
View File
@@ -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 }) => <code {...props} className="bg-gb-bg-t px-1 rounded text-gb-fg-s" />,
pre: ({ ...props }) => <pre {...props} className="bg-gb-bg-s p-2 my-1 overflow-x-auto text-gb-fg-s" />,
blockquote: ({ ...props }) => <blockquote {...props} className="border-l-2 border-gb-orange pl-2 my-1 text-gb-fg-s" />,
h1: ({ ...props }) => <h1 {...props} className="text-lg font-bold my-1" />,
h2: ({ ...props }) => <h2 {...props} className="text-base font-bold my-1" />,
h3: ({ ...props }) => <h3 {...props} className="text-sm font-bold my-0.5" />,
p: ({ ...props }) => <span {...props} className="inline" />,
img: ({ src, alt, ...props }) => (
<ExpandableImage src={src} alt={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<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const lastTypingRef = useRef<number>(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})`;
+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);