@reviewflow/ui 6.22.2 → 6.23.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 +2 -2
- package/src/board/pull-request-row.tsx +13 -10
- package/src/components/pull-request-files.tsx +1 -1
- package/src/components/pull-request-panel.tsx +3 -7
- 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.23.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",
|
|
@@ -68,7 +68,7 @@
|
|
|
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.23.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.
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
PullRequestThread,
|
|
7
7
|
PullRequestViewerLastReview,
|
|
8
8
|
} from "@reviewflow/sdk";
|
|
9
|
+
import { formatRelativeDate } 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 {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
PrStatus,
|
|
2
3
|
PullRequestCandidatesResponse,
|
|
3
4
|
PullRequestDetail,
|
|
4
5
|
PullRequestDetailResponse,
|
|
@@ -6,6 +7,7 @@ import type {
|
|
|
6
7
|
PullRequestReviewState,
|
|
7
8
|
PullRequestSummary,
|
|
8
9
|
} from "@reviewflow/sdk";
|
|
10
|
+
import { prStatus, prStatusLabels, reviewflowPrStatus } from "@reviewflow/sdk";
|
|
9
11
|
import type { Accent } from "alouette";
|
|
10
12
|
import {
|
|
11
13
|
AccentScope,
|
|
@@ -33,13 +35,7 @@ import { ChatCircleDotsRegularIcon } from "alouette-icons/phosphor-icons/ChatCir
|
|
|
33
35
|
import type { ReactNode } from "react";
|
|
34
36
|
import { useState } from "react";
|
|
35
37
|
import { usePullRequestDataSource } from "../dataSource.ts";
|
|
36
|
-
import
|
|
37
|
-
import {
|
|
38
|
-
prStatus,
|
|
39
|
-
prStatusAccents,
|
|
40
|
-
prStatusLabels,
|
|
41
|
-
reviewflowPrStatus,
|
|
42
|
-
} from "../prStatus.ts";
|
|
38
|
+
import { prStatusAccents } from "../prStatus.ts";
|
|
43
39
|
import { emojify } from "../services/emojify.ts";
|
|
44
40
|
import { errorToMessage } from "../services/errorToMessage.ts";
|
|
45
41
|
import { formatDataAge } from "../services/formatDataAge.ts";
|
|
@@ -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
|
-
};
|