feat: implement message read tracking and enhance UI components

- Added `useTeamMessagesRead` hook to manage read state of messages within a team, utilizing local storage for persistence.
- Introduced `toMessageKey` utility for generating stable keys for messages based on their properties.
- Enhanced `CollapsibleTeamSection` to display a secondary badge for unread message counts.
- Updated `TeamDetailView` to calculate and pass unread message counts to `CollapsibleTeamSection`.
- Implemented message visibility tracking in `ActivityTimeline` to mark messages as read when they enter the viewport.
This commit is contained in:
iliya 2026-02-23 15:02:33 +02:00
parent 41717c5c7e
commit 40beaf20d9
6 changed files with 181 additions and 2 deletions

View file

@ -6,6 +6,8 @@ import { ChevronRight } from 'lucide-react';
interface CollapsibleTeamSectionProps {
title: string;
badge?: string | number;
/** Secondary badge (e.g. unread count). Shown next to main badge when defined. */
secondaryBadge?: number;
defaultOpen?: boolean;
forceOpen?: boolean;
action?: React.ReactNode;
@ -15,6 +17,7 @@ interface CollapsibleTeamSectionProps {
export const CollapsibleTeamSection = ({
title,
badge,
secondaryBadge,
defaultOpen = true,
forceOpen,
action,
@ -44,6 +47,19 @@ export const CollapsibleTeamSection = ({
{badge}
</Badge>
)}
{secondaryBadge != null && secondaryBadge >= 0 && (
<Badge
variant="secondary"
className={
secondaryBadge > 0
? 'bg-blue-500/20 px-1.5 py-0.5 text-[10px] font-normal leading-none text-blue-400'
: 'px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]'
}
title={secondaryBadge > 0 ? `${secondaryBadge} unread` : undefined}
>
{secondaryBadge}
</Badge>
)}
</button>
{action && <div className="shrink-0">{action}</div>}
</div>

View file

@ -3,9 +3,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { MessageSquare, Pencil, Play, Plus, Search, Trash2, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -287,6 +289,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return list;
}, [data, timeWindow, messagesFilter, messagesSearchQuery]);
const { readSet, markRead } = useTeamMessagesRead(teamName ?? '');
const messagesUnreadCount = useMemo(
() => filteredMessages.filter((m) => !readSet.has(toMessageKey(m))).length,
[filteredMessages, readSet]
);
const kanbanDisplayTasks = useMemo(() => {
const query = kanbanSearch.trim();
if (!query) return filteredTasks;
@ -623,6 +631,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
<CollapsibleTeamSection
title="Messages"
badge={filteredMessages.length}
secondaryBadge={
filteredMessages.length > 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined
}
defaultOpen
action={
<div className="flex items-center gap-2 pl-2">
@ -680,6 +691,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
setReplyQuote({ from: message.from, text: message.text });
setSendDialogOpen(true);
}}
onMessageVisible={(message) => markRead(toMessageKey(message))}
/>
</CollapsibleTeamSection>

View file

@ -1,3 +1,5 @@
import { useCallback, useEffect, useRef } from 'react';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { ActivityItem } from './ActivityItem';
@ -10,14 +12,80 @@ interface ActivityTimelineProps {
onCreateTaskFromMessage?: (subject: string, description: string) => void;
onReplyToMessage?: (message: InboxMessage) => void;
onMemberClick?: (member: ResolvedTeamMember) => void;
/** Called when a message enters the viewport (for marking as read). */
onMessageVisible?: (message: InboxMessage) => void;
}
const VIEWPORT_THRESHOLD = 0.15;
const MessageRowWithObserver = ({
message,
memberRole,
memberColor,
recipientColor,
onMemberNameClick,
onCreateTask,
onReply,
onVisible,
}: {
message: InboxMessage;
memberRole?: string;
memberColor?: string;
recipientColor?: string;
onMemberNameClick?: (name: string) => void;
onCreateTask?: (subject: string, description: string) => void;
onReply?: (message: InboxMessage) => void;
onVisible?: (message: InboxMessage) => void;
}): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null);
const reportedRef = useRef(false);
const handleIntersect = useCallback(
(entry: IntersectionObserverEntry) => {
if (!entry.isIntersecting || !onVisible) return;
if (reportedRef.current) return;
reportedRef.current = true;
onVisible(message);
},
[message, onVisible]
);
useEffect(() => {
if (!onVisible) return;
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry) handleIntersect(entry);
},
{ threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
);
observer.observe(el);
return () => observer.disconnect();
}, [onVisible, handleIntersect]);
return (
<div ref={ref} className="min-h-px">
<ActivityItem
message={message}
memberRole={memberRole}
memberColor={memberColor}
recipientColor={recipientColor}
onMemberNameClick={onMemberNameClick}
onCreateTask={onCreateTask}
onReply={onReply}
/>
</div>
);
};
export const ActivityTimeline = ({
messages,
members,
onCreateTaskFromMessage,
onReplyToMessage,
onMemberClick,
onMessageVisible,
}: ActivityTimelineProps): React.JSX.Element => {
const memberInfo = new Map<string, { role?: string; color?: string }>();
if (members) {
@ -54,9 +122,10 @@ export const ActivityTimeline = ({
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
const recipientColor =
recipientInfo?.color ?? (message.to ? getMemberColorByName(message.to) : undefined);
const messageKey = `${message.messageId ?? index}-${message.timestamp}-${message.from}`;
return (
<ActivityItem
key={`${message.messageId ?? index}-${message.timestamp}-${message.from}`}
<MessageRowWithObserver
key={messageKey}
message={message}
memberRole={info?.role}
memberColor={info?.color}
@ -64,6 +133,7 @@ export const ActivityTimeline = ({
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
onCreateTask={onCreateTaskFromMessage}
onReply={onReplyToMessage}
onVisible={onMessageVisible}
/>
);
})}

View file

@ -0,0 +1,39 @@
import { useCallback, useEffect, useState } from 'react';
import {
getReadSet as getReadSetStorage,
markRead as markReadStorage,
} from '@renderer/utils/teamMessageReadStorage';
export function useTeamMessagesRead(teamName: string): {
readSet: Set<string>;
markRead: (messageKey: string) => void;
} {
const [readSet, setReadSet] = useState<Set<string>>(() =>
teamName ? getReadSetStorage(teamName) : new Set()
);
useEffect(() => {
if (!teamName) {
setReadSet(new Set());
return;
}
setReadSet(getReadSetStorage(teamName));
}, [teamName]);
const markRead = useCallback(
(messageKey: string) => {
if (!teamName) return;
setReadSet((prev) => {
if (prev.has(messageKey)) return prev;
const next = new Set(prev);
next.add(messageKey);
markReadStorage(teamName, messageKey);
return next;
});
},
[teamName]
);
return { readSet, markRead };
}

View file

@ -0,0 +1,14 @@
import type { InboxMessage } from '@shared/types';
const FALLBACK_SLICE = 80;
/**
* Stable key for a team message. Prefer messageId; otherwise build from timestamp, from, and text.
*/
export function toMessageKey(message: InboxMessage): string {
if (typeof message.messageId === 'string' && message.messageId.trim().length > 0) {
return message.messageId;
}
const text = (message.text ?? '').slice(0, FALLBACK_SLICE);
return `${message.timestamp}-${message.from}-${text}`;
}

View file

@ -0,0 +1,28 @@
const STORAGE_PREFIX = 'team-messages-read:';
function storageKey(teamName: string): string {
return `${STORAGE_PREFIX}${teamName}`;
}
export function getReadSet(teamName: string): Set<string> {
try {
const raw = localStorage.getItem(storageKey(teamName));
if (!raw) return new Set();
const arr = JSON.parse(raw) as unknown;
if (!Array.isArray(arr)) return new Set();
return new Set(arr.filter((x): x is string => typeof x === 'string'));
} catch {
return new Set();
}
}
export function markRead(teamName: string, messageKey: string): void {
const set = getReadSet(teamName);
if (set.has(messageKey)) return;
set.add(messageKey);
try {
localStorage.setItem(storageKey(teamName), JSON.stringify([...set]));
} catch {
// quota or disabled
}
}