| 1 | import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; |
| 2 | import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; |
| 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 4 | |
| 5 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 6 | import { useEnvironmentQuery } from "~/state/query"; |
| 7 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 8 | |
| 9 | import { toastManager } from "../ui/toast"; |
| 10 | import { |
| 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 | */ |
| 27 | const FLUSH_DELAY_MS = 400; |
| 28 | |
| 29 | const NO_OVERLAY: FileViewedOverlay = new Map(); |
| 30 | |
| 31 | export 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 | */ |
| 58 | export 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 | } |