@@ -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
+ }
+}