From 40beaf20d98cba5b3b6dbfe71eb403c2d225989b Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Feb 2026 15:02:33 +0200 Subject: [PATCH] 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. --- .../team/CollapsibleTeamSection.tsx | 16 ++++ .../components/team/TeamDetailView.tsx | 12 +++ .../team/activity/ActivityTimeline.tsx | 74 ++++++++++++++++++- src/renderer/hooks/useTeamMessagesRead.ts | 39 ++++++++++ src/renderer/utils/teamMessageKey.ts | 14 ++++ src/renderer/utils/teamMessageReadStorage.ts | 28 +++++++ 6 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 src/renderer/hooks/useTeamMessagesRead.ts create mode 100644 src/renderer/utils/teamMessageKey.ts create mode 100644 src/renderer/utils/teamMessageReadStorage.ts diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 2b1f1d2b..6ed2afef 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -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} )} + {secondaryBadge != null && secondaryBadge >= 0 && ( + 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} + + )} {action &&
{action}
} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 7044239f..2dfe880c 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined + } defaultOpen action={
@@ -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))} /> diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 5cccaa04..675ad36e 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -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(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 ( +
+ +
+ ); +}; + export const ActivityTimeline = ({ messages, members, onCreateTaskFromMessage, onReplyToMessage, onMemberClick, + onMessageVisible, }: ActivityTimelineProps): React.JSX.Element => { const memberInfo = new Map(); 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 ( - ); })} diff --git a/src/renderer/hooks/useTeamMessagesRead.ts b/src/renderer/hooks/useTeamMessagesRead.ts new file mode 100644 index 00000000..7b2ec59c --- /dev/null +++ b/src/renderer/hooks/useTeamMessagesRead.ts @@ -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; + markRead: (messageKey: string) => void; +} { + const [readSet, setReadSet] = useState>(() => + 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 }; +} diff --git a/src/renderer/utils/teamMessageKey.ts b/src/renderer/utils/teamMessageKey.ts new file mode 100644 index 00000000..b1b98499 --- /dev/null +++ b/src/renderer/utils/teamMessageKey.ts @@ -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}`; +} diff --git a/src/renderer/utils/teamMessageReadStorage.ts b/src/renderer/utils/teamMessageReadStorage.ts new file mode 100644 index 00000000..a65a44ce --- /dev/null +++ b/src/renderer/utils/teamMessageReadStorage.ts @@ -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 { + 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 + } +}