@reviewflow/ui 0.0.0-stage → 6.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +95 -2
  3. package/package.json +102 -4
  4. package/src/board/bucket-section.tsx +94 -0
  5. package/src/board/dataSource.ts +44 -0
  6. package/src/board/facts.ts +183 -0
  7. package/src/board/index.ts +12 -0
  8. package/src/board/pull-request-board.tsx +239 -0
  9. package/src/board/pull-request-row.tsx +181 -0
  10. package/src/components/branch-diff/diff-themes.ts +6 -0
  11. package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
  12. package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
  13. package/src/components/branch-diff/diff-worker.ts +7 -0
  14. package/src/components/branch-diff/use-collapsed-files.ts +49 -0
  15. package/src/components/branch-diff/use-diff-settings.tsx +97 -0
  16. package/src/components/branch-diff/whitespace-button.tsx +32 -0
  17. package/src/components/copyable-code-block.tsx +101 -0
  18. package/src/components/label-pill.tsx +84 -0
  19. package/src/components/link-in-row.ts +6 -0
  20. package/src/components/markdown-suggestion.tsx +60 -0
  21. package/src/components/markdown-text.tsx +1067 -0
  22. package/src/components/pull-request-actions-merge.tsx +341 -0
  23. package/src/components/pull-request-actions-review.tsx +218 -0
  24. package/src/components/pull-request-checks.tsx +86 -0
  25. package/src/components/pull-request-commit-files.ts +143 -0
  26. package/src/components/pull-request-commits.tsx +221 -0
  27. package/src/components/pull-request-conversation-card.tsx +220 -0
  28. package/src/components/pull-request-conversation-edit.tsx +147 -0
  29. package/src/components/pull-request-conversation-event.tsx +267 -0
  30. package/src/components/pull-request-conversation-person.tsx +28 -0
  31. package/src/components/pull-request-conversation-thread.tsx +158 -0
  32. package/src/components/pull-request-diff-annotations.tsx +443 -0
  33. package/src/components/pull-request-diff-collapse.ts +43 -0
  34. package/src/components/pull-request-diff-data.ts +174 -0
  35. package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
  36. package/src/components/pull-request-diff-impacts.ts +45 -0
  37. package/src/components/pull-request-diff-jump.ts +87 -0
  38. package/src/components/pull-request-diff-lines.ts +103 -0
  39. package/src/components/pull-request-diff-overview.tsx +416 -0
  40. package/src/components/pull-request-diff-view.tsx +438 -0
  41. package/src/components/pull-request-diff-viewer.tsx +1374 -0
  42. package/src/components/pull-request-files.tsx +533 -0
  43. package/src/components/pull-request-markdown-input.tsx +91 -0
  44. package/src/components/pull-request-panel.tsx +1270 -0
  45. package/src/components/pull-request-part-notice.tsx +82 -0
  46. package/src/components/pull-request-reactions.tsx +178 -0
  47. package/src/components/pull-request-review.tsx +164 -0
  48. package/src/components/pull-request-revisions.tsx +273 -0
  49. package/src/components/pull-request-screen.tsx +299 -0
  50. package/src/components/pull-request-suggestion-actions.tsx +201 -0
  51. package/src/components/pull-request-timeline.tsx +270 -0
  52. package/src/components/repository-attachment-banner.tsx +42 -0
  53. package/src/components/scroll-parent.ts +50 -0
  54. package/src/css.d.ts +1 -0
  55. package/src/dataSource.ts +242 -0
  56. package/src/editor/alerts.ts +105 -0
  57. package/src/editor/code-editor.native.tsx +25 -0
  58. package/src/editor/code-editor.tsx +112 -0
  59. package/src/editor/codeEditorExtensions.ts +65 -0
  60. package/src/editor/codeHighlight.ts +71 -0
  61. package/src/editor/commands.ts +175 -0
  62. package/src/editor/decorations.ts +425 -0
  63. package/src/editor/editable-file.tsx +224 -0
  64. package/src/editor/emojiCompletion.ts +110 -0
  65. package/src/editor/extensions.ts +149 -0
  66. package/src/editor/frontmatter.ts +76 -0
  67. package/src/editor/inlineFormat.ts +114 -0
  68. package/src/editor/lineTypes.ts +55 -0
  69. package/src/editor/markdown-editor.css +511 -0
  70. package/src/editor/markdown-editor.native.tsx +29 -0
  71. package/src/editor/markdown-editor.tsx +295 -0
  72. package/src/editor/nodeClasses.ts +69 -0
  73. package/src/index.ts +75 -0
  74. package/src/markdown-github/flatten.ts +49 -0
  75. package/src/markdown-github/html-tokens.ts +93 -0
  76. package/src/markdown-github/pair-html.ts +354 -0
  77. package/src/markdown-github/safe-url.ts +15 -0
  78. package/src/markdown-github/table-columns.ts +23 -0
  79. package/src/markdown-github/toggle-task.ts +164 -0
  80. package/src/nativewind-env.d.ts +1 -0
  81. package/src/prStatus.ts +90 -0
  82. package/src/services/PullRequestDraftsProvider.tsx +179 -0
  83. package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
  84. package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
  85. package/src/services/emojify.ts +13 -0
  86. package/src/services/errorToMessage.ts +6 -0
  87. package/src/services/expiringDraftStore.ts +102 -0
  88. package/src/services/formatDataAge.ts +12 -0
  89. package/src/services/formatRelativeDate.ts +56 -0
  90. package/src/services/githubImages.tsx +33 -0
  91. package/src/services/isViewerAuthor.ts +7 -0
  92. package/src/slots.tsx +34 -0
@@ -0,0 +1,179 @@
1
+ import type { PullRequestRef } from "@reviewflow/sdk";
2
+ import type { ReactNode } from "react";
3
+ import {
4
+ createContext,
5
+ use,
6
+ useMemo,
7
+ useState,
8
+ useSyncExternalStore,
9
+ } from "react";
10
+
11
+ /**
12
+ * Where what is being written on a pull request is kept between two visits.
13
+ * Read synchronously, so an editor opens on its draft rather than flashing
14
+ * empty: an app without synchronous storage passes none, and drafts then live
15
+ * as long as the screen.
16
+ */
17
+ export interface DraftStore {
18
+ /** how long a draft is kept after its last change, told to the reader */
19
+ retentionDays: number;
20
+ /** undefined when nothing is kept under `key`, or it expired */
21
+ read: (key: string) => unknown;
22
+ write: (key: string, value: unknown) => void;
23
+ remove: (key: string) => void;
24
+ }
25
+
26
+ /**
27
+ * What every editor of the screen holds, blank and unchanged texts included,
28
+ * outside the editors themselves: an editor remounted (its diff annotation
29
+ * moved to another slot) opens on what was typed, and two editors of one key
30
+ * (a thread's reply box in both tabs) show the same text.
31
+ */
32
+ interface DraftMemory {
33
+ get: (key: string) => string | undefined;
34
+ set: (key: string, text: string) => void;
35
+ delete: (key: string) => void;
36
+ subscribe: (key: string, listener: () => void) => () => void;
37
+ }
38
+
39
+ const createDraftMemory = (): DraftMemory => {
40
+ const texts = new Map<string, string>();
41
+ const listeners = new Map<string, Set<() => void>>();
42
+ const notify = (key: string): void => {
43
+ for (const listener of listeners.get(key) ?? []) listener();
44
+ };
45
+ return {
46
+ get: (key) => texts.get(key),
47
+ set: (key, text) => {
48
+ texts.set(key, text);
49
+ notify(key);
50
+ },
51
+ delete: (key) => {
52
+ if (texts.delete(key)) notify(key);
53
+ },
54
+ subscribe: (key, listener) => {
55
+ const ofKey = listeners.get(key) ?? new Set();
56
+ ofKey.add(listener);
57
+ listeners.set(key, ofKey);
58
+ return () => {
59
+ ofKey.delete(listener);
60
+ };
61
+ },
62
+ };
63
+ };
64
+
65
+ interface DraftsContextValue {
66
+ /** the store, its keys scoped to the pull request of the provider */
67
+ store: DraftStore | null;
68
+ memory: DraftMemory;
69
+ }
70
+
71
+ const DraftsContext = createContext<DraftsContextValue | null>(null);
72
+
73
+ interface PullRequestDraftsProviderProps {
74
+ store: DraftStore | undefined;
75
+ pullRequest: PullRequestRef;
76
+ children: ReactNode;
77
+ }
78
+
79
+ export function PullRequestDraftsProvider({
80
+ store,
81
+ pullRequest,
82
+ children,
83
+ }: PullRequestDraftsProviderProps): ReactNode {
84
+ const scope = `${pullRequest.ownerRepo}#${String(pullRequest.number)}`;
85
+ const value = useMemo<DraftsContextValue>(
86
+ () => ({
87
+ store:
88
+ store === undefined
89
+ ? null
90
+ : {
91
+ retentionDays: store.retentionDays,
92
+ read: (key) => store.read(`${scope}:${key}`),
93
+ write: (key, text) => {
94
+ store.write(`${scope}:${key}`, text);
95
+ },
96
+ remove: (key) => {
97
+ store.remove(`${scope}:${key}`);
98
+ },
99
+ },
100
+ memory: createDraftMemory(),
101
+ }),
102
+ [store, scope],
103
+ );
104
+ return <DraftsContext value={value}>{children}</DraftsContext>;
105
+ }
106
+
107
+ /** without a provider (a story), the editor keeps its own memory */
108
+ const useDraftsContext = (): DraftsContextValue => {
109
+ const context = use(DraftsContext);
110
+ // oxlint-disable-next-line react/hook-use-state -- created once, never set
111
+ const [own] = useState((): DraftsContextValue => ({
112
+ store: null,
113
+ memory: createDraftMemory(),
114
+ }));
115
+ return context ?? own;
116
+ };
117
+
118
+ /** null when the app keeps no draft beyond the screen */
119
+ export const usePullRequestDrafts = (): DraftStore | null =>
120
+ useDraftsContext().store;
121
+
122
+ const readKept = (
123
+ { store, memory }: DraftsContextValue,
124
+ key: string,
125
+ ): string | undefined => {
126
+ const held = memory.get(key);
127
+ if (held !== undefined) return held;
128
+ const stored = store?.read(key);
129
+ return typeof stored === "string" ? stored : undefined;
130
+ };
131
+
132
+ /** Whether a text draft is kept under `key`, as of the mount. */
133
+ export const useHasDraftText = (key: string): boolean => {
134
+ const context = useDraftsContext();
135
+ // oxlint-disable-next-line react/hook-use-state -- read once, at mount, never set
136
+ const [exists] = useState(() => readKept(context, key) !== undefined);
137
+ return exists;
138
+ };
139
+
140
+ export interface DraftText {
141
+ text: string;
142
+ setText: (text: string) => void;
143
+ /**
144
+ * Back to `initial`, and forgets the kept draft: on cancel, or on send with
145
+ * the text sent, kept instead when more was typed while it went out.
146
+ */
147
+ discard: (sent?: string) => void;
148
+ }
149
+
150
+ /**
151
+ * The text of one editor, kept under `key` while it differs from `initial`
152
+ * and is not blank. `initial` is read at each render: once discarded, the
153
+ * editor shows the latest one.
154
+ */
155
+ export const useDraftText = (key: string, initial = ""): DraftText => {
156
+ const context = useDraftsContext();
157
+ const { store, memory } = context;
158
+ const kept = useSyncExternalStore(
159
+ (listener) => memory.subscribe(key, listener),
160
+ () => readKept(context, key),
161
+ );
162
+
163
+ return {
164
+ text: kept ?? initial,
165
+ setText: (next) => {
166
+ memory.set(key, next);
167
+ if (store === null) return;
168
+ if (next === initial || next.trim() === "") store.remove(key);
169
+ else store.write(key, next);
170
+ },
171
+ discard: (sent) => {
172
+ if (sent !== undefined && (readKept(context, key) ?? initial) !== sent) {
173
+ return;
174
+ }
175
+ memory.delete(key);
176
+ store?.remove(key);
177
+ },
178
+ };
179
+ };
@@ -0,0 +1,178 @@
1
+ import type { PullRequestDraftComment } from "@reviewflow/sdk";
2
+ import type { ReactNode } from "react";
3
+ import { createContext, use, useMemo, useRef, useState } from "react";
4
+ import type { Composer } from "../components/pull-request-diff-lines.ts";
5
+ import type { PullRequestShas } from "../dataSource.ts";
6
+ import { usePullRequestDrafts } from "./PullRequestDraftsProvider.tsx";
7
+
8
+ export interface PendingComment extends PullRequestDraftComment {
9
+ /** local only, so a draft can be edited or dropped before the review is sent */
10
+ key: string;
11
+ }
12
+
13
+ interface PullRequestReviewDraft {
14
+ /** the comments that go with the next review, in the order they were written */
15
+ comments: PendingComment[];
16
+ add: (comment: PullRequestDraftComment) => void;
17
+ update: (key: string, body: string) => void;
18
+ remove: (key: string) => void;
19
+ /** after the review is sent */
20
+ clear: () => void;
21
+ /** the comment being written on the pull request's diff */
22
+ composer: Composer | null;
23
+ openComposer: (composer: Composer) => void;
24
+ closeComposer: () => void;
25
+ /** the range the diff shows and a review is sent on, null until known */
26
+ shas: PullRequestShas | null;
27
+ /** the pull request's own range, which `shas` lags once a push landed */
28
+ latest: PullRequestShas | null;
29
+ /** shows `latest`; null while a review is in progress */
30
+ follow: (() => void) | null;
31
+ }
32
+
33
+ const ReviewDraftContext = createContext<PullRequestReviewDraft | null>(null);
34
+
35
+ interface PullRequestReviewDraftProviderProps {
36
+ /** the pull request's range as last read */
37
+ latest: PullRequestShas | null;
38
+ children: ReactNode;
39
+ }
40
+
41
+ interface ReviewInProgress {
42
+ comments: PendingComment[];
43
+ composer: Composer | null;
44
+ }
45
+
46
+ /** What is kept of a review in progress between two visits. */
47
+ interface StoredReview extends ReviewInProgress {
48
+ pinned: PullRequestShas;
49
+ }
50
+
51
+ const storedReviewKey = "review";
52
+
53
+ const isStoredReview = (value: unknown): value is StoredReview =>
54
+ typeof value === "object" &&
55
+ value !== null &&
56
+ Array.isArray((value as StoredReview).comments) &&
57
+ typeof (value as StoredReview).pinned?.head === "string";
58
+
59
+ /**
60
+ * The review being written on one pull request: comments added from the diff,
61
+ * sent together with the verdict from the header. Kept by the app's
62
+ * `DraftStore` when it has one, with the range it was written on, so a
63
+ * reload reopens it as it was; in memory otherwise.
64
+ * Mounted per pull request, so drafts never leak from one to the next.
65
+ *
66
+ * The range is pinned to the first one known and moves only on `follow`: a
67
+ * push swapping the diff under the reader would change the code being read
68
+ * without saying so, move a pending comment's line onto other code, and drop a
69
+ * comment being typed whose line left the diff. `follow` is withheld while a
70
+ * comment is pending or being written, so the comments always go out on the
71
+ * commit they were written on.
72
+ */
73
+ export function PullRequestReviewDraftProvider({
74
+ latest,
75
+ children,
76
+ }: PullRequestReviewDraftProviderProps): ReactNode {
77
+ const drafts = usePullRequestDrafts();
78
+ // oxlint-disable-next-line react/hook-use-state -- read once, at mount, never set
79
+ const [restored] = useState(() => {
80
+ const stored = drafts?.read(storedReviewKey);
81
+ return isStoredReview(stored) ? stored : null;
82
+ });
83
+ const [review, setReview] = useState<ReviewInProgress>({
84
+ comments: restored?.comments ?? [],
85
+ composer: restored?.composer ?? null,
86
+ });
87
+ // one press can make two changes ("Start a review" adds, then closes the
88
+ // composer): each builds on the one before, not on the last render
89
+ const current = useRef(review);
90
+ const [pinned, setPinned] = useState<PullRequestShas | null>(
91
+ restored?.pinned ?? latest,
92
+ );
93
+ if (pinned === null && latest !== null) setPinned(latest);
94
+ const { comments, composer } = review;
95
+ const inProgress = comments.length > 0 || composer !== null;
96
+
97
+ const draft = useMemo<PullRequestReviewDraft>(() => {
98
+ const change = (
99
+ edit: (previous: ReviewInProgress) => ReviewInProgress,
100
+ ): void => {
101
+ const next = edit(current.current);
102
+ current.current = next;
103
+ setReview(next);
104
+ const range = pinned ?? latest;
105
+ if (drafts === null) return;
106
+ if (
107
+ range === null ||
108
+ (next.comments.length === 0 && next.composer === null)
109
+ ) {
110
+ drafts.remove(storedReviewKey);
111
+ return;
112
+ }
113
+ drafts.write(storedReviewKey, {
114
+ ...next,
115
+ pinned: range,
116
+ } satisfies StoredReview);
117
+ };
118
+
119
+ return {
120
+ comments,
121
+ add: (comment) => {
122
+ change((previous) => ({
123
+ ...previous,
124
+ comments: [
125
+ ...previous.comments,
126
+ {
127
+ ...comment,
128
+ key: `${String(Date.now())}-${String(previous.comments.length)}`,
129
+ },
130
+ ],
131
+ }));
132
+ },
133
+ update: (key, body) => {
134
+ change((previous) => ({
135
+ ...previous,
136
+ comments: previous.comments.map((comment) =>
137
+ comment.key === key ? { ...comment, body } : comment,
138
+ ),
139
+ }));
140
+ },
141
+ remove: (key) => {
142
+ change((previous) => ({
143
+ ...previous,
144
+ comments: previous.comments.filter((comment) => comment.key !== key),
145
+ }));
146
+ },
147
+ clear: () => {
148
+ change((previous) => ({ ...previous, comments: [] }));
149
+ },
150
+ composer,
151
+ openComposer: (next) => {
152
+ change((previous) => ({ ...previous, composer: next }));
153
+ },
154
+ closeComposer: () => {
155
+ change((previous) => ({ ...previous, composer: null }));
156
+ },
157
+ shas: pinned ?? latest,
158
+ latest,
159
+ follow: inProgress
160
+ ? null
161
+ : () => {
162
+ setPinned(latest);
163
+ },
164
+ };
165
+ }, [comments, composer, pinned, latest, inProgress, drafts]);
166
+
167
+ return <ReviewDraftContext value={draft}>{children}</ReviewDraftContext>;
168
+ }
169
+
170
+ export const usePullRequestReviewDraft = (): PullRequestReviewDraft => {
171
+ const draft = use(ReviewDraftContext);
172
+ if (draft === null) {
173
+ throw new Error(
174
+ "usePullRequestReviewDraft needs a PullRequestReviewDraftProvider",
175
+ );
176
+ }
177
+ return draft;
178
+ };
@@ -0,0 +1,112 @@
1
+ import type {
2
+ PullRequestDetail,
3
+ PullRequestEdit,
4
+ PullRequestSuggestionRef,
5
+ SuggestionRange,
6
+ } from "@reviewflow/sdk";
7
+ import { View, WarningMessage } from "alouette";
8
+ import type { ReactNode } from "react";
9
+ import { createContext, use, useMemo, useState } from "react";
10
+ import { usePullRequestDataSource } from "../dataSource.ts";
11
+
12
+ export type BatchedSuggestion = PullRequestSuggestionRef & SuggestionRange;
13
+
14
+ export interface SuggestionCommitMessage {
15
+ headline: string;
16
+ body: string;
17
+ }
18
+
19
+ interface PullRequestSuggestionBatch {
20
+ pullRequest: PullRequestDetail;
21
+ batch: BatchedSuggestion[];
22
+ add: (suggestion: BatchedSuggestion) => void;
23
+ remove: (commentId: string) => void;
24
+ /** one commit for `suggestions`, then the batch is emptied */
25
+ commit: (
26
+ suggestions: PullRequestSuggestionRef[],
27
+ message: SuggestionCommitMessage,
28
+ ) => Promise<void>;
29
+ }
30
+
31
+ const SuggestionBatchContext = createContext<PullRequestSuggestionBatch | null>(
32
+ null,
33
+ );
34
+
35
+ interface PullRequestSuggestionBatchProviderProps {
36
+ pullRequest: PullRequestDetail;
37
+ onEdited: (edit: PullRequestEdit) => Promise<void>;
38
+ children: ReactNode;
39
+ }
40
+
41
+ /**
42
+ * The suggestions gathered to be committed together, as GitHub's "Add
43
+ * suggestion to batch". In memory, per pull request: a reload drops the
44
+ * batch, which commits nothing until pressed.
45
+ *
46
+ * The commit lands even when resolving its threads afterwards fails; that
47
+ * failure is shown here, above the tabs, since the button that committed may
48
+ * be gone with its thread.
49
+ */
50
+ export function PullRequestSuggestionBatchProvider({
51
+ pullRequest,
52
+ onEdited,
53
+ children,
54
+ }: PullRequestSuggestionBatchProviderProps): ReactNode {
55
+ const [batch, setBatch] = useState<BatchedSuggestion[]>([]);
56
+ const [followUpError, setFollowUpError] = useState<string | null>(null);
57
+ const { edit: writeEdit } = usePullRequestDataSource();
58
+
59
+ const value = useMemo<PullRequestSuggestionBatch>(
60
+ () => ({
61
+ pullRequest,
62
+ batch,
63
+ add: (suggestion) => {
64
+ setBatch((previous) => [...previous, suggestion]);
65
+ },
66
+ remove: (commentId) => {
67
+ setBatch((previous) =>
68
+ previous.filter((suggestion) => suggestion.commentId !== commentId),
69
+ );
70
+ },
71
+ commit: async (suggestions, { headline, body }) => {
72
+ const edit: PullRequestEdit = {
73
+ kind: "applySuggestions",
74
+ expectedHeadSha: pullRequest.headSha,
75
+ suggestions: suggestions.map(({ threadId, commentId }) => ({
76
+ threadId,
77
+ commentId,
78
+ })),
79
+ headline,
80
+ body,
81
+ };
82
+ const response = await writeEdit(pullRequest, edit);
83
+ setBatch([]);
84
+ setFollowUpError(response.followUpError);
85
+ await onEdited(edit);
86
+ },
87
+ }),
88
+ [pullRequest, batch, onEdited, writeEdit],
89
+ );
90
+
91
+ return (
92
+ <SuggestionBatchContext value={value}>
93
+ <View className="gap-m">
94
+ {followUpError === null ? null : (
95
+ <WarningMessage
96
+ dismissIconAriaLabel="Dismiss"
97
+ onDismiss={() => {
98
+ setFollowUpError(null);
99
+ }}
100
+ >
101
+ {followUpError}
102
+ </WarningMessage>
103
+ )}
104
+ {children}
105
+ </View>
106
+ </SuggestionBatchContext>
107
+ );
108
+ }
109
+
110
+ /** Null outside a pull request screen: its suggestions are not committable there. */
111
+ export const useSuggestionBatch = (): PullRequestSuggestionBatch | null =>
112
+ use(SuggestionBatchContext);
@@ -0,0 +1,13 @@
1
+ import { nameToEmoji } from "gemoji";
2
+
3
+ const shortcode = /:([\w+-]+):/g;
4
+
5
+ /**
6
+ * GitHub's `:green_heart:` shortcodes as the emoji GitHub renders them as, with
7
+ * the same table reviewflow reads (`gemoji`). An unknown name stays as typed.
8
+ */
9
+ export const emojify = (text: string): string =>
10
+ text.replaceAll(
11
+ shortcode,
12
+ (match, name: string) => nameToEmoji[name] ?? match,
13
+ );
@@ -0,0 +1,6 @@
1
+ import { FormValidationError } from "alouette";
2
+
3
+ export const errorToMessage = (error: unknown): string => {
4
+ if (error instanceof FormValidationError) return "Fix the errors above.";
5
+ return error instanceof Error ? error.message : String(error);
6
+ };
@@ -0,0 +1,102 @@
1
+ import type { DraftStore } from "./PullRequestDraftsProvider.tsx";
2
+
3
+ /** The part of the web `Storage` the store needs, so a test can pass a map. */
4
+ export interface DraftStorage {
5
+ readonly length: number;
6
+ key: (index: number) => string | null;
7
+ getItem: (key: string) => string | null;
8
+ setItem: (key: string, value: string) => void;
9
+ removeItem: (key: string) => void;
10
+ }
11
+
12
+ interface StoredDraft {
13
+ savedAt: number;
14
+ value: unknown;
15
+ }
16
+
17
+ const prefix = "reviewflow:draft:";
18
+ const dayMs = 24 * 60 * 60 * 1000;
19
+
20
+ const parse = (raw: string | null): StoredDraft | null => {
21
+ if (raw === null) return null;
22
+ try {
23
+ const parsed: unknown = JSON.parse(raw);
24
+ return typeof parsed === "object" &&
25
+ parsed !== null &&
26
+ typeof (parsed as StoredDraft).savedAt === "number"
27
+ ? (parsed as StoredDraft)
28
+ : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ };
33
+
34
+ export interface ExpiringDraftStoreOptions {
35
+ storage: DraftStorage;
36
+ retentionDays: number;
37
+ now?: () => number;
38
+ }
39
+
40
+ /**
41
+ * A {@link DraftStore} over a synchronous key-value storage, each draft
42
+ * dropped `retentionDays` after its last write. Expired drafts are swept when
43
+ * the store is created, so a pull request never opened again does not keep
44
+ * its drafts forever. Every access is guarded: storage that throws (full,
45
+ * blocked site data) loses the draft, never the editor.
46
+ */
47
+ export const createExpiringDraftStore = ({
48
+ storage,
49
+ retentionDays,
50
+ now = Date.now,
51
+ }: ExpiringDraftStoreOptions): DraftStore => {
52
+ const isExpired = (draft: StoredDraft): boolean =>
53
+ now() - draft.savedAt > retentionDays * dayMs;
54
+
55
+ try {
56
+ const keys = Array.from({ length: storage.length }, (_, index) =>
57
+ storage.key(index),
58
+ ).filter((key) => key?.startsWith(prefix) === true) as string[];
59
+ for (const key of keys) {
60
+ const draft = parse(storage.getItem(key));
61
+ if (draft === null || isExpired(draft)) storage.removeItem(key);
62
+ }
63
+ } catch {
64
+ // nothing swept: each read still drops what expired
65
+ }
66
+
67
+ const remove = (key: string): void => {
68
+ try {
69
+ storage.removeItem(`${prefix}${key}`);
70
+ } catch {
71
+ // already unreadable
72
+ }
73
+ };
74
+
75
+ return {
76
+ retentionDays,
77
+ read: (key) => {
78
+ try {
79
+ const draft = parse(storage.getItem(`${prefix}${key}`));
80
+ if (draft === null) return undefined;
81
+ if (isExpired(draft)) {
82
+ remove(key);
83
+ return undefined;
84
+ }
85
+ return draft.value;
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ },
90
+ write: (key, value) => {
91
+ try {
92
+ storage.setItem(
93
+ `${prefix}${key}`,
94
+ JSON.stringify({ savedAt: now(), value } satisfies StoredDraft),
95
+ );
96
+ } catch {
97
+ // the draft lives as long as the screen
98
+ }
99
+ },
100
+ remove,
101
+ };
102
+ };
@@ -0,0 +1,12 @@
1
+ import type { IsoDateTime } from "@reviewflow/sdk";
2
+
3
+ /**
4
+ * Static on purpose: the line is read when the board is looked at, and a ticking
5
+ * re-render would cost a timer per button to say the same thing a minute later.
6
+ */
7
+ export const formatDataAge = (date: IsoDateTime): string => {
8
+ const then = new Date(date);
9
+ return then.toDateString() === new Date().toDateString()
10
+ ? then.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
11
+ : then.toLocaleString();
12
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A past date the way the operator says it: "today 14:32", "yesterday 09:10",
3
+ * "last Monday 16:05", then "12 Oct, 14:32" — and the year once it is not this
4
+ * one. The boards read dates to answer "is this from this morning or last
5
+ * week", which a full timestamp makes them work out.
6
+ *
7
+ * Calendar days in the machine's own time zone, not 24-hour spans: 23:50
8
+ * yesterday is "yesterday" at 00:10. The time and the month follow the
9
+ * locale; the words are the app's, which is in English.
10
+ */
11
+
12
+ const dayMs = 24 * 60 * 60 * 1000;
13
+
14
+ /** how many days back the weekday alone still names the day without doubt */
15
+ const weekdayReach = 6;
16
+
17
+ const startOfDay = (date: Date): number =>
18
+ new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
19
+
20
+ /** Whole calendar days from `date` to `now`; negative for a future date. */
21
+ const calendarDaysAgo = (date: Date, now: Date): number =>
22
+ Math.round((startOfDay(now) - startOfDay(date)) / dayMs);
23
+
24
+ export interface FormatRelativeDateOptions {
25
+ now?: Date;
26
+ /** a BCP 47 tag; the runtime's own when absent */
27
+ locale?: string;
28
+ }
29
+
30
+ export const formatRelativeDate = (
31
+ value: Date | string,
32
+ { now = new Date(), locale }: FormatRelativeDateOptions = {},
33
+ ): string => {
34
+ const date = typeof value === "string" ? new Date(value) : value;
35
+ if (Number.isNaN(date.getTime())) return "unknown date";
36
+
37
+ const time = date.toLocaleTimeString(locale, {
38
+ hour: "2-digit",
39
+ minute: "2-digit",
40
+ });
41
+ const days = calendarDaysAgo(date, now);
42
+
43
+ if (days === 0) return `today ${time}`;
44
+ if (days === 1) return `yesterday ${time}`;
45
+ if (days > 1 && days <= weekdayReach) {
46
+ const weekday = date.toLocaleDateString("en", { weekday: "long" });
47
+ return `last ${weekday} ${time}`;
48
+ }
49
+
50
+ const day = date.toLocaleDateString(locale, {
51
+ day: "numeric",
52
+ month: "short",
53
+ ...(date.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }),
54
+ });
55
+ return `${day}, ${time}`;
56
+ };
@@ -0,0 +1,33 @@
1
+ import type { PullRequestImageProxies } from "@reviewflow/sdk";
2
+ import { createContext, use } from "react";
3
+
4
+ /**
5
+ * The camo urls of the pull request's bodies, by the url each body wrote:
6
+ * an image is loaded through GitHub's proxy, so its host never learns who
7
+ * read the pull request nor when.
8
+ */
9
+ export const GithubImagesContext = createContext<PullRequestImageProxies>({});
10
+
11
+ const githubHost = /(?:^|\.)(?:github\.com|githubusercontent\.com)$/;
12
+
13
+ /**
14
+ * Where to load an image from: its camo url, or the image itself when GitHub
15
+ * hosts it (an attachment); null for any other host, whose image is linked
16
+ * rather than loaded.
17
+ */
18
+ export const imageSourceOf = (
19
+ src: string,
20
+ proxies: PullRequestImageProxies,
21
+ ): string | null => {
22
+ const proxied = proxies[src];
23
+ if (proxied !== undefined) return proxied;
24
+ try {
25
+ const { protocol, hostname } = new URL(src);
26
+ return protocol === "https:" && githubHost.test(hostname) ? src : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ };
31
+
32
+ export const useGithubImages = (): PullRequestImageProxies =>
33
+ use(GithubImagesContext);