@reviewflow/ui 6.22.2 → 6.24.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.
- package/package.json +6 -6
- package/src/board/pull-request-row.tsx +13 -10
- package/src/components/pull-request-actions-review.tsx +2 -2
- package/src/components/pull-request-commits.tsx +2 -1
- package/src/components/pull-request-diff-annotations.tsx +3 -3
- package/src/components/pull-request-diff-impacts-summary.tsx +2 -1
- package/src/components/pull-request-diff-impacts.ts +1 -3
- package/src/components/pull-request-diff-overview.tsx +1 -2
- package/src/components/pull-request-diff-view.tsx +1 -1
- package/src/components/pull-request-diff-viewer.tsx +3 -4
- package/src/components/pull-request-files.tsx +2 -2
- package/src/components/pull-request-panel.tsx +21 -25
- package/src/components/pull-request-reactions.tsx +2 -2
- package/src/components/pull-request-revisions.tsx +2 -1
- package/src/components/pull-request-screen.tsx +21 -13
- package/src/components/pull-request-suggestion-actions.tsx +7 -6
- package/src/components/repository-attachment-banner.tsx +1 -1
- package/src/editor/markdown-editor.css +17 -1
- package/src/index.ts +1 -2
- package/src/prStatus.ts +1 -78
- package/src/board/facts.ts +0 -183
- package/src/services/formatRelativeDate.ts +0 -56
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reviewflow/ui",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.24.0",
|
|
4
4
|
"description": "react-native pull request screen components. Consumed as SOURCES (no build): exports resolve `default` to ./src/index.ts, which metro transforms like the app's own files; the consuming app's css must @source this package's src so tailwind keeps its classes.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"alouette",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"access": "public"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@codemirror/autocomplete": "6.20.
|
|
55
|
+
"@codemirror/autocomplete": "6.20.4",
|
|
56
56
|
"@codemirror/commands": "6.11.1",
|
|
57
57
|
"@codemirror/lang-css": "6.3.1",
|
|
58
58
|
"@codemirror/lang-html": "6.4.12",
|
|
@@ -60,15 +60,15 @@
|
|
|
60
60
|
"@codemirror/lang-json": "6.0.2",
|
|
61
61
|
"@codemirror/lang-markdown": "6.5.2",
|
|
62
62
|
"@codemirror/lang-yaml": "6.1.3",
|
|
63
|
-
"@codemirror/language": "6.
|
|
63
|
+
"@codemirror/language": "6.13.1",
|
|
64
64
|
"@codemirror/search": "6.7.2",
|
|
65
|
-
"@codemirror/state": "6.7.
|
|
66
|
-
"@codemirror/view": "6.43.
|
|
65
|
+
"@codemirror/state": "6.7.6",
|
|
66
|
+
"@codemirror/view": "6.43.15",
|
|
67
67
|
"@lezer/common": "1.5.5",
|
|
68
68
|
"@lezer/highlight": "1.2.3",
|
|
69
69
|
"@lezer/markdown": "1.7.2",
|
|
70
70
|
"@pierre/diffs": "1.5.2",
|
|
71
|
-
"@reviewflow/sdk": "6.
|
|
71
|
+
"@reviewflow/sdk": "6.24.0",
|
|
72
72
|
"@tanstack/markdown": "0.0.13",
|
|
73
73
|
"alouette-icons": "12.0.0",
|
|
74
74
|
"gemoji": "8.1.0",
|
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
formatPrChanges,
|
|
3
|
+
reviewflowPrChangesRequested,
|
|
4
|
+
reviewflowPrFailure,
|
|
5
|
+
reviewflowPrFlowDate,
|
|
6
|
+
reviewflowPrOwners,
|
|
7
|
+
reviewflowPrProgress,
|
|
8
|
+
} from "@reviewflow/sdk";
|
|
9
|
+
import type {
|
|
10
|
+
PullRequestRef,
|
|
11
|
+
ReviewflowBoardPr,
|
|
12
|
+
ReviewflowRowContext,
|
|
13
|
+
} from "@reviewflow/sdk";
|
|
2
14
|
import {
|
|
3
15
|
AccentScope,
|
|
4
16
|
ExternalLinkText,
|
|
@@ -11,15 +23,6 @@ import type { ReactNode } from "react";
|
|
|
11
23
|
import type { GestureResponderEvent } from "react-native";
|
|
12
24
|
import { linkInRow } from "../components/link-in-row.ts";
|
|
13
25
|
import { isSafeUrl } from "../markdown-github/safe-url.ts";
|
|
14
|
-
import {
|
|
15
|
-
formatPrChanges,
|
|
16
|
-
reviewflowPrChangesRequested,
|
|
17
|
-
reviewflowPrFailure,
|
|
18
|
-
reviewflowPrFlowDate,
|
|
19
|
-
reviewflowPrOwners,
|
|
20
|
-
reviewflowPrProgress,
|
|
21
|
-
} from "./facts.ts";
|
|
22
|
-
import type { ReviewflowRowContext } from "./facts.ts";
|
|
23
26
|
|
|
24
27
|
/**
|
|
25
28
|
* Following a link from inside the pressable row must not also press the row.
|
|
@@ -53,7 +53,7 @@ const submitBlocker = (
|
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
* "Review changes", ending the
|
|
56
|
+
* "Review changes", ending the Review tab's sticky toolbar: the verdict, a
|
|
57
57
|
* summary and the inline comments the diff collected, sent as one review.
|
|
58
58
|
* Brand while comments are pending, so an unsent review is not forgotten. Never
|
|
59
59
|
* offered to the author, whom GitHub lets comment but not review. A review
|
|
@@ -181,7 +181,7 @@ export function PullRequestReviewButton({
|
|
|
181
181
|
</Text>
|
|
182
182
|
{comments.length === 0 ? (
|
|
183
183
|
<Text className="text-sm text-muted">
|
|
184
|
-
None. Add them from the
|
|
184
|
+
None. Add them from the diff lines.
|
|
185
185
|
</Text>
|
|
186
186
|
) : (
|
|
187
187
|
comments.map((comment) => (
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
PullRequestDetailResponse,
|
|
4
4
|
PullRequestEdit,
|
|
5
5
|
} from "@reviewflow/sdk";
|
|
6
|
+
import { pluralOf } from "@reviewflow/sdk";
|
|
6
7
|
import {
|
|
7
8
|
Button,
|
|
8
9
|
Code,
|
|
@@ -213,7 +214,7 @@ export function PullRequestCommits({
|
|
|
213
214
|
))}
|
|
214
215
|
{unlisted > 0 ? (
|
|
215
216
|
<Text className="text-xs text-muted">
|
|
216
|
-
{`${
|
|
217
|
+
{`${pluralOf(unlisted, "more commit")} not listed`}
|
|
217
218
|
</Text>
|
|
218
219
|
) : null}
|
|
219
220
|
</View>
|
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
PullRequestEdit,
|
|
5
5
|
PullRequestThread,
|
|
6
6
|
} from "@reviewflow/sdk";
|
|
7
|
-
import { suggestionOriginalLines } from "@reviewflow/sdk";
|
|
7
|
+
import { pluralOf, suggestionOriginalLines } from "@reviewflow/sdk";
|
|
8
8
|
import {
|
|
9
9
|
ActionButton,
|
|
10
10
|
Badge,
|
|
@@ -114,7 +114,7 @@ export function ThreadAnnotation({
|
|
|
114
114
|
Resolved
|
|
115
115
|
</Badge>
|
|
116
116
|
<Text className="text-sm text-muted">
|
|
117
|
-
{`${first?.author.login ?? "thread"}, ${
|
|
117
|
+
{`${first?.author.login ?? "thread"}, ${pluralOf(thread.commentsTotal, "comment")}`}
|
|
118
118
|
</Text>
|
|
119
119
|
<Button
|
|
120
120
|
size="sm"
|
|
@@ -160,7 +160,7 @@ export function ThreadAnnotation({
|
|
|
160
160
|
</SuggestionOriginalContext>
|
|
161
161
|
{unlisted > 0 ? (
|
|
162
162
|
<Text className="text-xs text-muted">
|
|
163
|
-
{`${
|
|
163
|
+
{`${pluralOf(unlisted, "more comment")} on GitHub`}
|
|
164
164
|
</Text>
|
|
165
165
|
) : null}
|
|
166
166
|
{thread.viewerCanReply && first !== undefined ? (
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { DiffImpactsResponse } from "@reviewflow/sdk";
|
|
2
|
+
import { pluralOf } from "@reviewflow/sdk";
|
|
2
3
|
import { AccentScope, Icon, Text, View } from "alouette";
|
|
3
4
|
import { InfoRegularIcon } from "alouette-icons/phosphor-icons/Info";
|
|
4
5
|
import { ShareNetworkDuotoneIcon } from "alouette-icons/phosphor-icons/ShareNetwork";
|
|
5
6
|
import type { ReactNode } from "react";
|
|
6
7
|
import type { ImpactsStatus } from "./pull-request-diff-impacts.ts";
|
|
7
|
-
import { countImpacts
|
|
8
|
+
import { countImpacts } from "./pull-request-diff-impacts.ts";
|
|
8
9
|
|
|
9
10
|
interface PullRequestDiffImpactsSummaryProps {
|
|
10
11
|
/** null until read, or when it could not be */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DiffFileImpacts } from "@reviewflow/sdk";
|
|
2
|
+
import { pluralOf } from "@reviewflow/sdk";
|
|
2
3
|
|
|
3
4
|
/** `none`: no checkout, or a source that cannot read impacts. */
|
|
4
5
|
export type ImpactsStatus = "failed" | "none" | "reading" | "ready";
|
|
@@ -34,9 +35,6 @@ export const countImpacts = (
|
|
|
34
35
|
/** Past this many files using a changed file's exports, each export carries its counts only. */
|
|
35
36
|
export const maxImporterNames = 50;
|
|
36
37
|
|
|
37
|
-
export const pluralOf = (count: number, word: string): string =>
|
|
38
|
-
`${String(count)} ${word}${count === 1 ? "" : "s"}`;
|
|
39
|
-
|
|
40
38
|
/** "4 files · 2 tests", either part left out when it is 0. */
|
|
41
39
|
export const usageLabel = ({ files, tests }: ImpactCounts): string =>
|
|
42
40
|
[
|
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
DiffOmittedFile,
|
|
5
5
|
DiffReadingOrderResponse,
|
|
6
6
|
} from "@reviewflow/sdk";
|
|
7
|
-
import { isAttachablePath, subjectsOf } from "@reviewflow/sdk";
|
|
7
|
+
import { isAttachablePath, pluralOf, subjectsOf } from "@reviewflow/sdk";
|
|
8
8
|
import { Icon, PressableBox, Text, View } from "alouette";
|
|
9
9
|
import { FileRegularIcon } from "alouette-icons/phosphor-icons/File";
|
|
10
10
|
import { FileTsRegularIcon } from "alouette-icons/phosphor-icons/FileTs";
|
|
@@ -13,7 +13,6 @@ import { TestTubeRegularIcon } from "alouette-icons/phosphor-icons/TestTube";
|
|
|
13
13
|
import type { ReactNode } from "react";
|
|
14
14
|
import { PullRequestDiffImpactsSummary } from "./pull-request-diff-impacts-summary.tsx";
|
|
15
15
|
import type { ImpactsStatus } from "./pull-request-diff-impacts.ts";
|
|
16
|
-
import { pluralOf } from "./pull-request-diff-impacts.ts";
|
|
17
16
|
|
|
18
17
|
/** Past this many names over the range, the rows carry their counts only. */
|
|
19
18
|
export const maxOverviewNames = 50;
|
|
@@ -272,7 +272,7 @@ export function PullRequestDiffView({
|
|
|
272
272
|
<View
|
|
273
273
|
className={
|
|
274
274
|
aside && diffStyle === "split"
|
|
275
|
-
? "gap-m web:relative web:left-1/2 web:w-[
|
|
275
|
+
? "gap-m web:relative web:left-1/2 web:w-[100cqw] web:-translate-x-1/2"
|
|
276
276
|
: "gap-m"
|
|
277
277
|
}
|
|
278
278
|
>
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
PullRequestRef,
|
|
19
19
|
PullRequestThread,
|
|
20
20
|
} from "@reviewflow/sdk";
|
|
21
|
+
import { pluralOf } from "@reviewflow/sdk";
|
|
21
22
|
import {
|
|
22
23
|
AccentScope,
|
|
23
24
|
Badge,
|
|
@@ -70,7 +71,6 @@ import type { PullRequestMovedOn } from "./pull-request-diff-data.ts";
|
|
|
70
71
|
import {
|
|
71
72
|
countImpacts,
|
|
72
73
|
maxImporterNames,
|
|
73
|
-
pluralOf,
|
|
74
74
|
usageLabel,
|
|
75
75
|
} from "./pull-request-diff-impacts.ts";
|
|
76
76
|
import type { DiffFileHighlight } from "./pull-request-diff-jump.ts";
|
|
@@ -214,8 +214,7 @@ const useStableAnnotations = (
|
|
|
214
214
|
/** Why a thread is listed above its file rather than placed on a line. */
|
|
215
215
|
type UnplacedKind = "changedSince" | "outdated" | "whitespace";
|
|
216
216
|
|
|
217
|
-
const commentsText = (count: number): string =>
|
|
218
|
-
`${String(count)} comment${count === 1 ? "" : "s"}`;
|
|
217
|
+
const commentsText = (count: number): string => pluralOf(count, "comment");
|
|
219
218
|
|
|
220
219
|
const unplacedLabel = (kind: UnplacedKind, count: number): string => {
|
|
221
220
|
if (kind === "changedSince") {
|
|
@@ -224,7 +223,7 @@ const unplacedLabel = (kind: UnplacedKind, count: number): string => {
|
|
|
224
223
|
if (kind === "whitespace") {
|
|
225
224
|
return `${commentsText(count)} on lines hidden with whitespace changes`;
|
|
226
225
|
}
|
|
227
|
-
return
|
|
226
|
+
return pluralOf(count, "outdated comment");
|
|
228
227
|
};
|
|
229
228
|
|
|
230
229
|
const unplacedLineText = (
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
PullRequestThread,
|
|
7
7
|
PullRequestViewerLastReview,
|
|
8
8
|
} from "@reviewflow/sdk";
|
|
9
|
+
import { formatRelativeDate, pluralOf } from "@reviewflow/sdk";
|
|
9
10
|
import {
|
|
10
11
|
Button,
|
|
11
12
|
Code,
|
|
@@ -27,7 +28,6 @@ import { useMemo, useState } from "react";
|
|
|
27
28
|
import { usePullRequestDataSource } from "../dataSource.ts";
|
|
28
29
|
import { usePullRequestReviewDraft } from "../services/PullRequestReviewDraftProvider.tsx";
|
|
29
30
|
import { errorToMessage } from "../services/errorToMessage.ts";
|
|
30
|
-
import { formatRelativeDate } from "../services/formatRelativeDate.ts";
|
|
31
31
|
import { isViewerAuthor } from "../services/isViewerAuthor.ts";
|
|
32
32
|
import { PullRequestReviewButton } from "./pull-request-actions-review.tsx";
|
|
33
33
|
import type {
|
|
@@ -149,7 +149,7 @@ const describeCommit = (
|
|
|
149
149
|
): string => {
|
|
150
150
|
const oid = commit.oid.slice(0, 7);
|
|
151
151
|
if (summary === undefined) return oid;
|
|
152
|
-
const files =
|
|
152
|
+
const files = pluralOf(summary.files, "file");
|
|
153
153
|
return [oid, summary.workspaces.join(", "), files]
|
|
154
154
|
.filter((part) => part !== "")
|
|
155
155
|
.join(" · ");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
PrStatus,
|
|
2
3
|
PullRequestCandidatesResponse,
|
|
3
4
|
PullRequestDetail,
|
|
4
5
|
PullRequestDetailResponse,
|
|
@@ -6,6 +7,12 @@ import type {
|
|
|
6
7
|
PullRequestReviewState,
|
|
7
8
|
PullRequestSummary,
|
|
8
9
|
} from "@reviewflow/sdk";
|
|
10
|
+
import {
|
|
11
|
+
pluralOf,
|
|
12
|
+
prStatus,
|
|
13
|
+
prStatusLabels,
|
|
14
|
+
reviewflowPrStatus,
|
|
15
|
+
} from "@reviewflow/sdk";
|
|
9
16
|
import type { Accent } from "alouette";
|
|
10
17
|
import {
|
|
11
18
|
AccentScope,
|
|
@@ -33,16 +40,11 @@ import { ChatCircleDotsRegularIcon } from "alouette-icons/phosphor-icons/ChatCir
|
|
|
33
40
|
import type { ReactNode } from "react";
|
|
34
41
|
import { useState } from "react";
|
|
35
42
|
import { usePullRequestDataSource } from "../dataSource.ts";
|
|
36
|
-
import
|
|
37
|
-
import {
|
|
38
|
-
prStatus,
|
|
39
|
-
prStatusAccents,
|
|
40
|
-
prStatusLabels,
|
|
41
|
-
reviewflowPrStatus,
|
|
42
|
-
} from "../prStatus.ts";
|
|
43
|
+
import { prStatusAccents } from "../prStatus.ts";
|
|
43
44
|
import { emojify } from "../services/emojify.ts";
|
|
44
45
|
import { errorToMessage } from "../services/errorToMessage.ts";
|
|
45
46
|
import { formatDataAge } from "../services/formatDataAge.ts";
|
|
47
|
+
import { isViewerAuthor } from "../services/isViewerAuthor.ts";
|
|
46
48
|
import { LabelPill } from "./label-pill.tsx";
|
|
47
49
|
import {
|
|
48
50
|
PullRequestMergeButton,
|
|
@@ -52,7 +54,6 @@ import {
|
|
|
52
54
|
import { CheckLine } from "./pull-request-checks.tsx";
|
|
53
55
|
import { PullRequestCommits } from "./pull-request-commits.tsx";
|
|
54
56
|
import { PersonAvatar } from "./pull-request-conversation-person.tsx";
|
|
55
|
-
import { PullRequestFiles } from "./pull-request-files.tsx";
|
|
56
57
|
import {
|
|
57
58
|
PullRequestPartError,
|
|
58
59
|
PullRequestPartPending,
|
|
@@ -365,7 +366,7 @@ function UnresolvedThreads({
|
|
|
365
366
|
</View>
|
|
366
367
|
</AccentScope>
|
|
367
368
|
<Text className="min-w-0 flex-1 text-sm">
|
|
368
|
-
{
|
|
369
|
+
{pluralOf(unresolved, "unresolved thread")}
|
|
369
370
|
</Text>
|
|
370
371
|
</View>
|
|
371
372
|
);
|
|
@@ -1124,18 +1125,12 @@ function Sidebar({ detail, candidates, onEdited }: CandidatesProps): ReactNode {
|
|
|
1124
1125
|
);
|
|
1125
1126
|
}
|
|
1126
1127
|
|
|
1127
|
-
export type PullRequestTab =
|
|
1128
|
-
| "checks"
|
|
1129
|
-
| "commits"
|
|
1130
|
-
| "files"
|
|
1131
|
-
| "review"
|
|
1132
|
-
| "timeline";
|
|
1128
|
+
export type PullRequestTab = "checks" | "commits" | "review" | "timeline";
|
|
1133
1129
|
|
|
1134
1130
|
export const isPullRequestTab = (value: unknown): value is PullRequestTab =>
|
|
1135
1131
|
value === "checks" ||
|
|
1136
1132
|
value === "commits" ||
|
|
1137
1133
|
value === "timeline" ||
|
|
1138
|
-
value === "files" ||
|
|
1139
1134
|
value === "review";
|
|
1140
1135
|
|
|
1141
1136
|
/** The timeline opens on the description, as GitHub's first comment. */
|
|
@@ -1178,7 +1173,7 @@ export interface PullRequestPanelProps extends CandidatesProps {
|
|
|
1178
1173
|
/**
|
|
1179
1174
|
* One pull request as GitHub reports it now, laid out as GitHub's own page —
|
|
1180
1175
|
* the header, then Timeline (the description, a status box and a narrow
|
|
1181
|
-
* sidebar), Checks and
|
|
1176
|
+
* sidebar), Commits, Checks and Review — with the edits the operator makes
|
|
1182
1177
|
* before asking for a review. Every edit is GitHub's to refuse, and the panel
|
|
1183
1178
|
* re-reads the pull request after it, so nothing on screen is the request
|
|
1184
1179
|
* echoed back.
|
|
@@ -1198,6 +1193,13 @@ export function PullRequestPanel({
|
|
|
1198
1193
|
pullRequest,
|
|
1199
1194
|
"checks",
|
|
1200
1195
|
).value;
|
|
1196
|
+
const viewerIsAuthor = isViewerAuthor(detail);
|
|
1197
|
+
const reviewTab = (
|
|
1198
|
+
<Tab
|
|
1199
|
+
value="review"
|
|
1200
|
+
label={`Review (${pluralOf(pullRequest.changedFiles, "file")})`}
|
|
1201
|
+
/>
|
|
1202
|
+
);
|
|
1201
1203
|
return (
|
|
1202
1204
|
<View className="gap-l">
|
|
1203
1205
|
{[...new Set([...errors, ...(candidates?.errors ?? [])])].map((error) => (
|
|
@@ -1218,6 +1220,7 @@ export function PullRequestPanel({
|
|
|
1218
1220
|
if (isPullRequestTab(next)) onTabChange(next);
|
|
1219
1221
|
}}
|
|
1220
1222
|
>
|
|
1223
|
+
{viewerIsAuthor ? null : reviewTab}
|
|
1221
1224
|
<Tab value="timeline" label="Conversation" />
|
|
1222
1225
|
<Tab
|
|
1223
1226
|
value="commits"
|
|
@@ -1231,11 +1234,7 @@ export function PullRequestPanel({
|
|
|
1231
1234
|
: `Checks (${String(checks.checksTotal)})`
|
|
1232
1235
|
}
|
|
1233
1236
|
/>
|
|
1234
|
-
|
|
1235
|
-
value="files"
|
|
1236
|
-
label={`Files changed (${String(pullRequest.changedFiles)})`}
|
|
1237
|
-
/>
|
|
1238
|
-
<Tab value="review" label="Review" />
|
|
1237
|
+
{viewerIsAuthor ? reviewTab : null}
|
|
1239
1238
|
</Tabs>
|
|
1240
1239
|
<PullRequestActions detail={detail} onEdited={onEdited} />
|
|
1241
1240
|
</View>
|
|
@@ -1251,9 +1250,6 @@ export function PullRequestPanel({
|
|
|
1251
1250
|
);
|
|
1252
1251
|
}
|
|
1253
1252
|
if (tab === "checks") return <ChecksTab pullRequest={pullRequest} />;
|
|
1254
|
-
if (tab === "files") {
|
|
1255
|
-
return <PullRequestFiles detail={detail} onEdited={onEdited} />;
|
|
1256
|
-
}
|
|
1257
1253
|
if (tab === "review") {
|
|
1258
1254
|
return <PullRequestReview detail={detail} onEdited={onEdited} />;
|
|
1259
1255
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { pullRequestReactionContents } from "@reviewflow/sdk";
|
|
1
|
+
import { pluralOf, pullRequestReactionContents } from "@reviewflow/sdk";
|
|
2
2
|
import type {
|
|
3
3
|
PullRequestEdit,
|
|
4
4
|
PullRequestReactable,
|
|
@@ -58,7 +58,7 @@ function ReactionButton({
|
|
|
58
58
|
toggle,
|
|
59
59
|
}: ReactionButtonProps): ReactNode {
|
|
60
60
|
const { content, count, viewerHasReacted } = reaction;
|
|
61
|
-
const people = count
|
|
61
|
+
const people = pluralOf(count, "person", "people");
|
|
62
62
|
return (
|
|
63
63
|
<ActionButton
|
|
64
64
|
text={`${reactionEmoji[content]} ${String(count)}`}
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
PullRequestRevision,
|
|
4
4
|
PullRequestTextLine,
|
|
5
5
|
} from "@reviewflow/sdk";
|
|
6
|
+
import { pluralOf } from "@reviewflow/sdk";
|
|
6
7
|
import {
|
|
7
8
|
Box,
|
|
8
9
|
Button,
|
|
@@ -86,7 +87,7 @@ function ChangeRunText({ run }: { run: ChangeRun }): ReactNode {
|
|
|
86
87
|
if (run.kind === "folded") {
|
|
87
88
|
return (
|
|
88
89
|
<Text className="py-xxs text-xs text-muted">
|
|
89
|
-
{`⋯ ${
|
|
90
|
+
{`⋯ ${pluralOf(run.count, "unchanged line")}`}
|
|
90
91
|
</Text>
|
|
91
92
|
);
|
|
92
93
|
}
|
|
@@ -253,11 +253,17 @@ export interface PullRequestScreenProps {
|
|
|
253
253
|
|
|
254
254
|
/**
|
|
255
255
|
* One pull request laid out as GitHub's page: the header, then the tabs
|
|
256
|
-
* Conversation, Commits, Checks and
|
|
256
|
+
* Conversation, Commits, Checks and Review, with the edits a reviewer
|
|
257
257
|
* or an author makes. Reads and writes through the
|
|
258
258
|
* {@link PullRequestDataSource} of the nearest
|
|
259
259
|
* `PullRequestDataSourceProvider`, which the app provides. Mount it with a
|
|
260
260
|
* `key` of the pull request, so a draft review never leaks to the next one.
|
|
261
|
+
*
|
|
262
|
+
* It fills its parent, and the app chooses how wide that is: its text stays in
|
|
263
|
+
* a 1200px column, and only a split diff beside its file list widens to the
|
|
264
|
+
* whole screen. The screen is the CSS container that diff is sized against
|
|
265
|
+
* (`100cqw`): the window (`100vw`) overflows an app that lays the page beside a
|
|
266
|
+
* sidebar.
|
|
261
267
|
*/
|
|
262
268
|
export function PullRequestScreen({
|
|
263
269
|
ownerRepo,
|
|
@@ -280,18 +286,20 @@ export function PullRequestScreen({
|
|
|
280
286
|
fileOrder={diffFileOrder}
|
|
281
287
|
onFileOrderChange={onDiffFileOrderChange}
|
|
282
288
|
>
|
|
283
|
-
<View className="
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
289
|
+
<View className="w-full px-l pt-l pb-xl web:@container">
|
|
290
|
+
<View className="mx-auto w-full max-w-[1200px] gap-m">
|
|
291
|
+
<PullRequestDraftsProvider
|
|
292
|
+
store={draftStore}
|
|
293
|
+
pullRequest={{ ownerRepo, number }}
|
|
294
|
+
>
|
|
295
|
+
<PullRequestBody
|
|
296
|
+
key={`${ownerRepo}#${String(number)}`}
|
|
297
|
+
ownerRepo={ownerRepo}
|
|
298
|
+
number={number}
|
|
299
|
+
{...body}
|
|
300
|
+
/>
|
|
301
|
+
</PullRequestDraftsProvider>
|
|
302
|
+
</View>
|
|
295
303
|
</View>
|
|
296
304
|
</DiffSettingsProvider>
|
|
297
305
|
</PullRequestSlotsProvider>
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
parseSuggestions,
|
|
3
|
-
suggestionCommitRefusal,
|
|
4
|
-
suggestionRangesOverlap,
|
|
5
|
-
} from "@reviewflow/sdk";
|
|
6
1
|
import type {
|
|
7
2
|
PullRequestComment,
|
|
8
3
|
PullRequestSuggestionRef,
|
|
9
4
|
PullRequestThread,
|
|
10
5
|
} from "@reviewflow/sdk";
|
|
6
|
+
import {
|
|
7
|
+
parseSuggestions,
|
|
8
|
+
pluralOf,
|
|
9
|
+
suggestionCommitRefusal,
|
|
10
|
+
suggestionRangesOverlap,
|
|
11
|
+
} from "@reviewflow/sdk";
|
|
11
12
|
import {
|
|
12
13
|
Button,
|
|
13
14
|
FormItem,
|
|
@@ -161,7 +162,7 @@ export function SuggestionActions({
|
|
|
161
162
|
text={
|
|
162
163
|
batch.batch.length === 0
|
|
163
164
|
? "Commit suggestion"
|
|
164
|
-
: `Commit ${
|
|
165
|
+
: `Commit ${pluralOf(batch.batch.length, "suggestion")}`
|
|
165
166
|
}
|
|
166
167
|
disabledReason={
|
|
167
168
|
toCommit.length === 0 ? (refusal ?? undefined) : undefined
|
|
@@ -13,7 +13,7 @@ interface RepositoryAttachmentBannerProps {
|
|
|
13
13
|
/**
|
|
14
14
|
* Says up front, on every tab, that the diff of this repository cannot be read
|
|
15
15
|
* because no local checkout is attached to it, rather than leaving that to be
|
|
16
|
-
* found in
|
|
16
|
+
* found in the Review tab. An entry that exists but fails its check shows why.
|
|
17
17
|
* Nothing when the checkout is attached.
|
|
18
18
|
*/
|
|
19
19
|
export function RepositoryAttachmentBanner({
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* The markdown editor's look, ported from vscode-extension-markdown-beautiful-editor
|
|
3
3
|
* (src/styles/md-inline.css, md-block.css, md-alerts.css, code-tokens.css) with
|
|
4
4
|
* every --vscode-* colour rewritten as an alouette token, so the light and dark
|
|
5
|
-
* themes reach it through the same .light/.dark ancestor as the rest of
|
|
5
|
+
* themes reach it through the same .light/.dark/.system ancestor as the rest of
|
|
6
|
+
* the app.
|
|
6
7
|
*
|
|
7
8
|
* Everything is scoped under .markdown-editor: the md-* classes are generic
|
|
8
9
|
* names and must not reach anything else on the page.
|
|
@@ -63,6 +64,21 @@
|
|
|
63
64
|
--md-tok-invalid: #f85149;
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/* the "system" preference never sets .dark: the palette resolves it with this
|
|
68
|
+
media query, so the dark tokens are repeated here */
|
|
69
|
+
@media (prefers-color-scheme: dark) {
|
|
70
|
+
.system .markdown-editor {
|
|
71
|
+
--md-tok-comment: #8b949e;
|
|
72
|
+
--md-tok-keyword: #ff7b72;
|
|
73
|
+
--md-tok-string: #a5d6ff;
|
|
74
|
+
--md-tok-constant: #79c0ff;
|
|
75
|
+
--md-tok-property: #79c0ff;
|
|
76
|
+
--md-tok-type: #7ee787;
|
|
77
|
+
--md-tok-function: #d2a8ff;
|
|
78
|
+
--md-tok-invalid: #f85149;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
.markdown-editor:hover {
|
|
67
83
|
border-color: var(--color-interactive-outlined-hover);
|
|
68
84
|
}
|
package/src/index.ts
CHANGED
|
@@ -59,8 +59,7 @@ export { PullRequestSuggestionBatchProvider } from "./services/PullRequestSugges
|
|
|
59
59
|
export { emojify } from "./services/emojify.ts";
|
|
60
60
|
export { errorToMessage } from "./services/errorToMessage.ts";
|
|
61
61
|
export { isViewerAuthor } from "./services/isViewerAuthor.ts";
|
|
62
|
-
export
|
|
63
|
-
export { prStatus, prStatusAccents, prStatusLabels } from "./prStatus.ts";
|
|
62
|
+
export { prStatusAccents } from "./prStatus.ts";
|
|
64
63
|
export * from "./board/index.ts";
|
|
65
64
|
export { MarkdownEditor } from "./editor/markdown-editor.tsx";
|
|
66
65
|
export type { MarkdownEditorFieldProps } from "./editor/markdown-editor.tsx";
|
package/src/prStatus.ts
CHANGED
|
@@ -1,62 +1,6 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
PrReviewDecision,
|
|
3
|
-
PrState,
|
|
4
|
-
ReviewflowBoardPr,
|
|
5
|
-
} from "@reviewflow/sdk";
|
|
1
|
+
import type { PrStatus } from "@reviewflow/sdk";
|
|
6
2
|
import type { Accent } from "alouette";
|
|
7
3
|
|
|
8
|
-
/**
|
|
9
|
-
* What a reader is told about a pull request in one word or two: an open pull
|
|
10
|
-
* request is only interesting for where its review stands, so state and review
|
|
11
|
-
* decision collapse into one status.
|
|
12
|
-
*/
|
|
13
|
-
export type PrStatus =
|
|
14
|
-
| "approved"
|
|
15
|
-
| "changesRequested"
|
|
16
|
-
| "closed"
|
|
17
|
-
| "draft"
|
|
18
|
-
| "merged"
|
|
19
|
-
| "noReviewers"
|
|
20
|
-
| "waitingReview";
|
|
21
|
-
|
|
22
|
-
/** A closed or merged pull request keeps its own status whatever the last review said. */
|
|
23
|
-
export const prStatus = ({
|
|
24
|
-
state,
|
|
25
|
-
reviewDecision,
|
|
26
|
-
}: {
|
|
27
|
-
state: PrState;
|
|
28
|
-
reviewDecision: PrReviewDecision;
|
|
29
|
-
}): PrStatus => {
|
|
30
|
-
if (state !== "open") return state;
|
|
31
|
-
switch (reviewDecision) {
|
|
32
|
-
case "approved": {
|
|
33
|
-
return "approved";
|
|
34
|
-
}
|
|
35
|
-
case "changesRequested": {
|
|
36
|
-
return "changesRequested";
|
|
37
|
-
}
|
|
38
|
-
case "reviewRequired": {
|
|
39
|
-
return "waitingReview";
|
|
40
|
-
}
|
|
41
|
-
case null: {
|
|
42
|
-
return "noReviewers";
|
|
43
|
-
}
|
|
44
|
-
default: {
|
|
45
|
-
return reviewDecision satisfies never;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
export const prStatusLabels: Record<PrStatus, string> = {
|
|
51
|
-
approved: "approved",
|
|
52
|
-
changesRequested: "changes requested",
|
|
53
|
-
closed: "closed",
|
|
54
|
-
draft: "draft",
|
|
55
|
-
merged: "merged",
|
|
56
|
-
noReviewers: "no reviewers",
|
|
57
|
-
waitingReview: "waiting review",
|
|
58
|
-
};
|
|
59
|
-
|
|
60
4
|
/** A merged pull request takes the brand accent; an app with its own accent for it passes one instead. */
|
|
61
5
|
export const prStatusAccents: Record<PrStatus, Accent | undefined> = {
|
|
62
6
|
approved: "success",
|
|
@@ -67,24 +11,3 @@ export const prStatusAccents: Record<PrStatus, Accent | undefined> = {
|
|
|
67
11
|
noReviewers: "warning",
|
|
68
12
|
waitingReview: "info",
|
|
69
13
|
};
|
|
70
|
-
|
|
71
|
-
const reviewflowReviewDecisions = {
|
|
72
|
-
approved: "approved",
|
|
73
|
-
changesRequested: "changesRequested",
|
|
74
|
-
requested: "reviewRequired",
|
|
75
|
-
none: null,
|
|
76
|
-
} as const;
|
|
77
|
-
|
|
78
|
-
/** null when reviewflow does not know enough to say */
|
|
79
|
-
export const reviewflowPrStatus = ({
|
|
80
|
-
state,
|
|
81
|
-
reviewState,
|
|
82
|
-
}: Pick<ReviewflowBoardPr, "reviewState" | "state">): PrStatus | null => {
|
|
83
|
-
if (state === null) return null;
|
|
84
|
-
if (state !== "open") return state;
|
|
85
|
-
if (reviewState === null) return null;
|
|
86
|
-
return prStatus({
|
|
87
|
-
state,
|
|
88
|
-
reviewDecision: reviewflowReviewDecisions[reviewState],
|
|
89
|
-
});
|
|
90
|
-
};
|
package/src/board/facts.ts
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
import type { ReviewflowBoardPr, ReviewflowPrChanges } from "@reviewflow/sdk";
|
|
2
|
-
import type { FormatRelativeDateOptions } from "../services/formatRelativeDate.ts";
|
|
3
|
-
import { formatRelativeDate } from "../services/formatRelativeDate.ts";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* What reviewflow's own board row says about a pull request, in its wording so
|
|
7
|
-
* the two boards read the same. The status line comes in three parts by how
|
|
8
|
-
* loudly each is shown: what blocks the merge, changes requested, the rest.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export const maxFailedCheckNames = 9;
|
|
12
|
-
|
|
13
|
-
/** Where a row is shown, which decides what it leaves to its bucket's title. */
|
|
14
|
-
export interface ReviewflowRowContext {
|
|
15
|
-
bucketKey: string;
|
|
16
|
-
groupKey: string;
|
|
17
|
-
/** the token's login, named "you" */
|
|
18
|
-
viewer: string | null;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** the buckets whose title already says whether the checks pass */
|
|
22
|
-
const bucketsSilentOnPassedChecks = new Set(["changes-requested", "drafts"]);
|
|
23
|
-
/** the bucket whose title already says every reviewer was asked again */
|
|
24
|
-
const reRequestedBucket = "re-requested-reviews";
|
|
25
|
-
/** someone else's pull request requests a review, the operator's awaits one */
|
|
26
|
-
const requestingAttentionGroup = "requesting-attention";
|
|
27
|
-
|
|
28
|
-
const counted = (count: number, one: string, many: string): string =>
|
|
29
|
-
`${String(count)} ${count === 1 ? one : many}`;
|
|
30
|
-
|
|
31
|
-
const people = (logins: string[], viewer: string | null): string =>
|
|
32
|
-
logins.map((login) => (login === viewer ? "you" : `@${login}`)).join(", ");
|
|
33
|
-
|
|
34
|
-
const failedChecksText = ({
|
|
35
|
-
checksConclusion,
|
|
36
|
-
failedChecks,
|
|
37
|
-
}: Pick<ReviewflowBoardPr, "checksConclusion" | "failedChecks">):
|
|
38
|
-
| string
|
|
39
|
-
| null => {
|
|
40
|
-
if (failedChecks.length === 0) {
|
|
41
|
-
return checksConclusion === "failed" ? "checks failed" : null;
|
|
42
|
-
}
|
|
43
|
-
const names = failedChecks.slice(0, maxFailedCheckNames).join(", ");
|
|
44
|
-
const remaining = failedChecks.length - maxFailedCheckNames;
|
|
45
|
-
const more = remaining > 0 ? ` +${String(remaining)}` : "";
|
|
46
|
-
return `${failedChecks.length === 1 ? "check" : "checks"} failed: ${names}${more}`;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Failing checks (after reviewflow's allowed-to-fail rules, by name), a failed
|
|
51
|
-
* pr lint and a merge conflict; null when nothing blocks.
|
|
52
|
-
*/
|
|
53
|
-
export const reviewflowPrFailure = (
|
|
54
|
-
pr: Pick<
|
|
55
|
-
ReviewflowBoardPr,
|
|
56
|
-
"checksConclusion" | "failedChecks" | "lintFailed" | "mergeable"
|
|
57
|
-
>,
|
|
58
|
-
): string | null => {
|
|
59
|
-
const parts = [
|
|
60
|
-
failedChecksText(pr),
|
|
61
|
-
pr.lintFailed ? "pr lint failed" : null,
|
|
62
|
-
pr.mergeable === "conflicting" ? "conflicts" : null,
|
|
63
|
-
].filter((part) => part !== null);
|
|
64
|
-
return parts.length === 0 ? null : parts.join(" · ");
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
export const reviewflowPrChangesRequested = (
|
|
68
|
-
{ changesRequestedBy }: Pick<ReviewflowBoardPr, "changesRequestedBy">,
|
|
69
|
-
viewer: string | null,
|
|
70
|
-
): string | null =>
|
|
71
|
-
changesRequestedBy.length === 0
|
|
72
|
-
? null
|
|
73
|
-
: `changes requested by ${people(changesRequestedBy, viewer)}`;
|
|
74
|
-
|
|
75
|
-
const checksProgressText = (
|
|
76
|
-
{
|
|
77
|
-
checksConclusion,
|
|
78
|
-
checksRunning,
|
|
79
|
-
}: Pick<ReviewflowBoardPr, "checksConclusion" | "checksRunning">,
|
|
80
|
-
bucketKey: string,
|
|
81
|
-
): string | null => {
|
|
82
|
-
if (checksRunning > 0) {
|
|
83
|
-
return `${counted(checksRunning, "check", "checks")} running`;
|
|
84
|
-
}
|
|
85
|
-
return checksConclusion === "passed" &&
|
|
86
|
-
!bucketsSilentOnPassedChecks.has(bucketKey)
|
|
87
|
-
? "checks passed"
|
|
88
|
-
: null;
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
const awaitedText = (
|
|
92
|
-
{
|
|
93
|
-
requestedReviewers,
|
|
94
|
-
reRequestedReviewers,
|
|
95
|
-
requestedTeams,
|
|
96
|
-
}: Pick<
|
|
97
|
-
ReviewflowBoardPr,
|
|
98
|
-
"requestedReviewers" | "requestedTeams" | "reRequestedReviewers"
|
|
99
|
-
>,
|
|
100
|
-
{ bucketKey, groupKey, viewer }: ReviewflowRowContext,
|
|
101
|
-
): string | null => {
|
|
102
|
-
const reviewers = [
|
|
103
|
-
...requestedReviewers,
|
|
104
|
-
...(bucketKey === reRequestedBucket ? [] : reRequestedReviewers),
|
|
105
|
-
];
|
|
106
|
-
if (reviewers.length === 0 && requestedTeams.length === 0) return null;
|
|
107
|
-
const verb = groupKey === requestingAttentionGroup ? "requested" : "awaiting";
|
|
108
|
-
const awaited = [
|
|
109
|
-
...(reviewers.length === 0 ? [] : [people(reviewers, viewer)]),
|
|
110
|
-
...requestedTeams.map((team) => `#${team}`),
|
|
111
|
-
];
|
|
112
|
-
return `${verb} ${awaited.join(", ")}`;
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
/** running or passed checks, approvals, open threads, awaited reviewers */
|
|
116
|
-
export const reviewflowPrProgress = (
|
|
117
|
-
pr: Pick<
|
|
118
|
-
ReviewflowBoardPr,
|
|
119
|
-
| "approvedBy"
|
|
120
|
-
| "checksConclusion"
|
|
121
|
-
| "checksRunning"
|
|
122
|
-
| "requestedReviewers"
|
|
123
|
-
| "requestedTeams"
|
|
124
|
-
| "reRequestedReviewers"
|
|
125
|
-
| "unresolvedThreads"
|
|
126
|
-
>,
|
|
127
|
-
context: ReviewflowRowContext,
|
|
128
|
-
): string | null => {
|
|
129
|
-
const parts = [
|
|
130
|
-
checksProgressText(pr, context.bucketKey),
|
|
131
|
-
pr.approvedBy.length === 0
|
|
132
|
-
? null
|
|
133
|
-
: `approved by ${people(pr.approvedBy, context.viewer)}`,
|
|
134
|
-
pr.unresolvedThreads
|
|
135
|
-
? counted(pr.unresolvedThreads, "unresolved thread", "unresolved threads")
|
|
136
|
-
: null,
|
|
137
|
-
awaitedText(pr, context),
|
|
138
|
-
].filter((part) => part !== null);
|
|
139
|
-
return parts.length === 0 ? null : parts.join(" · ");
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* "by @author", "assigned to @a", or both. An author who is their own only
|
|
144
|
-
* assignee is a byline, not an assignment; a pull request whose only person is
|
|
145
|
-
* the viewer names nobody.
|
|
146
|
-
*/
|
|
147
|
-
export const reviewflowPrOwners = (
|
|
148
|
-
{ author, assignees }: Pick<ReviewflowBoardPr, "assignees" | "author">,
|
|
149
|
-
viewer: string | null,
|
|
150
|
-
): string | null => {
|
|
151
|
-
const named = [...(author === null ? [] : [author]), ...assignees];
|
|
152
|
-
if (named.length === 0) return null;
|
|
153
|
-
if (viewer !== null && named.every((login) => login === viewer)) return null;
|
|
154
|
-
|
|
155
|
-
const byline = author === null ? null : `by ${people([author], viewer)}`;
|
|
156
|
-
if (assignees.length === 0) return byline;
|
|
157
|
-
const assignment = `assigned to ${people(assignees, viewer)}`;
|
|
158
|
-
|
|
159
|
-
if (author === null || !assignees.includes(author)) {
|
|
160
|
-
return byline === null ? assignment : `${byline} · ${assignment}`;
|
|
161
|
-
}
|
|
162
|
-
// naming every assignee already names the author among them
|
|
163
|
-
return assignees.length === 1 ? byline : assignment;
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
export const reviewflowPrFlowDate = (
|
|
167
|
-
{ openedAt, approvedAt }: Pick<ReviewflowBoardPr, "approvedAt" | "openedAt">,
|
|
168
|
-
options?: FormatRelativeDateOptions,
|
|
169
|
-
): string | null => {
|
|
170
|
-
if (approvedAt !== null) {
|
|
171
|
-
return `approved ${formatRelativeDate(approvedAt, options)}`;
|
|
172
|
-
}
|
|
173
|
-
return openedAt === null
|
|
174
|
-
? null
|
|
175
|
-
: `opened ${formatRelativeDate(openedAt, options)}`;
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
export const formatPrChanges = ({
|
|
179
|
-
changedFiles,
|
|
180
|
-
additions,
|
|
181
|
-
deletions,
|
|
182
|
-
}: ReviewflowPrChanges): string =>
|
|
183
|
-
`${counted(changedFiles, "file", "files")} +${String(additions)} −${String(deletions)}`;
|
|
@@ -1,56 +0,0 @@
|
|
|
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
|
-
};
|