~james/core

core/web/src/lib/components/notifications/NotificationItem.svelte -rw-r--r-- 7.2 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
<script module lang="ts">
	import { tv, type VariantProps } from "tailwind-variants";

	export const notificationItem = tv({
		slots: {
			// the row subscribes to NotificationList's columns, so every row shares one
			// lead column width and the descriptions line up down the whole list. it
			// only lays out correctly inside that grid.
			root: "group/notification relative isolate col-span-full grid grid-cols-subgrid items-center gap-y-2xs rounded-(--notification-radius) px-xs py-sm hover:bg-background-canvas md:px-sm dark:hover:bg-background-inset",
			lead: "relative col-start-1 row-start-1",
			badge: "absolute -right-1.5 -bottom-1.5 flex size-5 items-center justify-center rounded-full bg-background-default group-hover/notification:bg-background-canvas dark:group-hover/notification:bg-background-inset",
			icon: "size-3 shrink-0",
			title: "col-start-2 row-start-1 flex min-w-0 items-center typography-paragraph-small text-inherit no-underline after:absolute after:inset-0 after:z-1 hover:no-underline",
			slot: "flex shrink-0 transition-[width,margin-right] duration-200 ease-out motion-reduce:transition-none",
			dot: "size-2 shrink-0 rounded-full bg-background-info-emphasis transition-transform motion-reduce:transition-none",
			heading:
				"flex min-w-0 items-center overflow-hidden *:not-first:before:whitespace-pre *:not-first:before:content-['_']",
			number: "shrink-0",
			text: "truncate",
			subtitle:
				"col-start-2 row-start-2 flex min-w-0 overflow-hidden typography-paragraph-small text-foreground-subtle *:not-first:before:whitespace-pre *:not-first:before:content-['_']",
			actor: "min-w-0 truncate",
			phrase: "min-w-0 truncate",
			repo: "min-w-0 truncate",
			action: "col-start-3 row-start-1 flex h-4 items-center justify-end",
			toggle: "relative z-10 hidden items-center text-foreground-placeholder hover:cursor-pointer hover:text-foreground-muted md:group-hover/notification:flex",
			time: "col-start-3 text-right typography-paragraph-small whitespace-nowrap text-foreground-subtle"
		},
		variants: {
			single: {
				true: { time: "row-start-1 md:group-hover/notification:invisible" },
				false: { root: "grid-rows-[auto_auto]", lead: "row-span-2", time: "row-start-2" }
			},
			read: {
				true: { title: "text-foreground-subtle", slot: "mr-0 w-0", dot: "scale-0" },
				false: {
					title: "font-medium text-foreground-default",
					slot: "mr-xs w-2",
					dot: "scale-100"
				}
			}
		},
		defaultVariants: {
			single: false,
			read: true
		}
	});

	export type NotificationItemVariants = VariantProps<typeof notificationItem>;
</script>

<script lang="ts">
	import { flushSync } from "svelte";
	import type { Attachment } from "svelte/attachments";
	import Mail from "$icon/mail";
	import MailOpen from "$icon/mail-open";
	import { buttonIcon } from "#lib/components/ui/Button.svelte";
	import Avatar from "#lib/components/ui/Avatar.svelte";
	import TimeAgo from "#lib/components/ui/TimeAgo.svelte";
	import { EXIT_DURATION, EXIT_EASING, POP_DURATION, POP_EASING } from "#lib/motion/spring.ts";
	import { profileIdentifier } from "#lib/api/identity.ts";
	import {
		notificationHref,
		notificationIcon,
		notificationPhrase,
		notificationTarget,
		notificationTone
	} from "#lib/components/notifications/notification.ts";
	import type { NotificationSummary } from "#lib/components/notifications/types.ts";

	interface Props {
		notification: NotificationSummary;
		onToggleRead?: (uri: string, read: boolean) => void;
	}

	let { notification, onToggleRead }: Props = $props();

	const single = $derived(!notification.subject);
	const style = $derived(notificationItem({ single, read: notification.read }));
	const Icon = $derived(notificationIcon(notification.type));
	// resolve() would mangle the raw at:// uri here (collapses "//"), so it's used as-is
	const href = $derived(notificationHref(notification));
	const actor = $derived(profileIdentifier(notification.actor.handle, notification.actor.did));
	let includingOwner = $state(true);
	const target = $derived(notificationTarget(notification, includingOwner));

	const truncates = (element: Element) =>
		[...element.querySelectorAll("*")].some(
			(child) =>
				getComputedStyle(child).textOverflow === "ellipsis" &&
				child.scrollWidth > child.clientWidth + 1
		);

	const fit: Attachment<HTMLElement> = (line) => {
		const observer = new ResizeObserver(() => {
			includingOwner = true;
			flushSync();
			if (!truncates(line)) return;
			includingOwner = false;
			flushSync();
		});
		observer.observe(line);
		for (const child of line.children) observer.observe(child);
		return () => observer.disconnect();
	};

	const toggle = () => onToggleRead?.(notification.uri, !notification.read);

	const open = (event: MouseEvent & { currentTarget: HTMLAnchorElement }) => {
		onToggleRead?.(notification.uri, true);
		const popover = event.currentTarget.closest("[popover]");
		if (popover instanceof HTMLElement) popover.hidePopover();
	};
</script>

{#snippet summary()}
	<span class={style.actor()} title={actor}>{actor}</span>
	<span class={style.phrase()}>{notificationPhrase(notification, true)}</span>
	{#if target}
		<span class={style.repo()} title={notificationTarget(notification)}>{target}</span>
	{/if}
{/snippet}

<div
	data-testid="notificationRow"
	data-uri={notification.uri}
	data-type={notification.type}
	data-notification
	class={style.root()}
>
	<div class={style.lead()}>
		<Avatar
			did={notification.actor.did}
			handle={notification.actor.handle}
			size="full"
			pixels={32}
			class="size-8"
		/>
		<span class={style.badge()} aria-hidden="true">
			<Icon class={style.icon({ class: notificationTone(notification.type) })} />
		</span>
	</div>

	<!-- the href embeds an at:// subject uri whose // resolve() would collapse -->
	<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
	<a data-testid="notificationPhraseLink" {href} class={style.title()} onclick={open}>
		<span class={style.slot()} aria-hidden="true">
			<span
				class={style.dot()}
				style:transition-duration="{notification.read ? EXIT_DURATION : POP_DURATION}ms"
				style:transition-timing-function={notification.read ? EXIT_EASING : POP_EASING}
			></span>
		</span>
		{#if notification.subject}
			<span class={style.heading()}>
				<span class={style.number()}>#{notification.subject.number}</span>
				{#if notification.subject.title}
					<span class={style.text()} title={notification.subject.title}
						>{notification.subject.title}</span
					>
				{/if}
			</span>
		{:else}
			<span class={style.heading()} {@attach notification.repo && fit}>
				{@render summary()}
			</span>
		{/if}
	</a>

	<div class={style.action()}>
		{#if onToggleRead}
			<button
				type="button"
				class={style.toggle()}
				title={notification.read ? "Mark as unread" : "Mark as read"}
				aria-label={notification.read ? "Mark as unread" : "Mark as read"}
				onclick={toggle}
			>
				{#if notification.read}
					<Mail class={buttonIcon({ size: "small" })} aria-hidden="true" />
				{:else}
					<MailOpen class={buttonIcon({ size: "small" })} aria-hidden="true" />
				{/if}
			</button>
		{/if}
	</div>

	{#if !single}
		<div class={style.subtitle()} {@attach notification.repo && fit}>
			{@render summary()}
		</div>
	{/if}

	<TimeAgo value={notification.createdAt} class={style.time()} />
</div>