@gogitcms/design-system 0.15.0-next.3
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/README.md +77 -0
- package/css/components.css +312 -0
- package/css/tokens.css +212 -0
- package/package.json +65 -0
- package/src/ThemeProvider.tsx +86 -0
- package/src/__tests__/ApplyChangesModal.test.tsx +148 -0
- package/src/__tests__/BranchImport.test.tsx +46 -0
- package/src/__tests__/Button.test.tsx +45 -0
- package/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
- package/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
- package/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
- package/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
- package/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
- package/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
- package/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
- package/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
- package/src/__tests__/ContentBrowser.media.test.tsx +140 -0
- package/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
- package/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
- package/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
- package/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
- package/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
- package/src/__tests__/ContentBrowser.search.test.tsx +135 -0
- package/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
- package/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
- package/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
- package/src/__tests__/MediaBrowser.test.tsx +353 -0
- package/src/__tests__/MediaField.test.tsx +185 -0
- package/src/__tests__/Notifications.test.tsx +69 -0
- package/src/__tests__/Onboarding.test.tsx +287 -0
- package/src/__tests__/cssTokens.test.ts +201 -0
- package/src/__tests__/fieldComponents.test.ts +43 -0
- package/src/__tests__/reorder.test.ts +58 -0
- package/src/components/ApplyChangesModal.tsx +348 -0
- package/src/components/BranchImport.tsx +157 -0
- package/src/components/BranchMenu.tsx +192 -0
- package/src/components/Button.tsx +131 -0
- package/src/components/ChangeDetail.tsx +472 -0
- package/src/components/ChangeRequestSummary.tsx +173 -0
- package/src/components/CollabField.tsx +388 -0
- package/src/components/ContentBrowser.tsx +5073 -0
- package/src/components/Icon.tsx +28 -0
- package/src/components/Icon.web.tsx +31 -0
- package/src/components/Input.tsx +106 -0
- package/src/components/MediaBrowser.tsx +766 -0
- package/src/components/MediaField.tsx +670 -0
- package/src/components/MediaPreview.tsx +91 -0
- package/src/components/MediaPreview.web.tsx +169 -0
- package/src/components/NavRow.tsx +105 -0
- package/src/components/Notifications.tsx +301 -0
- package/src/components/Onboarding.tsx +751 -0
- package/src/components/ProjectMenu.tsx +124 -0
- package/src/components/Segment.tsx +87 -0
- package/src/components/Skeleton.tsx +216 -0
- package/src/components/Spinner.tsx +44 -0
- package/src/components/Text.tsx +85 -0
- package/src/components/documentDrafts.ts +213 -0
- package/src/components/layout.tsx +284 -0
- package/src/components/primitives.tsx +143 -0
- package/src/components/reorder.ts +40 -0
- package/src/fieldComponents.ts +63 -0
- package/src/icons.ts +102 -0
- package/src/index.ts +198 -0
- package/src/media.ts +229 -0
- package/src/theme.ts +116 -0
- package/src/web/Button.tsx +110 -0
- package/src/web/Icon.tsx +52 -0
- package/src/web/Input.tsx +39 -0
- package/src/web/index.ts +43 -0
- package/src/web/primitives.tsx +119 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, Image, Linking, Pressable } from "react-native";
|
|
3
|
+
import { useTheme } from "../ThemeProvider";
|
|
4
|
+
import { Text } from "./Text";
|
|
5
|
+
import { Icon } from "./Icon";
|
|
6
|
+
import { MediaAsset, formatBytes, kindIcon } from "../media";
|
|
7
|
+
|
|
8
|
+
// Native previews. (Web renders through MediaPreview.web.tsx via platform
|
|
9
|
+
// resolution, and gets real <video>/<audio>/<object> playback.)
|
|
10
|
+
//
|
|
11
|
+
// Native deliberately stops at images: video, audio and PDF playback would each
|
|
12
|
+
// pull in a separate native dependency, and the editor's job on a phone is to
|
|
13
|
+
// let you see *which* asset a field points at, not to be a media player. Those
|
|
14
|
+
// kinds render the same chip as any other file, which opens the asset in the
|
|
15
|
+
// system browser on tap.
|
|
16
|
+
|
|
17
|
+
export type MediaPreviewProps = {
|
|
18
|
+
media: MediaAsset;
|
|
19
|
+
maxHeight?: number;
|
|
20
|
+
compact?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function MediaPreview({ media, maxHeight = 240, compact }: MediaPreviewProps) {
|
|
24
|
+
const t = useTheme();
|
|
25
|
+
|
|
26
|
+
// SVG needs react-native-svg's SvgUri, an optional peer dependency. Rendering
|
|
27
|
+
// it through <Image> would silently show nothing on Android, so it takes the
|
|
28
|
+
// chip path instead — visible and honest.
|
|
29
|
+
const renderable = media.kind === "image";
|
|
30
|
+
if (compact || !renderable) return <MediaChip media={media} />;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<View
|
|
34
|
+
style={{
|
|
35
|
+
borderWidth: 1,
|
|
36
|
+
borderColor: t.color.borderDefault,
|
|
37
|
+
borderRadius: t.radius.md,
|
|
38
|
+
backgroundColor: t.color.surfaceSunken,
|
|
39
|
+
overflow: "hidden",
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
<Image
|
|
43
|
+
source={{ uri: media.url }}
|
|
44
|
+
accessibilityLabel={media.repoPath}
|
|
45
|
+
resizeMode="contain"
|
|
46
|
+
style={{ width: "100%", height: maxHeight }}
|
|
47
|
+
/>
|
|
48
|
+
</View>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// MediaChip is the fallback representation: an icon, the file name, its size,
|
|
53
|
+
// and a tap target that opens the asset externally.
|
|
54
|
+
export function MediaChip({ media }: { media: MediaAsset }) {
|
|
55
|
+
const t = useTheme();
|
|
56
|
+
return (
|
|
57
|
+
<Pressable
|
|
58
|
+
onPress={() => Linking.openURL(media.url)}
|
|
59
|
+
style={{
|
|
60
|
+
flexDirection: "row",
|
|
61
|
+
alignItems: "center",
|
|
62
|
+
gap: t.space(3),
|
|
63
|
+
padding: t.space(3),
|
|
64
|
+
borderWidth: 1,
|
|
65
|
+
borderColor: t.color.borderDefault,
|
|
66
|
+
borderRadius: t.radius.md,
|
|
67
|
+
backgroundColor: t.color.surfaceRaised,
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<Icon name={kindIcon(media.kind)} size={20} color={t.color.textSecondary} />
|
|
71
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
72
|
+
<Text variant="body" numberOfLines={1}>
|
|
73
|
+
{fileName(media.repoPath)}
|
|
74
|
+
</Text>
|
|
75
|
+
<Text variant="xs" color="tertiary">
|
|
76
|
+
{[media.mimeType, formatBytes(media.size), dimensions(media)].filter(Boolean).join(" · ")}
|
|
77
|
+
</Text>
|
|
78
|
+
</View>
|
|
79
|
+
<Icon name="download" size={16} color={t.color.textSecondary} />
|
|
80
|
+
</Pressable>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function fileName(repoPath: string): string {
|
|
85
|
+
const i = repoPath.lastIndexOf("/");
|
|
86
|
+
return i >= 0 ? repoPath.slice(i + 1) : repoPath;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function dimensions(media: MediaAsset): string {
|
|
90
|
+
return media.width && media.height ? `${media.width}×${media.height}` : "";
|
|
91
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { useTheme } from "../ThemeProvider";
|
|
4
|
+
import { Text } from "./Text";
|
|
5
|
+
import { Icon } from "./Icon";
|
|
6
|
+
import { MediaAsset, formatBytes, kindIcon } from "../media";
|
|
7
|
+
|
|
8
|
+
// Web previews. Native renders through MediaPreview.tsx, which falls back to a
|
|
9
|
+
// file chip for the kinds that need DOM media elements.
|
|
10
|
+
|
|
11
|
+
export type MediaPreviewProps = {
|
|
12
|
+
media: MediaAsset;
|
|
13
|
+
// Cap on the rendered height. The preview never exceeds it; images letterbox
|
|
14
|
+
// inside it rather than pushing the rest of the form off-screen.
|
|
15
|
+
maxHeight?: number;
|
|
16
|
+
// Compact renders the chip form regardless of kind — used in the picker grid
|
|
17
|
+
// and anywhere a full preview would be too heavy.
|
|
18
|
+
compact?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function MediaPreview({ media, maxHeight = 240, compact }: MediaPreviewProps) {
|
|
22
|
+
const t = useTheme();
|
|
23
|
+
|
|
24
|
+
if (compact) return <MediaChip media={media} />;
|
|
25
|
+
|
|
26
|
+
const frame = {
|
|
27
|
+
borderWidth: 1,
|
|
28
|
+
borderColor: t.color.borderDefault,
|
|
29
|
+
borderRadius: t.radius.md,
|
|
30
|
+
backgroundColor: t.color.surfaceSunken,
|
|
31
|
+
overflow: "hidden" as const,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
switch (media.kind) {
|
|
35
|
+
case "image":
|
|
36
|
+
case "svg":
|
|
37
|
+
// Both render through <img>. SVG is loaded as an image rather than inlined
|
|
38
|
+
// so the document's scripts and styles cannot escape into this page.
|
|
39
|
+
return (
|
|
40
|
+
<View style={frame}>
|
|
41
|
+
<img
|
|
42
|
+
src={media.url}
|
|
43
|
+
alt={media.repoPath}
|
|
44
|
+
style={{
|
|
45
|
+
display: "block",
|
|
46
|
+
width: "100%",
|
|
47
|
+
maxHeight,
|
|
48
|
+
objectFit: "contain",
|
|
49
|
+
// A transparent PNG over a light surface should still read as
|
|
50
|
+
// transparent rather than as white.
|
|
51
|
+
background: "transparent",
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
</View>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
case "video":
|
|
58
|
+
return (
|
|
59
|
+
<View style={frame}>
|
|
60
|
+
<video
|
|
61
|
+
src={media.url}
|
|
62
|
+
controls
|
|
63
|
+
// Metadata only: an editor with several video fields should not pull
|
|
64
|
+
// megabytes for previews the author may never play.
|
|
65
|
+
preload="metadata"
|
|
66
|
+
style={{ display: "block", width: "100%", maxHeight }}
|
|
67
|
+
/>
|
|
68
|
+
</View>
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
case "audio":
|
|
72
|
+
return (
|
|
73
|
+
<View style={{ gap: t.space(2) }}>
|
|
74
|
+
<MediaChip media={media} />
|
|
75
|
+
<audio src={media.url} controls preload="metadata" style={{ width: "100%" }} />
|
|
76
|
+
</View>
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
case "pdf":
|
|
80
|
+
return (
|
|
81
|
+
<View style={{ gap: t.space(2) }}>
|
|
82
|
+
<View style={{ ...frame, height: maxHeight }}>
|
|
83
|
+
{/* <object> degrades to its children when the browser has no PDF
|
|
84
|
+
viewer, which <iframe> does not. */}
|
|
85
|
+
<object
|
|
86
|
+
data={media.url}
|
|
87
|
+
type="application/pdf"
|
|
88
|
+
style={{ display: "block", width: "100%", height: "100%" }}
|
|
89
|
+
>
|
|
90
|
+
<View style={{ padding: t.space(4), gap: t.space(2), alignItems: "center" }}>
|
|
91
|
+
<Text variant="xs" color="tertiary">This browser can't display PDFs inline.</Text>
|
|
92
|
+
<MediaDownloadLink media={media} label="Open PDF" />
|
|
93
|
+
</View>
|
|
94
|
+
</object>
|
|
95
|
+
</View>
|
|
96
|
+
<MediaDownloadLink media={media} label="Open in new tab" />
|
|
97
|
+
</View>
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
default:
|
|
101
|
+
return <MediaChip media={media} />;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// MediaChip is the fallback representation: an icon, the file name, its size,
|
|
106
|
+
// and a download affordance. It is what every non-previewable kind gets, and
|
|
107
|
+
// what the picker grid uses for anything that is not an image.
|
|
108
|
+
export function MediaChip({ media }: { media: MediaAsset }) {
|
|
109
|
+
const t = useTheme();
|
|
110
|
+
return (
|
|
111
|
+
<View
|
|
112
|
+
style={{
|
|
113
|
+
flexDirection: "row",
|
|
114
|
+
alignItems: "center",
|
|
115
|
+
gap: t.space(3),
|
|
116
|
+
padding: t.space(3),
|
|
117
|
+
borderWidth: 1,
|
|
118
|
+
borderColor: t.color.borderDefault,
|
|
119
|
+
borderRadius: t.radius.md,
|
|
120
|
+
backgroundColor: t.color.surfaceRaised,
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<Icon name={kindIcon(media.kind)} size={20} color={t.color.textSecondary} />
|
|
124
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
125
|
+
<Text variant="body" numberOfLines={1}>
|
|
126
|
+
{fileName(media.repoPath)}
|
|
127
|
+
</Text>
|
|
128
|
+
<Text variant="xs" color="tertiary">
|
|
129
|
+
{[media.mimeType, formatBytes(media.size), dimensions(media)].filter(Boolean).join(" · ")}
|
|
130
|
+
</Text>
|
|
131
|
+
</View>
|
|
132
|
+
<MediaDownloadLink media={media} />
|
|
133
|
+
</View>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function MediaDownloadLink({ media, label }: { media: MediaAsset; label?: string }) {
|
|
138
|
+
const t = useTheme();
|
|
139
|
+
return (
|
|
140
|
+
<a
|
|
141
|
+
href={media.url}
|
|
142
|
+
target="_blank"
|
|
143
|
+
rel="noreferrer"
|
|
144
|
+
// download hints the filename for kinds a browser would otherwise navigate
|
|
145
|
+
// to; for previewable kinds the new tab is the more useful behaviour.
|
|
146
|
+
download={media.kind === "file" ? fileName(media.repoPath) : undefined}
|
|
147
|
+
style={{
|
|
148
|
+
display: "inline-flex",
|
|
149
|
+
alignItems: "center",
|
|
150
|
+
gap: 6,
|
|
151
|
+
color: t.color.textSecondary,
|
|
152
|
+
textDecoration: "none",
|
|
153
|
+
fontSize: 12,
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
<Icon name="download" size={16} color={t.color.textSecondary} />
|
|
157
|
+
{label}
|
|
158
|
+
</a>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function fileName(repoPath: string): string {
|
|
163
|
+
const i = repoPath.lastIndexOf("/");
|
|
164
|
+
return i >= 0 ? repoPath.slice(i + 1) : repoPath;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function dimensions(media: MediaAsset): string {
|
|
168
|
+
return media.width && media.height ? `${media.width}×${media.height}` : "";
|
|
169
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Pressable, View } from "react-native";
|
|
3
|
+
import { useTheme } from "../ThemeProvider";
|
|
4
|
+
import { Text } from "./Text";
|
|
5
|
+
import { Icon } from "./Icon";
|
|
6
|
+
import type { IconName } from "../icons";
|
|
7
|
+
|
|
8
|
+
export type NavRowProps = {
|
|
9
|
+
label: string;
|
|
10
|
+
icon?: IconName;
|
|
11
|
+
count?: number | string;
|
|
12
|
+
/** Unresolved merge conflicts in this item, shown as a red pill. */
|
|
13
|
+
conflicts?: number;
|
|
14
|
+
meta?: string;
|
|
15
|
+
active?: boolean;
|
|
16
|
+
onPress?: () => void;
|
|
17
|
+
showChevron?: boolean;
|
|
18
|
+
divided?: boolean;
|
|
19
|
+
/** "compact" for the desktop sidebar, "large" for the mobile list. */
|
|
20
|
+
size?: "compact" | "large";
|
|
21
|
+
testID?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function NavRow({
|
|
25
|
+
label,
|
|
26
|
+
icon,
|
|
27
|
+
count,
|
|
28
|
+
conflicts,
|
|
29
|
+
meta,
|
|
30
|
+
active,
|
|
31
|
+
onPress,
|
|
32
|
+
showChevron,
|
|
33
|
+
divided,
|
|
34
|
+
size = "compact",
|
|
35
|
+
testID,
|
|
36
|
+
}: NavRowProps) {
|
|
37
|
+
const t = useTheme();
|
|
38
|
+
const large = size === "large";
|
|
39
|
+
const height = large ? 56 : t.control.lg;
|
|
40
|
+
const iconSize = large ? 20 : 16;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Pressable
|
|
44
|
+
testID={testID}
|
|
45
|
+
accessibilityRole="button"
|
|
46
|
+
accessibilityLabel={label}
|
|
47
|
+
// The active row was signalled only by a background colour, which conveys
|
|
48
|
+
// nothing to a screen reader — and left the current section unannounced.
|
|
49
|
+
aria-current={active ? "page" : undefined}
|
|
50
|
+
onPress={onPress}
|
|
51
|
+
style={({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
|
|
52
|
+
height,
|
|
53
|
+
// Content is inset, but the border sits on the full-width row so the
|
|
54
|
+
// divider still spans the whole screen on mobile.
|
|
55
|
+
paddingHorizontal: t.space(large ? 4 : 3),
|
|
56
|
+
flexDirection: "row",
|
|
57
|
+
alignItems: "center",
|
|
58
|
+
gap: t.space(3),
|
|
59
|
+
borderRadius: large ? 0 : t.radius.md,
|
|
60
|
+
backgroundColor:
|
|
61
|
+
active ? t.color.surfaceActive : hovered || pressed ? t.color.surfaceHover : "transparent",
|
|
62
|
+
borderBottomWidth: divided ? 1 : 0,
|
|
63
|
+
borderBottomColor: t.color.borderSubtle,
|
|
64
|
+
})}
|
|
65
|
+
>
|
|
66
|
+
{icon ? (
|
|
67
|
+
<Icon name={icon} size={iconSize} color={active ? t.color.textPrimary : t.color.textSecondary} />
|
|
68
|
+
) : null}
|
|
69
|
+
<Text
|
|
70
|
+
variant={large ? "bodyLg" : "body"}
|
|
71
|
+
weight={active ? "medium" : "regular"}
|
|
72
|
+
color={active ? "primary" : "primary"}
|
|
73
|
+
style={{ flex: 1 }}
|
|
74
|
+
numberOfLines={1}
|
|
75
|
+
>
|
|
76
|
+
{label}
|
|
77
|
+
</Text>
|
|
78
|
+
{meta ? (
|
|
79
|
+
<Text variant="monoSm" color="tertiary">{meta}</Text>
|
|
80
|
+
) : null}
|
|
81
|
+
{/* A red conflict pill takes the count's place while there are conflicts to
|
|
82
|
+
resolve — a collection with conflicts is the one that needs attention. */}
|
|
83
|
+
{conflicts && conflicts > 0 ? (
|
|
84
|
+
<View
|
|
85
|
+
testID={testID ? `${testID}-conflicts` : undefined}
|
|
86
|
+
style={{
|
|
87
|
+
minWidth: 18, height: 18, paddingHorizontal: t.space(1.5),
|
|
88
|
+
alignItems: "center", justifyContent: "center",
|
|
89
|
+
borderRadius: t.radius.pill, backgroundColor: t.color.diffDelBg,
|
|
90
|
+
borderWidth: 1, borderColor: t.color.diffDelFg,
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
<Text variant="monoSm" weight="semibold" color={t.color.diffDelFg}>{String(conflicts)}</Text>
|
|
94
|
+
</View>
|
|
95
|
+
) : count != null ? (
|
|
96
|
+
<Text variant={large ? "mono" : "monoSm"} color="tertiary">
|
|
97
|
+
{String(count)}
|
|
98
|
+
</Text>
|
|
99
|
+
) : null}
|
|
100
|
+
{showChevron ? (
|
|
101
|
+
<Icon name="chevronRight" size={large ? 20 : 16} color={t.color.textTertiary} />
|
|
102
|
+
) : null}
|
|
103
|
+
</Pressable>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { View, Pressable, ScrollView } from "react-native";
|
|
3
|
+
import { useTheme, useThemeMode, ThemeScope } from "../ThemeProvider";
|
|
4
|
+
import { lightTheme, darkTheme } from "../theme";
|
|
5
|
+
import { Text } from "./Text";
|
|
6
|
+
import { Icon } from "./Icon";
|
|
7
|
+
import { Spinner } from "./Spinner";
|
|
8
|
+
|
|
9
|
+
// One notification, shaped for display. Transport-agnostic: the frontend/mobile
|
|
10
|
+
// apps map GraphQL rows and the desktop maps REST rows into this.
|
|
11
|
+
export type NotificationItem = {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
body?: string;
|
|
15
|
+
level: "info" | "error";
|
|
16
|
+
read: boolean;
|
|
17
|
+
createdAt: string; // ISO 8601
|
|
18
|
+
author?: string; // display name/email of who caused it, when known
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// relativeTime renders a compact age ("just now", "5m", "3h", "2d").
|
|
22
|
+
function relativeTime(iso: string): string {
|
|
23
|
+
const then = Date.parse(iso);
|
|
24
|
+
if (Number.isNaN(then)) return "";
|
|
25
|
+
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
|
26
|
+
if (secs < 45) return "just now";
|
|
27
|
+
const mins = Math.floor(secs / 60);
|
|
28
|
+
if (mins < 60) return `${mins}m`;
|
|
29
|
+
const hours = Math.floor(mins / 60);
|
|
30
|
+
if (hours < 24) return `${hours}h`;
|
|
31
|
+
const days = Math.floor(hours / 24);
|
|
32
|
+
return `${days}d`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A single notification row: an unread dot + level-tinted title, body, and age.
|
|
36
|
+
function NotificationRow({
|
|
37
|
+
item,
|
|
38
|
+
divided,
|
|
39
|
+
onPress,
|
|
40
|
+
}: {
|
|
41
|
+
item: NotificationItem;
|
|
42
|
+
divided?: boolean;
|
|
43
|
+
onPress?: () => void;
|
|
44
|
+
}) {
|
|
45
|
+
const t = useTheme();
|
|
46
|
+
const titleColor = item.level === "error" ? t.color.diffDelFg : t.color.textPrimary;
|
|
47
|
+
return (
|
|
48
|
+
<Pressable
|
|
49
|
+
onPress={onPress}
|
|
50
|
+
style={({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
|
|
51
|
+
flexDirection: "row",
|
|
52
|
+
gap: t.space(2.5),
|
|
53
|
+
paddingHorizontal: t.space(4),
|
|
54
|
+
paddingVertical: t.space(3),
|
|
55
|
+
borderBottomWidth: divided ? 1 : 0,
|
|
56
|
+
borderBottomColor: t.color.borderSubtle,
|
|
57
|
+
backgroundColor: hovered || pressed ? t.color.surfaceHover : "transparent",
|
|
58
|
+
})}
|
|
59
|
+
>
|
|
60
|
+
<View
|
|
61
|
+
style={{
|
|
62
|
+
width: 8,
|
|
63
|
+
height: 8,
|
|
64
|
+
marginTop: 5,
|
|
65
|
+
borderRadius: t.radius.pill,
|
|
66
|
+
backgroundColor: item.read ? "transparent" : t.color.diffDelFg,
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
70
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
71
|
+
<Text
|
|
72
|
+
variant="sm"
|
|
73
|
+
weight={item.read ? "medium" : "semibold"}
|
|
74
|
+
color={titleColor}
|
|
75
|
+
numberOfLines={2}
|
|
76
|
+
style={{ flex: 1 }}
|
|
77
|
+
>
|
|
78
|
+
{item.title}
|
|
79
|
+
</Text>
|
|
80
|
+
<Text variant="monoSm" color="tertiary">{relativeTime(item.createdAt)}</Text>
|
|
81
|
+
</View>
|
|
82
|
+
{item.body ? (
|
|
83
|
+
<Text variant="monoSm" color="secondary" numberOfLines={2}>{item.body}</Text>
|
|
84
|
+
) : null}
|
|
85
|
+
{item.author ? (
|
|
86
|
+
<Text variant="monoSm" color="tertiary" numberOfLines={1}>{`by ${item.author}`}</Text>
|
|
87
|
+
) : null}
|
|
88
|
+
</View>
|
|
89
|
+
</Pressable>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type NotificationBellProps = {
|
|
94
|
+
notifications: NotificationItem[];
|
|
95
|
+
unreadCount: number;
|
|
96
|
+
// Called when the menu opens — the host marks the shown notifications read
|
|
97
|
+
// and/or refreshes the list.
|
|
98
|
+
onOpen?: () => void;
|
|
99
|
+
// Called when "Read more" is pressed — the host navigates to the full screen.
|
|
100
|
+
onViewAll?: () => void;
|
|
101
|
+
// Work is running in the background for whatever this bell is scoped to — an
|
|
102
|
+
// import over the open branch, today. The bell becomes a spinner for as long
|
|
103
|
+
// as it lasts: the outcome arrives as a notification, so the icon that will
|
|
104
|
+
// announce it is the honest place to say one is coming. The menu keeps
|
|
105
|
+
// working throughout; only the glyph changes.
|
|
106
|
+
busy?: boolean;
|
|
107
|
+
// What the spinner is waiting on, for the accessibility label. Ignored unless
|
|
108
|
+
// `busy`.
|
|
109
|
+
busyLabel?: string;
|
|
110
|
+
size?: "sm" | "md" | "lg";
|
|
111
|
+
testID?: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// NotificationBell is the bell button with an unread dot and a popup menu
|
|
115
|
+
// showing up to the last 5 notifications plus a "Read more" footer. It renders
|
|
116
|
+
// identically on web (react-native-web) and native.
|
|
117
|
+
export function NotificationBell({
|
|
118
|
+
notifications,
|
|
119
|
+
unreadCount,
|
|
120
|
+
onOpen,
|
|
121
|
+
onViewAll,
|
|
122
|
+
busy,
|
|
123
|
+
busyLabel = "Working",
|
|
124
|
+
size = "sm",
|
|
125
|
+
testID,
|
|
126
|
+
}: NotificationBellProps) {
|
|
127
|
+
const t = useTheme();
|
|
128
|
+
// The bell button sits in the dark top-bar chrome, but its dropdown should
|
|
129
|
+
// match the page, not the bar — so the menu renders under the app theme.
|
|
130
|
+
const { mode } = useThemeMode();
|
|
131
|
+
const pageTheme = mode === "dark" ? darkTheme : lightTheme;
|
|
132
|
+
const [open, setOpen] = useState(false);
|
|
133
|
+
const box = t.control[size];
|
|
134
|
+
const recent = notifications.slice(0, 5);
|
|
135
|
+
|
|
136
|
+
function toggle() {
|
|
137
|
+
const next = !open;
|
|
138
|
+
setOpen(next);
|
|
139
|
+
if (next) onOpen?.();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<View style={{ position: "relative" }}>
|
|
144
|
+
<Pressable
|
|
145
|
+
testID={testID ?? "notification-bell"}
|
|
146
|
+
accessibilityRole="button"
|
|
147
|
+
accessibilityLabel={
|
|
148
|
+
busy
|
|
149
|
+
? `${busyLabel}. Notifications`
|
|
150
|
+
: unreadCount > 0
|
|
151
|
+
? `Notifications, ${unreadCount} unread`
|
|
152
|
+
: "Notifications"
|
|
153
|
+
}
|
|
154
|
+
onPress={toggle}
|
|
155
|
+
style={({ pressed }) => ({
|
|
156
|
+
width: box,
|
|
157
|
+
height: box,
|
|
158
|
+
alignItems: "center",
|
|
159
|
+
justifyContent: "center",
|
|
160
|
+
borderRadius: t.radius.md,
|
|
161
|
+
borderWidth: 1,
|
|
162
|
+
borderColor: open ? t.color.borderDefault : "transparent",
|
|
163
|
+
backgroundColor: open || pressed ? t.color.surfaceHover : "transparent",
|
|
164
|
+
})}
|
|
165
|
+
>
|
|
166
|
+
{busy ? (
|
|
167
|
+
<Spinner testID="notification-busy" size={size === "sm" ? 16 : 18} color={t.color.textSecondary} />
|
|
168
|
+
) : (
|
|
169
|
+
<Icon name="bell" size={size === "sm" ? 16 : 18} color={t.color.textSecondary} />
|
|
170
|
+
)}
|
|
171
|
+
{/* The unread dot rides on the spinner too. Unread notifications don't
|
|
172
|
+
stop being unread because something else is running. */}
|
|
173
|
+
{unreadCount > 0 ? (
|
|
174
|
+
<View
|
|
175
|
+
testID="notification-unread-dot"
|
|
176
|
+
style={{
|
|
177
|
+
position: "absolute",
|
|
178
|
+
top: 5,
|
|
179
|
+
right: 5,
|
|
180
|
+
width: 8,
|
|
181
|
+
height: 8,
|
|
182
|
+
borderRadius: t.radius.pill,
|
|
183
|
+
backgroundColor: t.color.diffDelFg,
|
|
184
|
+
borderWidth: 1,
|
|
185
|
+
borderColor: t.color.surfacePage,
|
|
186
|
+
}}
|
|
187
|
+
/>
|
|
188
|
+
) : null}
|
|
189
|
+
</Pressable>
|
|
190
|
+
|
|
191
|
+
{open ? (
|
|
192
|
+
<ThemeScope theme={pageTheme}>
|
|
193
|
+
{/* Outside-press backdrop. `position: fixed` (web) covers the viewport;
|
|
194
|
+
on native the bell toggles the menu closed instead. */}
|
|
195
|
+
<Pressable
|
|
196
|
+
onPress={() => setOpen(false)}
|
|
197
|
+
// @ts-expect-error react-native-web accepts position: fixed
|
|
198
|
+
style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, zIndex: 40 }}
|
|
199
|
+
/>
|
|
200
|
+
<View
|
|
201
|
+
testID="notification-menu"
|
|
202
|
+
style={{
|
|
203
|
+
position: "absolute",
|
|
204
|
+
top: box + t.space(1),
|
|
205
|
+
right: 0,
|
|
206
|
+
width: 320,
|
|
207
|
+
maxWidth: 320,
|
|
208
|
+
zIndex: 50,
|
|
209
|
+
backgroundColor: pageTheme.color.surfaceRaised,
|
|
210
|
+
borderWidth: 1,
|
|
211
|
+
borderColor: pageTheme.color.borderDefault,
|
|
212
|
+
borderRadius: t.radius.lg,
|
|
213
|
+
overflow: "hidden",
|
|
214
|
+
}}
|
|
215
|
+
>
|
|
216
|
+
<View
|
|
217
|
+
style={{
|
|
218
|
+
flexDirection: "row",
|
|
219
|
+
alignItems: "center",
|
|
220
|
+
justifyContent: "space-between",
|
|
221
|
+
paddingHorizontal: t.space(4),
|
|
222
|
+
paddingVertical: t.space(3),
|
|
223
|
+
borderBottomWidth: 1,
|
|
224
|
+
borderBottomColor: pageTheme.color.borderSubtle,
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
<Text variant="sm" weight="semibold">Notifications</Text>
|
|
228
|
+
{unreadCount > 0 ? (
|
|
229
|
+
<Text variant="monoSm" color="tertiary">{`${unreadCount} unread`}</Text>
|
|
230
|
+
) : null}
|
|
231
|
+
</View>
|
|
232
|
+
|
|
233
|
+
{recent.length === 0 ? (
|
|
234
|
+
<View style={{ padding: t.space(5), alignItems: "center" }}>
|
|
235
|
+
<Text variant="sm" color="tertiary" testID="notification-empty">You're all caught up</Text>
|
|
236
|
+
</View>
|
|
237
|
+
) : (
|
|
238
|
+
<ScrollView style={{ maxHeight: 360 }}>
|
|
239
|
+
{recent.map((n, i) => (
|
|
240
|
+
<NotificationRow key={n.id} item={n} divided={i < recent.length - 1} />
|
|
241
|
+
))}
|
|
242
|
+
</ScrollView>
|
|
243
|
+
)}
|
|
244
|
+
|
|
245
|
+
<Pressable
|
|
246
|
+
testID="notification-read-more"
|
|
247
|
+
onPress={() => {
|
|
248
|
+
setOpen(false);
|
|
249
|
+
onViewAll?.();
|
|
250
|
+
}}
|
|
251
|
+
style={({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
|
|
252
|
+
paddingVertical: t.space(3),
|
|
253
|
+
alignItems: "center",
|
|
254
|
+
borderTopWidth: 1,
|
|
255
|
+
borderTopColor: pageTheme.color.borderSubtle,
|
|
256
|
+
backgroundColor: hovered || pressed ? pageTheme.color.surfaceHover : "transparent",
|
|
257
|
+
})}
|
|
258
|
+
>
|
|
259
|
+
<Text variant="sm" weight="medium">Read more</Text>
|
|
260
|
+
</Pressable>
|
|
261
|
+
</View>
|
|
262
|
+
</ThemeScope>
|
|
263
|
+
) : null}
|
|
264
|
+
</View>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export type NotificationListProps = {
|
|
269
|
+
notifications: NotificationItem[];
|
|
270
|
+
emptyMessage?: string;
|
|
271
|
+
onPressItem?: (item: NotificationItem) => void;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// NotificationList is the full-screen list for a context (workspace/repository),
|
|
275
|
+
// rendered by the app when the user chooses "Read more".
|
|
276
|
+
export function NotificationList({
|
|
277
|
+
notifications,
|
|
278
|
+
emptyMessage = "No notifications yet",
|
|
279
|
+
onPressItem,
|
|
280
|
+
}: NotificationListProps) {
|
|
281
|
+
const t = useTheme();
|
|
282
|
+
if (notifications.length === 0) {
|
|
283
|
+
return (
|
|
284
|
+
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
|
|
285
|
+
<Text variant="body" color="tertiary" testID="notification-list-empty">{emptyMessage}</Text>
|
|
286
|
+
</View>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return (
|
|
290
|
+
<ScrollView style={{ flex: 1 }} testID="notification-list">
|
|
291
|
+
{notifications.map((n, i) => (
|
|
292
|
+
<NotificationRow
|
|
293
|
+
key={n.id}
|
|
294
|
+
item={n}
|
|
295
|
+
divided={i < notifications.length - 1}
|
|
296
|
+
onPress={onPressItem ? () => onPressItem(n) : undefined}
|
|
297
|
+
/>
|
|
298
|
+
))}
|
|
299
|
+
</ScrollView>
|
|
300
|
+
);
|
|
301
|
+
}
|