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