diff --git a/web/src/lib/components/icons/UserRoundArrowForward.svelte b/web/src/lib/components/icons/UserRoundArrowForward.svelte new file mode 100644 index 00000000..8ba3d47b --- /dev/null +++ b/web/src/lib/components/icons/UserRoundArrowForward.svelte @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/web/src/lib/components/notifications/NotificationItem.stories.svelte b/web/src/lib/components/notifications/NotificationItem.stories.svelte new file mode 100644 index 00000000..353ff4d0 --- /dev/null +++ b/web/src/lib/components/notifications/NotificationItem.stories.svelte @@ -0,0 +1,64 @@ + + + + {#snippet template(args)} +
+ {/snippet} +
+ + + {#snippet template(args)} +
+ {/snippet} +
+ + + {#snippet template(args)} +
+ {/snippet} +
+ + + {#snippet template(args)} +
+ {/snippet} +
+ + + {#snippet template(args)} +
+ {/snippet} +
+ + + {#snippet template()} +
+ {#each notifications as notification (notification.id)} + {}} /> + {/each} +
+ {/snippet} +
diff --git a/web/src/lib/components/notifications/NotificationItem.svelte b/web/src/lib/components/notifications/NotificationItem.svelte new file mode 100644 index 00000000..6954b05f --- /dev/null +++ b/web/src/lib/components/notifications/NotificationItem.svelte @@ -0,0 +1,118 @@ + + + + + onToggleRead?.(notification.id, true)}> + diff --git a/web/src/lib/components/notifications/NotificationList.stories.svelte b/web/src/lib/components/notifications/NotificationList.stories.svelte new file mode 100644 index 00000000..8bb1f109 --- /dev/null +++ b/web/src/lib/components/notifications/NotificationList.stories.svelte @@ -0,0 +1,36 @@ + + + + + !n.read).length }} +/> + + !n.read).length + }} +/> + + + + diff --git a/web/src/lib/components/notifications/NotificationList.svelte b/web/src/lib/components/notifications/NotificationList.svelte new file mode 100644 index 00000000..a5625ed5 --- /dev/null +++ b/web/src/lib/components/notifications/NotificationList.svelte @@ -0,0 +1,84 @@ + + + +
+ {#if title || unreadCount > 0} +
+

+ {title} +

+ {#if unreadCount > 0} + + {formatCount(unreadCount)} + + {/if} +
+ {/if} + + {#if empty} +
+ +
+ {:else} + {#each sections as section (section.label)} + {#if section.items.length > 0} +
+ {section.label} +
+ {#each section.items as notification (notification.id)} + + {/each} + {/if} + {/each} + {/if} +
diff --git a/web/src/lib/components/notifications/NotificationToolbar.stories.svelte b/web/src/lib/components/notifications/NotificationToolbar.stories.svelte new file mode 100644 index 00000000..6c105111 --- /dev/null +++ b/web/src/lib/components/notifications/NotificationToolbar.stories.svelte @@ -0,0 +1,17 @@ + + + + + + + diff --git a/web/src/lib/components/notifications/NotificationToolbar.svelte b/web/src/lib/components/notifications/NotificationToolbar.svelte new file mode 100644 index 00000000..e7ba8d90 --- /dev/null +++ b/web/src/lib/components/notifications/NotificationToolbar.svelte @@ -0,0 +1,63 @@ + + +
+
+ + + + + + +
+ + +
diff --git a/web/src/lib/components/notifications/mock.ts b/web/src/lib/components/notifications/mock.ts new file mode 100644 index 00000000..f073e1f0 --- /dev/null +++ b/web/src/lib/components/notifications/mock.ts @@ -0,0 +1,179 @@ +// fixture notifications for the page and the stories. the xrpc method behind +// this page hands back at-uris rather than repo names and titles, so nothing +// reads from it yet. + +import type { NotificationSummary } from "./types"; + +const minutes = (n: number) => n * 60_000; + +// anchored on import so the date buckets stay populated whenever this runs +const base = Date.now(); +const ago = (mins: number) => new Date(base - minutes(mins)).toISOString(); + +const alice = { did: "did:plc:oky5czdrnfjpqslsw2a5iclo", handle: "alice.tngl.sh" }; +const bob = { did: "did:plc:3p7ejjxnufohsygt5vbyxb2i", handle: "bob.tngl.sh" }; +const carol = { did: "did:plc:7hbfxfxqxcbszrfhbvlnvxzq", handle: "carol.tngl.sh" }; +const dave = { did: "did:plc:kx4mzvhqrjpwqmvlbdxdlbnn", handle: "dave.tngl.sh" }; + +const core = { ownerHandle: "tangled.org", name: "core" }; +const knot = { ownerHandle: "tangled.org", name: "knot" }; + +export const notifications: NotificationSummary[] = [ + { + id: 1, + type: "user_mentioned", + read: false, + createdAt: ago(4), + actor: alice, + repo: core, + subject: { + kind: "pull", + number: 214, + title: "spindle: retry a workflow after a knot timeout" + } + }, + { + id: 2, + type: "pull_created", + read: false, + createdAt: ago(26), + actor: bob, + repo: core, + subject: { kind: "pull", number: 213, title: "web: port the notifications page to svelte" } + }, + { + id: 3, + type: "repo_starred", + read: false, + createdAt: ago(51), + actor: carol, + repo: core + }, + { + id: 4, + type: "issue_commented", + read: true, + createdAt: ago(190), + actor: dave, + repo: knot, + subject: { + kind: "issue", + number: 87, + title: "packfile upload stalls on large repositories" + } + }, + { + id: 5, + type: "followed", + read: false, + createdAt: ago(320), + actor: dave + }, + { + id: 6, + type: "issue_assigned", + read: true, + createdAt: ago(60 * 26), + actor: alice, + repo: core, + subject: { + kind: "issue", + number: 402, + title: "settings: the email tab drops its drill-down" + } + }, + { + id: 7, + type: "pull_merged", + read: true, + createdAt: ago(60 * 30), + actor: bob, + repo: core, + subject: { + kind: "pull", + number: 208, + title: "appview: resolve handles inside the timeline feed" + } + }, + { + id: 8, + type: "followed", + read: true, + createdAt: ago(60 * 40), + actor: carol + }, + { + id: 9, + type: "issue_created", + read: false, + createdAt: ago(60 * 55), + actor: carol, + repo: knot, + subject: { + kind: "issue", + number: 91, + title: "git hooks reject a push with no error message" + } + }, + { + id: 10, + type: "pull_closed", + read: true, + createdAt: ago(60 * 24 * 5), + actor: dave, + repo: core, + subject: { kind: "pull", number: 199, title: "drop the legacy avatar worker" } + }, + { + id: 11, + type: "repo_starred", + read: true, + createdAt: ago(60 * 24 * 6), + actor: alice, + repo: knot + }, + { + id: 12, + type: "issue_closed", + read: true, + createdAt: ago(60 * 24 * 9), + actor: bob, + repo: core, + subject: { kind: "issue", number: 377, title: "dark mode inverts the button group seam" } + }, + { + id: 13, + type: "pull_commented", + read: true, + createdAt: ago(60 * 24 * 12), + actor: alice, + repo: knot, + subject: { + kind: "pull", + number: 64, + title: "knot: verify the owner signature before a fetch" + } + }, + { + id: 14, + type: "issue_reopen", + read: true, + createdAt: ago(60 * 24 * 15), + actor: carol, + repo: core, + subject: { + kind: "issue", + number: 311, + title: "pipelines tab shows a stale workflow status" + } + }, + { + id: 15, + type: "pull_unassigned", + read: true, + createdAt: ago(60 * 24 * 21), + actor: bob, + repo: core, + subject: { kind: "pull", number: 180, title: "camo: cache remote images for a week" } + } +]; diff --git a/web/src/lib/components/notifications/notification.ts b/web/src/lib/components/notifications/notification.ts new file mode 100644 index 00000000..ee117b55 --- /dev/null +++ b/web/src/lib/components/notifications/notification.ts @@ -0,0 +1,153 @@ +import type { Component } from "svelte"; +import type { SvelteHTMLElements } from "svelte/elements"; +import AtSign from "$icon/at-sign"; +import Ban from "$icon/ban"; +import CircleDot from "$icon/circle-dot"; +import GitMerge from "$icon/git-merge"; +import GitPullRequestClosed from "$icon/git-pull-request-closed"; +import GitPullRequestCreate from "$icon/git-pull-request-create"; +import MessageSquare from "$icon/message-square"; +import Star from "$icon/star"; +import UserPlus from "$icon/user-plus"; +import UserRoundMinus from "$icon/user-round-minus"; +import UserRoundArrowForward from "$lib/components/icons/UserRoundArrowForward.svelte"; +import type { + NotificationCategory, + NotificationGroups, + NotificationSummary, + NotificationType +} from "./types"; + +type IconComponent = Component; + +export const SOCIAL_TYPES: NotificationType[] = ["repo_starred", "followed"]; + +const CATEGORIES: Record = { + repo_starred: "social", + followed: "social", + issue_created: "work", + issue_commented: "work", + issue_closed: "work", + issue_reopen: "work", + issue_assigned: "work", + issue_unassigned: "work", + pull_created: "work", + pull_commented: "work", + pull_merged: "work", + pull_closed: "work", + pull_reopen: "work", + pull_assigned: "work", + pull_unassigned: "work", + user_mentioned: "work" +}; + +const ICONS: Record = { + repo_starred: Star, + followed: UserPlus, + issue_created: CircleDot, + issue_commented: MessageSquare, + issue_closed: Ban, + issue_reopen: CircleDot, + issue_assigned: UserRoundArrowForward, + issue_unassigned: UserRoundMinus, + pull_created: GitPullRequestCreate, + pull_commented: MessageSquare, + pull_merged: GitMerge, + pull_closed: GitPullRequestClosed, + pull_reopen: GitPullRequestCreate, + pull_assigned: UserRoundArrowForward, + pull_unassigned: UserRoundMinus, + user_mentioned: AtSign +}; + +// the design system has no purple, and a merged pull already reads as info on +// PullStatePill, so merges land on the same token here. +const TONES: Record = { + repo_starred: "text-foreground-warning", + followed: "text-foreground-muted", + issue_created: "text-foreground-success", + issue_commented: "text-foreground-muted", + issue_closed: "text-foreground-muted", + issue_reopen: "text-foreground-success", + issue_assigned: "text-foreground-info", + issue_unassigned: "text-foreground-muted", + pull_created: "text-foreground-success", + pull_commented: "text-foreground-muted", + pull_merged: "text-foreground-info", + pull_closed: "text-foreground-danger", + pull_reopen: "text-foreground-success", + pull_assigned: "text-foreground-info", + pull_unassigned: "text-foreground-muted", + user_mentioned: "text-foreground-info" +}; + +const PHRASES: Record = { + repo_starred: "starred", + followed: "followed you", + issue_created: "opened an issue on", + issue_commented: "commented on an issue on", + issue_closed: "closed an issue on", + issue_reopen: "reopened an issue on", + issue_assigned: "assigned you to an issue on", + issue_unassigned: "unassigned you from an issue on", + pull_created: "created a PR on", + pull_commented: "commented on a PR on", + pull_merged: "merged a PR on", + pull_closed: "closed a PR on", + pull_reopen: "reopened a PR on", + pull_assigned: "assigned you to a PR on", + pull_unassigned: "unassigned you from a PR on", + user_mentioned: "mentioned you in" +}; + +export const notificationCategory = (type: NotificationType): NotificationCategory => + CATEGORIES[type]; + +export const notificationIcon = (type: NotificationType): IconComponent => ICONS[type]; + +export const notificationTone = (type: NotificationType): string => TONES[type]; + +export const notificationPhrase = (notification: NotificationSummary): string => { + if (notification.type === "user_mentioned" && notification.subject) { + return notification.subject.kind === "issue" + ? "mentioned you on an issue in" + : "mentioned you on a pull request in"; + } + return PHRASES[notification.type]; +}; + +// the subject of the phrase, which the appview prints as owner/name after it +export const notificationTarget = (notification: NotificationSummary): string => + notification.repo ? `${notification.repo.ownerHandle}/${notification.repo.name}` : ""; + +export const notificationHref = (notification: NotificationSummary): string => { + if (notification.type === "followed") return `/${notification.actor.handle}`; + const repo = notification.repo; + if (!repo) return ""; + const base = `/${repo.ownerHandle}/${repo.name}`; + const subject = notification.subject; + if (!subject) return base; + return `${base}/${subject.kind === "issue" ? "issues" : "pulls"}/${subject.number}`; +}; + +// today, the six days before it, then everything older, same buckets the go page +// builds in GroupNotificationsByDate +export const groupNotificationsByDate = ( + notifications: NotificationSummary[], + now: Date = new Date() +): NotificationGroups => { + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const weekStart = todayStart - 6 * 24 * 60 * 60 * 1000; + + const groups: NotificationGroups = { today: [], thisWeek: [], older: [] }; + for (const notification of notifications) { + const created = Date.parse(notification.createdAt); + if (created >= todayStart) groups.today.push(notification); + else if (created >= weekStart) groups.thisWeek.push(notification); + else groups.older.push(notification); + } + return groups; +}; + +export const isEmpty = (groups: NotificationGroups): boolean => + groups.today.length === 0 && groups.thisWeek.length === 0 && groups.older.length === 0; diff --git a/web/src/lib/components/notifications/types.ts b/web/src/lib/components/notifications/types.ts new file mode 100644 index 00000000..74ed4285 --- /dev/null +++ b/web/src/lib/components/notifications/types.ts @@ -0,0 +1,57 @@ +// mirrors org.tangled.temp.notification.listNotifications#notification, plus the +// repo and issue/pull details the appview page renders. the xrpc method only +// hands back at-uris for those, so a real loader has to hydrate them from bobbin +// before it can build this shape. +export type NotificationType = + | "repo_starred" + | "followed" + | "issue_created" + | "issue_commented" + | "issue_closed" + | "issue_reopen" + | "issue_assigned" + | "issue_unassigned" + | "pull_created" + | "pull_commented" + | "pull_merged" + | "pull_closed" + | "pull_reopen" + | "pull_assigned" + | "pull_unassigned" + | "user_mentioned"; + +export type NotificationCategory = "work" | "social"; + +export type ReadFilter = "inbox" | "unread"; + +export interface NotificationActor { + did: string; + handle: string; +} + +export interface NotificationRepo { + ownerHandle: string; + name: string; +} + +export interface NotificationSubject { + kind: "issue" | "pull"; + number: number; + title: string; +} + +export interface NotificationSummary { + id: number; + type: NotificationType; + read: boolean; + createdAt: string; + actor: NotificationActor; + repo?: NotificationRepo; + subject?: NotificationSubject; +} + +export interface NotificationGroups { + today: NotificationSummary[]; + thisWeek: NotificationSummary[]; + older: NotificationSummary[]; +} diff --git a/web/src/lib/components/ui/EmptyState.svelte b/web/src/lib/components/ui/EmptyState.svelte index 1404a216..3fc381df 100644 --- a/web/src/lib/components/ui/EmptyState.svelte +++ b/web/src/lib/components/ui/EmptyState.svelte @@ -29,7 +29,7 @@ {shadow} {background} padding="none" - class="flex flex-col items-center justify-center gap-6 p-12 typography-paragraph-large text-foreground-subtle italic" + class="flex flex-col items-center justify-center gap-6 p-12 typography-paragraph-large text-foreground-subtle" > {#if Icon}