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:
parent
41717c5c7e
commit
40beaf20d9
6 changed files with 181 additions and 2 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
39
src/renderer/hooks/useTeamMessagesRead.ts
Normal file
39
src/renderer/hooks/useTeamMessagesRead.ts
Normal 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 };
|
||||
}
|
||||
14
src/renderer/utils/teamMessageKey.ts
Normal file
14
src/renderer/utils/teamMessageKey.ts
Normal 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}`;
|
||||
}
|
||||
28
src/renderer/utils/teamMessageReadStorage.ts
Normal file
28
src/renderer/utils/teamMessageReadStorage.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue