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 {
|
interface CollapsibleTeamSectionProps {
|
||||||
title: string;
|
title: string;
|
||||||
badge?: string | number;
|
badge?: string | number;
|
||||||
|
/** Secondary badge (e.g. unread count). Shown next to main badge when defined. */
|
||||||
|
secondaryBadge?: number;
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
forceOpen?: boolean;
|
forceOpen?: boolean;
|
||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
|
|
@ -15,6 +17,7 @@ interface CollapsibleTeamSectionProps {
|
||||||
export const CollapsibleTeamSection = ({
|
export const CollapsibleTeamSection = ({
|
||||||
title,
|
title,
|
||||||
badge,
|
badge,
|
||||||
|
secondaryBadge,
|
||||||
defaultOpen = true,
|
defaultOpen = true,
|
||||||
forceOpen,
|
forceOpen,
|
||||||
action,
|
action,
|
||||||
|
|
@ -44,6 +47,19 @@ export const CollapsibleTeamSection = ({
|
||||||
{badge}
|
{badge}
|
||||||
</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>
|
</button>
|
||||||
{action && <div className="shrink-0">{action}</div>}
|
{action && <div className="shrink-0">{action}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
|
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
|
||||||
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { MessageSquare, Pencil, Play, Plus, Search, Trash2, X } from 'lucide-react';
|
import { MessageSquare, Pencil, Play, Plus, Search, Trash2, X } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -287,6 +289,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
return list;
|
return list;
|
||||||
}, [data, timeWindow, messagesFilter, messagesSearchQuery]);
|
}, [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 kanbanDisplayTasks = useMemo(() => {
|
||||||
const query = kanbanSearch.trim();
|
const query = kanbanSearch.trim();
|
||||||
if (!query) return filteredTasks;
|
if (!query) return filteredTasks;
|
||||||
|
|
@ -623,6 +631,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
title="Messages"
|
title="Messages"
|
||||||
badge={filteredMessages.length}
|
badge={filteredMessages.length}
|
||||||
|
secondaryBadge={
|
||||||
|
filteredMessages.length > 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined
|
||||||
|
}
|
||||||
defaultOpen
|
defaultOpen
|
||||||
action={
|
action={
|
||||||
<div className="flex items-center gap-2 pl-2">
|
<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 });
|
setReplyQuote({ from: message.from, text: message.text });
|
||||||
setSendDialogOpen(true);
|
setSendDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
|
onMessageVisible={(message) => markRead(toMessageKey(message))}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
|
|
||||||
import { ActivityItem } from './ActivityItem';
|
import { ActivityItem } from './ActivityItem';
|
||||||
|
|
@ -10,14 +12,80 @@ interface ActivityTimelineProps {
|
||||||
onCreateTaskFromMessage?: (subject: string, description: string) => void;
|
onCreateTaskFromMessage?: (subject: string, description: string) => void;
|
||||||
onReplyToMessage?: (message: InboxMessage) => void;
|
onReplyToMessage?: (message: InboxMessage) => void;
|
||||||
onMemberClick?: (member: ResolvedTeamMember) => 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 = ({
|
export const ActivityTimeline = ({
|
||||||
messages,
|
messages,
|
||||||
members,
|
members,
|
||||||
onCreateTaskFromMessage,
|
onCreateTaskFromMessage,
|
||||||
onReplyToMessage,
|
onReplyToMessage,
|
||||||
onMemberClick,
|
onMemberClick,
|
||||||
|
onMessageVisible,
|
||||||
}: ActivityTimelineProps): React.JSX.Element => {
|
}: ActivityTimelineProps): React.JSX.Element => {
|
||||||
const memberInfo = new Map<string, { role?: string; color?: string }>();
|
const memberInfo = new Map<string, { role?: string; color?: string }>();
|
||||||
if (members) {
|
if (members) {
|
||||||
|
|
@ -54,9 +122,10 @@ export const ActivityTimeline = ({
|
||||||
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
|
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
|
||||||
const recipientColor =
|
const recipientColor =
|
||||||
recipientInfo?.color ?? (message.to ? getMemberColorByName(message.to) : undefined);
|
recipientInfo?.color ?? (message.to ? getMemberColorByName(message.to) : undefined);
|
||||||
|
const messageKey = `${message.messageId ?? index}-${message.timestamp}-${message.from}`;
|
||||||
return (
|
return (
|
||||||
<ActivityItem
|
<MessageRowWithObserver
|
||||||
key={`${message.messageId ?? index}-${message.timestamp}-${message.from}`}
|
key={messageKey}
|
||||||
message={message}
|
message={message}
|
||||||
memberRole={info?.role}
|
memberRole={info?.role}
|
||||||
memberColor={info?.color}
|
memberColor={info?.color}
|
||||||
|
|
@ -64,6 +133,7 @@ export const ActivityTimeline = ({
|
||||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||||
onCreateTask={onCreateTaskFromMessage}
|
onCreateTask={onCreateTaskFromMessage}
|
||||||
onReply={onReplyToMessage}
|
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