1import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
2import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts";
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
5import { pullRequestEnvironment } from "~/state/pullRequests";
6import { useEnvironmentQuery } from "~/state/query";
7import { useAtomCommand } from "~/state/use-atom-command";
8
9import { toastManager } from "../ui/toast";
10import {
11 countViewedFiles,
12 isFileViewed,
13 isStaleViewedState,
14 revertFileViewedOverlay,
15 settleFileViewedOverlay,
16 toFileViewedBatch,
17 toFileViewedStates,
18 type FileViewedOverlay,
19 type FileViewedStates,
20} from "./pullRequestFilesViewed.logic";
21
22/**
23 * How long presses gather before the host is told. Long enough that ticking down a file list
24 * costs one request rather than one per file, short enough that a reader who ticks one file and
25 * closes the tab has already been recorded.
26 */
27const FLUSH_DELAY_MS = 400;
28
29const NO_OVERLAY: FileViewedOverlay = new Map();
30
31export interface PullRequestFilesViewedView {
32 /** Whether anything remembers this at all, which is what hides the whole control. */
33 readonly enabled: boolean;
34 readonly isViewed: (path: string) => boolean;
35 /** This file has been pushed to since it was cleared. */
36 readonly isStale: (path: string) => boolean;
37 readonly setViewed: (path: string, viewed: boolean) => void;
38 /** How many of the files on screen are ticked off. */
39 readonly viewedCount: number;
40 /** The host had more files than the read covered, so the count above may be short. */
41 readonly truncated: boolean;
42 /**
43 * Why the marks could not be read, when they could not. The boxes fall back to the last answer
44 * there was, or to empty when there has not been one, and neither of those says so on its own:
45 * a reader who sees every box unticked has no way to tell a fresh review from a failed read.
46 */
47 readonly error: string | null;
48 /** Re-ask the host, for the page's refresh button, which goes around the host's cache. */
49 readonly refresh: () => void;
50}
51
52/**
53 * Which files this reader has already cleared. The marks live on the server rather than in this
54 * tab, so a review carried on from another machine picks up where it was left. Presses show
55 * immediately and are held over the server's answer until a read that could have seen them comes
56 * back, so the checkbox never waits on a round trip and never outlasts the record behind it.
57 */
58export function usePullRequestFilesViewed(options: {
59 readonly environmentId: EnvironmentId;
60 readonly reference: PullRequestRef;
61 readonly enabled: boolean;
62 /** The paths on screen, which is what the counter counts. */
63 readonly paths: ReadonlyArray<string>;
64}): PullRequestFilesViewedView {
65 const { environmentId, reference, enabled, paths } = options;
66 const query = useEnvironmentQuery(
67 enabled ? pullRequestEnvironment.filesViewed({ environmentId, input: reference }) : null,
68 );
69 const refresh = query.refresh;
70 // `query.data` holds the last answer through a failure, so the boxes stay where the host last
71 // put them rather than emptying under the reader; the error travels with them, because ticks
72 // that stopped being refreshed look exactly like ticks that are current.
73 const states = useMemo(() => toFileViewedStates(query.data), [query.data]);
74 const truncated = query.data?.truncated === true;
75 const error = query.error;
76 const [overlay, setOverlay] = useState<FileViewedOverlay>(NO_OVERLAY);
77 const setFilesViewed = useAtomCommand(pullRequestEnvironment.setFilesViewed, {
78 reportFailure: false,
79 });
80
81 // Presses waiting for the next flush, and, for every path a request is already carrying, which
82 // request that is. A path pressed again while its request is out belongs to the later request
83 // from then on, and the earlier one stops answering for it. Both are refs rather than state:
84 // nothing on screen reads them, and the flush must see the latest.
85 const queued = useRef<Map<string, boolean>>(new Map());
86 const sentBy = useRef<Map<string, number>>(new Map());
87 const requests = useRef(0);
88 const flushTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
89
90 // Everything held here belongs to one change request on one environment. The environment is
91 // part of that: two of them can hand out the same project id, and a press made against one
92 // must never be answered for by the other.
93 const scopeKey = `${environmentId} ${reference.projectId} ${reference.repository} ${reference.number}`;
94 const scope = useRef(scopeKey);
95
96 // The host's answer as it stood when a completed write was acknowledged, per path. The first
97 // answer that differs from it is the first read that could have seen the write, which is what
98 // retires the press rather than the host happening to agree with it.
99 const answeredFrom = useRef<Map<string, FileViewedStates | null>>(new Map());
100 const statesRef = useRef(states);
101 statesRef.current = states;
102
103 useEffect(() => {
104 const pending = new Set([...queued.current.keys(), ...sentBy.current.keys()]);
105 const answered = new Set<string>();
106 for (const [path, from] of answeredFrom.current) {
107 if (pending.has(path) || from === states) continue;
108 answered.add(path);
109 answeredFrom.current.delete(path);
110 }
111 setOverlay((current) => settleFileViewedOverlay(current, states, pending, answered));
112 }, [states]);
113
114 const flush = useCallback(() => {
115 flushTimer.current = null;
116 const batch = toFileViewedBatch(queued.current);
117 if (batch.length === 0) return;
118 queued.current = new Map();
119 const sentFrom = scope.current;
120 const request = ++requests.current;
121 for (const file of batch) sentBy.current.set(file.path, request);
122 void setFilesViewed({ environmentId, input: { ...reference, files: batch } }).then((result) => {
123 const mine = batch
124 .map((file) => file.path)
125 .filter((path) => sentBy.current.get(path) === request);
126 for (const path of mine) sentBy.current.delete(path);
127 // The reader has moved on, and what is on screen now has nothing to do with this answer.
128 if (scope.current !== sentFrom) return;
129 if (result._tag === "Failure") {
130 // The host never heard these, so the ticks go back to whatever it last said. Only the
131 // paths this request still answers for: one pressed again since is waiting on a request
132 // of its own, or on the next flush, and that press is the one on screen.
133 const owned = new Set(mine.filter((path) => !queued.current.has(path)));
134 setOverlay((current) => revertFileViewedOverlay(current, batch, owned));
135 // Silent when nothing was still this request's to answer for, so nothing on screen went
136 // back, and when the connection went away mid-flight, which the reader is already being
137 // told about and which the host never refused.
138 if (owned.size > 0 && !isAtomCommandInterrupted(result)) {
139 toastManager.add({ type: "error", title: "Could not update viewed files" });
140 }
141 return;
142 }
143 // Answered for from the next read on, whatever it says. A push landing between the write
144 // and that read comes back as `dismissed`, and the press must not stand over it.
145 for (const path of mine) answeredFrom.current.set(path, statesRef.current);
146 refresh();
147 });
148 }, [environmentId, reference, refresh, setFilesViewed]);
149
150 // Read through a ref rather than closed over: `setViewed` is handed to every file header the
151 // viewer draws, and a new identity per render would rebuild all of them.
152 const flushRef = useRef(flush);
153 flushRef.current = flush;
154
155 // Leaving a change request, the environment it lives on, or the page itself records what was
156 // pressed and then drops the rest. The flush kept here is the one bound to the scope being
157 // left, which is what sends those last presses where they were meant to go.
158 useEffect(() => {
159 const flushScope = flushRef.current;
160 scope.current = scopeKey;
161 return () => {
162 if (flushTimer.current !== null) {
163 clearTimeout(flushTimer.current);
164 flushScope();
165 }
166 queued.current = new Map();
167 sentBy.current = new Map();
168 answeredFrom.current = new Map();
169 setOverlay(NO_OVERLAY);
170 };
171 }, [scopeKey]);
172
173 const refreshRef = useRef(refresh);
174 refreshRef.current = refresh;
175 const refreshFromHost = useCallback(() => refreshRef.current(), []);
176
177 const setViewed = useCallback((path: string, viewed: boolean) => {
178 setOverlay((current) => new Map(current).set(path, viewed));
179 queued.current.set(path, viewed);
180 if (flushTimer.current !== null) clearTimeout(flushTimer.current);
181 flushTimer.current = setTimeout(() => flushRef.current(), FLUSH_DELAY_MS);
182 }, []);
183
184 const isViewed = useCallback(
185 (path: string) => isFileViewed(path, states, overlay),
186 [overlay, states],
187 );
188 const isStale = useCallback(
189 (path: string) => !overlay.has(path) && isStaleViewedState(states?.get(path)),
190 [overlay, states],
191 );
192 const viewedCount = useMemo(
193 () => countViewedFiles(paths, states, overlay),
194 [overlay, paths, states],
195 );
196
197 // One identity per change of what it says: the viewer keys every file it draws off this.
198 return useMemo(
199 () => ({
200 enabled,
201 isViewed,
202 isStale,
203 setViewed,
204 viewedCount,
205 truncated,
206 error,
207 refresh: refreshFromHost,
208 }),
209 [enabled, error, isStale, isViewed, refreshFromHost, setViewed, truncated, viewedCount],
210 );
211}