import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { Eye, Loader2, LogIn, LogOut, PencilLine, ScanEye } from "lucide-react"; import { EmptyState } from "../../../components/ui/empty-state"; import { useMyHistoryService } from "../../../services/MyHistoryService.tsx"; import { HttpError } from "../../../services/HttpService.tsx"; import { hasReadableEntityName, type MyHistoryEvent } from "../../../utils/history/collapseHistory"; import { resourceLabel } from "../../../utils/history/resourceLabel"; import { describeHistoryWindow } from "../../../utils/history/describeHistoryWindow"; import { formatDateTime } from "../../../utils/dateUtils"; import { formatSessionDuration } from "../../../utils/history/formatSessionDuration"; import { forThisViewer, ownedRead } from "../../../utils/ownedRead"; import { onViewerChange, viewerEpoch } from "../../../utils/writeThroughHold"; // One page of this person's rows per look; the server filters by actor before it pages. const PAGE_SIZE = 20; // How long one look may take before the panel says it could not load. const PERSON_ACTIVITY_READ_DEADLINE_MS = 20_000; const VERBS: Record = { R: "Opened", C: "Added", U: "Changed", D: "Removed" }; // A helper's act-as session, as the server classified its row. Only an end has a duration, and only a known one // is shown: no duration is not zero, and a timeout is said only when the server recorded one. const SESSION_WORDS = { start: "Started acting for you", exit: "Stopped acting for you", timeout: "Their session acting for you timed out" } as const; const sessionLine = (event: MyHistoryEvent): string | undefined => { if (!event.accessSession) return undefined; const lasted = event.accessSession === "start" ? undefined : formatSessionDuration(event.durationMs); return lasted ? `${SESSION_WORDS[event.accessSession]} · lasted ${lasted}` : SESSION_WORDS[event.accessSession]; }; // Pages meet at an inclusive boundary (date=le), and a retry asks the same stretch again, so an answer can repeat an // event already shown. One audit event is one row: a repeat is dropped, a new event at the same moment is kept. const withoutRepeats = (shown: MyHistoryEvent[], page: MyHistoryEvent[]): MyHistoryEvent[] => { const seen = new Set(shown.map((event) => event.id).filter(Boolean)); return [...shown, ...page.filter((event) => { if (!event.id) return true; if (seen.has(event.id)) return false; seen.add(event.id); return true; })]; }; // Everything shown is stamped with the person it was read for, so another person's rows, failure // or late answer can never appear under this one. interface Found { id: string; status: "ready" | "failed" | "refused"; rows: MyHistoryEvent[]; cursor?: string; mayHaveOlder: boolean; // The server said there is more, but its cursor did not move past the one asked with: going on would read the // same stretch again, and stopping must not read as "that is all". stalled?: boolean; } export const PersonActivity = ({ relatedPersonId }: { relatedPersonId: string }) => { const { fetchMyHistory } = useMyHistoryService(); const [found, setFound] = useState(null); const [looking, setLooking] = useState(false); const [moreFailed, setMoreFailed] = useState(false); // One opening per viewer, practice and person; a change of any of them is a new look. const viewer = useSyncExternalStore(onViewerChange, viewerEpoch); const opening = relatedPersonId + "|" + viewer; const asking = useRef(opening); asking.current = opening; const reads = useRef(new Set()); // Bumped by every first look, so an older page or answer from a look since replaced is dropped. const generation = useRef(0); // Reads onward from `before`; the first look replaces what is shown, a further look adds to it. const look = useCallback(async (askedFor: string, before: string | undefined, first: boolean) => { const actor = `RelatedPerson/${askedFor}`; const scope = asking.current; if (first) { reads.current.forEach((read) => read.abort()); reads.current.clear(); generation.current += 1; } const asked = generation.current; const stillAsked = forThisViewer(() => asking.current === scope && generation.current === asked); setLooking(true); setMoreFailed(false); if (first) setFound(null); try { const response = await ownedRead((request) => fetchMyHistory(before, { agent: actor, count: PAGE_SIZE }, request), { deadlineMs: PERSON_ACTIVITY_READ_DEADLINE_MS, owns: stillAsked, track: reads.current }); if (!stillAsked()) return; // A server that ignored the filter must not put someone else's rows under this name. const rows = response.events.filter((event) => event.actorReference === actor); // Only the server's cursor moves the read on; a remainder without one is stalled, not paged from a row. const cursor = response.olderThan; const advanced = !!cursor && cursor !== before; const older = (response.mayHaveOlder ?? false) && advanced; const stalled = (response.mayHaveOlder ?? false) && !advanced; setFound((current) => ({ id: scope, status: "ready", rows: withoutRepeats(first || current?.id !== scope ? [] : current.rows, rows), cursor: advanced ? cursor : before, mayHaveOlder: older, stalled, })); } catch (error) { if (!stillAsked()) return; console.error("Failed to read this person's activity:", error); if (!first) setMoreFailed(true); else setFound({ id: scope, status: error instanceof HttpError && error.status === 403 ? "refused" : "failed", rows: [], mayHaveOlder: false }); } finally { if (stillAsked()) setLooking(false); } }, [fetchMyHistory]); useEffect(() => { const live = reads.current; void look(relatedPersonId, undefined, true); return () => { generation.current += 1; live.forEach((read) => read.abort()); live.clear(); }; }, [look, relatedPersonId, viewer]); const mine = found && found.id === opening ? found : null; if (!mine) { return (

); } if (mine.status === "refused") { return

The activity on this record is not yours to see.

; } if (mine.status === "failed") { return (
We couldn’t load their activity.
); } // The filtered feed has no 90-day floor, so with nothing older this is all of it. // A stalled read says how far it got, never that this is all of it. const reachedBack = mine.cursor ?? mine.rows[mine.rows.length - 1]?.recorded; const stalledLine = reachedBack ? `We could not read their activity before ${formatDateTime(reachedBack)}.` : "We could not read further back."; const windowLine = mine.stalled ? stalledLine : mine.mayHaveOlder ? describeHistoryWindow({ mayHaveOlder: true, olderThan: mine.cursor, oldest: mine.rows[mine.rows.length - 1]?.recorded }) : "That is all of their activity on your record."; return (
{mine.rows.length === 0 ? ( // Only after a read that answered: a stretch with nothing from them, or the whole window. ) : (
    {mine.rows.map((event, index) => { const session = sessionLine(event); const Icon = event.accessSession === "start" ? LogIn : event.accessSession ? LogOut : event.action === "R" ? Eye : PencilLine; return (
  • ); })}
)} {(mine.rows.length > 0 || mine.mayHaveOlder || mine.stalled) &&

{windowLine}

} {moreFailed &&

We couldn’t read further back. Try again.

} {mine.stalled && ( )} {mine.mayHaveOlder && ( )}
); }; export default PersonActivity;