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 { ChannelSettingsModal } from './ChannelSettingsModal.tsx';
|
||||||
import { useNotificationSettingsStore } from '../stores/notificationSettings.ts';
|
import { useNotificationSettingsStore } from '../stores/notificationSettings.ts';
|
||||||
import { useReadStatesStore } from '../stores/readStates.ts';
|
import { useReadStatesStore } from '../stores/readStates.ts';
|
||||||
|
import { useMessageStore } from '../stores/message.ts';
|
||||||
import { useContextMenu } from './ContextMenu.tsx';
|
import { useContextMenu } from './ContextMenu.tsx';
|
||||||
import { api } from '../lib/api.ts';
|
import { api } from '../lib/api.ts';
|
||||||
|
|
||||||
@@ -79,8 +80,9 @@ export function ChannelList() {
|
|||||||
const fetchNotifSettings = useNotificationSettingsStore((state) => state.fetchSettings);
|
const fetchNotifSettings = useNotificationSettingsStore((state) => state.fetchSettings);
|
||||||
const setNotifLevel = useNotificationSettingsStore((state) => state.setLevel);
|
const setNotifLevel = useNotificationSettingsStore((state) => state.setLevel);
|
||||||
|
|
||||||
const readStates = useReadStatesStore((state) => state.states);
|
|
||||||
const fetchReadStates = useReadStatesStore((state) => state.fetchStates);
|
const fetchReadStates = useReadStatesStore((state) => state.fetchStates);
|
||||||
|
const hasUnread = useReadStatesStore((state) => state.hasUnread);
|
||||||
|
const messagesByChannel = useMessageStore((state) => state.messagesByChannel);
|
||||||
|
|
||||||
const { showMenu, MenuPortal } = useContextMenu();
|
const { showMenu, MenuPortal } = useContextMenu();
|
||||||
|
|
||||||
@@ -179,8 +181,9 @@ export function ChannelList() {
|
|||||||
return level === 'mentions' ? '🔔@' : '🔔';
|
return level === 'mentions' ? '🔔@' : '🔔';
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasUnread = (channelId: string): boolean => {
|
const getLatestMessageId = (channelId: string): string | undefined => {
|
||||||
return !(channelId in readStates);
|
const msgs = messagesByChannel[channelId] || [];
|
||||||
|
return msgs.length > 0 ? msgs[msgs.length - 1].id : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Channel context menu ---
|
// --- Channel context menu ---
|
||||||
@@ -292,7 +295,7 @@ export function ChannelList() {
|
|||||||
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
|
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate flex-1">{channel.name}</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="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">
|
<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" />,
|
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" />,
|
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" />,
|
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" />,
|
p: ({ ...props }) => <span {...props} className="inline" />,
|
||||||
img: ({ src, alt, ...props }) => (
|
img: ({ src, alt, ...props }) => (
|
||||||
<ExpandableImage
|
<ExpandableImage
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useConversationStore } from "../stores/conversation.ts";
|
import { useConversationStore } from "../stores/conversation.ts";
|
||||||
import { useAuthStore } from "../stores/auth.ts";
|
import { useAuthStore } from "../stores/auth.ts";
|
||||||
|
import { useReadStatesStore } from "../stores/readStates.ts";
|
||||||
import { NewConversationModal } from "./NewConversationModal.tsx";
|
import { NewConversationModal } from "./NewConversationModal.tsx";
|
||||||
|
|
||||||
function otherMemberName(conv: { members: { id: string; username: string }[] }, currentUserId: string) {
|
function otherMemberName(conv: { members: { id: string; username: string }[] }, currentUserId: string) {
|
||||||
@@ -18,7 +19,10 @@ export function ConversationList() {
|
|||||||
const activeId = useConversationStore((s) => s.activeConversationId);
|
const activeId = useConversationStore((s) => s.activeConversationId);
|
||||||
const fetchConversations = useConversationStore((s) => s.fetchConversations);
|
const fetchConversations = useConversationStore((s) => s.fetchConversations);
|
||||||
const createConversation = useConversationStore((s) => s.createConversation);
|
const createConversation = useConversationStore((s) => s.createConversation);
|
||||||
|
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
|
const hasConvUnread = useReadStatesStore((s) => s.hasConvUnread);
|
||||||
|
const markConvRead = useReadStatesStore((s) => s.markConvRead);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [showNew, setShowNew] = useState(false);
|
const [showNew, setShowNew] = useState(false);
|
||||||
const [notesError, setNotesError] = useState<string | null>(null);
|
const [notesError, setNotesError] = useState<string | null>(null);
|
||||||
@@ -28,15 +32,19 @@ export function ConversationList() {
|
|||||||
}, [fetchConversations]);
|
}, [fetchConversations]);
|
||||||
|
|
||||||
const openConversation = (convId: string) => {
|
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}`);
|
navigate(`/dm/${convId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openNotes = async () => {
|
const openNotes = async () => {
|
||||||
setNotesError(null);
|
setNotesError(null);
|
||||||
// Find existing self-DM or create one
|
|
||||||
const existing = conversations.find((c) => isSelfDM(c, currentUser?.id || ""));
|
const existing = conversations.find((c) => isSelfDM(c, currentUser?.id || ""));
|
||||||
if (existing) {
|
if (existing) {
|
||||||
navigate(`/dm/${existing.id}`);
|
openConversation(existing.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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 (
|
return (
|
||||||
<div className="h-full w-56 bg-gb-bg-s border-r border-gb-bg-t flex flex-col">
|
<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">
|
<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.type === "group_dm"
|
||||||
? conv.name || conv.members.map((m) => m.username).join(", ")
|
? conv.name || conv.members.map((m) => m.username).join(", ")
|
||||||
: otherMemberName(conv, currentUser?.id || "");
|
: otherMemberName(conv, currentUser?.id || "");
|
||||||
|
const unread = hasConvUnread(conv.id, getLatestMessageId(conv.id));
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={conv.id}
|
key={conv.id}
|
||||||
@@ -93,7 +107,10 @@ export function ConversationList() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-gb-fg-f">{self ? "📝" : "@"}</span>
|
<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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useConversationStore, type ConversationMessage } from "../stores/conver
|
|||||||
import { useAuthStore } from "../stores/auth.ts";
|
import { useAuthStore } from "../stores/auth.ts";
|
||||||
import { useTypingStore } from "../stores/typing.ts";
|
import { useTypingStore } from "../stores/typing.ts";
|
||||||
import { useLayoutStore } from "../stores/layout.ts";
|
import { useLayoutStore } from "../stores/layout.ts";
|
||||||
|
import { useReadStatesStore } from "../stores/readStates.ts";
|
||||||
import { type Gif } from "./GiphyPicker.tsx";
|
import { type Gif } from "./GiphyPicker.tsx";
|
||||||
import { EmojiPicker as KaomojiPicker } from "./EmojiPicker.tsx";
|
import { EmojiPicker as KaomojiPicker } from "./EmojiPicker.tsx";
|
||||||
import Picker, { Theme } from 'emoji-picker-react';
|
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" />,
|
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" />,
|
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" />,
|
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" />,
|
p: ({ ...props }) => <span {...props} className="inline" />,
|
||||||
img: ({ src, alt, ...props }) => (
|
img: ({ src, alt, ...props }) => (
|
||||||
<ExpandableImage src={src} alt={alt} {...props} />
|
<ExpandableImage src={src} alt={alt} {...props} />
|
||||||
@@ -149,6 +153,7 @@ export function DMChat() {
|
|||||||
const fetchMessages = useConversationStore((s) => s.fetchMessages);
|
const fetchMessages = useConversationStore((s) => s.fetchMessages);
|
||||||
const fetchOlderMessages = useConversationStore((s) => s.fetchOlderMessages);
|
const fetchOlderMessages = useConversationStore((s) => s.fetchOlderMessages);
|
||||||
const isLoadingOlder = useConversationStore((s) => s.isLoadingOlder);
|
const isLoadingOlder = useConversationStore((s) => s.isLoadingOlder);
|
||||||
|
const isLoading = useConversationStore((s) => s.isLoading);
|
||||||
const sendMessage = useConversationStore((s) => s.sendMessage);
|
const sendMessage = useConversationStore((s) => s.sendMessage);
|
||||||
const fetchConversations = useConversationStore((s) => s.fetchConversations);
|
const fetchConversations = useConversationStore((s) => s.fetchConversations);
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
@@ -162,6 +167,7 @@ export function DMChat() {
|
|||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const lastTypingRef = useRef<number>(0);
|
const lastTypingRef = useRef<number>(0);
|
||||||
|
const markConvRead = useReadStatesStore((s) => s.markConvRead);
|
||||||
|
|
||||||
const id = conversationId || activeId;
|
const id = conversationId || activeId;
|
||||||
const conversation = conversations.find((c) => c.id === id);
|
const conversation = conversations.find((c) => c.id === id);
|
||||||
@@ -207,6 +213,14 @@ export function DMChat() {
|
|||||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||||
}, [messages]);
|
}, [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) => {
|
const handleGifSelect = async (gif: Gif) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const content = ``;
|
const content = ``;
|
||||||
|
|||||||
@@ -3,14 +3,18 @@ import { api } from '../lib/api';
|
|||||||
|
|
||||||
interface ReadStatesState {
|
interface ReadStatesState {
|
||||||
states: Record<string, string>; // channel_id -> last_read_message_id
|
states: Record<string, string>; // channel_id -> last_read_message_id
|
||||||
|
convStates: Record<string, string>; // conversation_id -> last_read_message_id
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
fetchStates: () => Promise<void>;
|
fetchStates: () => Promise<void>;
|
||||||
markRead: (channelId: string, messageId: string) => 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) => ({
|
export const useReadStatesStore = create<ReadStatesState>()((set, get) => ({
|
||||||
states: {},
|
states: {},
|
||||||
|
convStates: {},
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
|
||||||
fetchStates: async () => {
|
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;
|
const state = get().states;
|
||||||
// No read state means the user has never viewed this channel
|
const lastRead = state[channelId];
|
||||||
return !(channelId in state);
|
// 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 { useVoiceStore } from './voice.ts';
|
||||||
import { useAuthStore } from './auth.ts';
|
import { useAuthStore } from './auth.ts';
|
||||||
import { useConversationStore } from './conversation.ts';
|
import { useConversationStore } from './conversation.ts';
|
||||||
|
import { useReadStatesStore } from './readStates.ts';
|
||||||
import type { Message } from './message.ts';
|
import type { Message } from './message.ts';
|
||||||
import type { ConversationMessage } from './conversation.ts';
|
import type { ConversationMessage } from './conversation.ts';
|
||||||
import type { Channel } from './channel.ts';
|
import type { Channel } from './channel.ts';
|
||||||
@@ -132,6 +133,11 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
if (payload.conversation_id) {
|
if (payload.conversation_id) {
|
||||||
const msg = payload as unknown as ConversationMessage;
|
const msg = payload as unknown as ConversationMessage;
|
||||||
useConversationStore.getState().addMessage(msg);
|
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 {
|
} else {
|
||||||
const msg = payload as unknown as Message;
|
const msg = payload as unknown as Message;
|
||||||
addMessage(msg);
|
addMessage(msg);
|
||||||
|
|||||||
Reference in New Issue
Block a user