BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { useModerationDecision } from "$/components/moderation/hooks/useModerationDecision";
2import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar";
3import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow";
4import { useThreadOverlayNavigation } from "$/components/posts/hooks/useThreadOverlayNavigation";
5import { useAppSession } from "$/contexts/app-session";
6import { listNotifications, updateSeen } from "$/lib/api/notifications";
7import { NOTIFICATIONS_UNREAD_COUNT_EVENT } from "$/lib/constants/events";
8import { getAvatarLabel, getDisplayName } from "$/lib/feeds";
9import { collectModerationLabels } from "$/lib/moderation";
10import { buildPostRoute } from "$/lib/post-routes";
11import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile";
12import type { ListNotificationsResponse, NotificationReason, NotificationView, ProfileViewBasic } from "$/lib/types";
13import { formatRelativeTime } from "$/lib/utils/text";
14import { normalizeError } from "$/lib/utils/text";
15import { listen } from "@tauri-apps/api/event";
16import * as logger from "@tauri-apps/plugin-log";
17import { createMemo, createSignal, For, Match, onCleanup, onMount, type ParentProps, Show, Switch } from "solid-js";
18import { Motion, Presence } from "solid-motionone";
19import { Icon } from "../shared/Icon";
20import { notificationReasonCopy, notificationReasonIcon } from "./notification-copy";
21import {
22 buildAllNotificationsFeed,
23 groupActivityNotifications,
24 isMentionNotification,
25 splitByReadState,
26 toSingleFeedItems,
27} from "./notification-grouping";
28import type {
29 GroupedNotificationFeedItem,
30 NotificationFeedItem,
31 SingleNotificationFeedItem,
32} from "./notification-grouping";
33import { NotificationItem } from "./NotificationItem";
34
35type Tab = "all" | "mentions" | "activity";
36
37function hasUnreadNotifications(items: NotificationView[]) {
38 return items.some((notification) => !notification.isRead);
39}
40
41function groupedSummary(item: GroupedNotificationFeedItem) {
42 const [first, second] = item.actors;
43 const action = notificationReasonCopy(item.reason);
44
45 if (!first) {
46 return `${item.count} accounts ${action}`;
47 }
48
49 const firstName = getDisplayName(first);
50 if (!second) {
51 return `${firstName} ${action}`;
52 }
53
54 const secondName = getDisplayName(second);
55 if (item.actorCount === 2) {
56 return `${firstName} and ${secondName} ${action}`;
57 }
58
59 const others = item.actorCount - 2;
60 const label = others === 1 ? "other" : "others";
61 return `${firstName}, ${secondName}, and ${others} ${label} ${action}`;
62}
63
64export function NotificationsPanel() {
65 const session = useAppSession();
66 let threadOverlay: ReturnType<typeof useThreadOverlayNavigation> | null = null;
67 try {
68 threadOverlay = useThreadOverlayNavigation();
69 } catch {
70 threadOverlay = null;
71 }
72
73 const buildPostHref = (uri: string | null) => {
74 if (!uri) {
75 return "/notifications";
76 }
77
78 if (threadOverlay) {
79 return threadOverlay.buildThreadHref(uri);
80 }
81
82 return buildPostRoute(uri);
83 };
84 const openPost = (uri: string) => {
85 if (threadOverlay) {
86 void threadOverlay.openThread(uri);
87 return;
88 }
89
90 globalThis.location.hash = `#${buildPostRoute(uri)}`;
91 };
92 const [tab, setTab] = createSignal<Tab>("all");
93 const [notifications, setNotifications] = createSignal<NotificationView[]>([]);
94 const [loading, setLoading] = createSignal(true);
95 const [error, setError] = createSignal<string | null>(null);
96 let loadRequestId = 0;
97 let markSeenPending = false;
98
99 const mentionsRaw = createMemo(() => notifications().filter((notification) => isMentionNotification(notification)));
100 const activityRaw = createMemo(() => notifications().filter((notification) => !isMentionNotification(notification)));
101 const mentionsFeed = createMemo(() => toSingleFeedItems(mentionsRaw()));
102 const activityGrouped = createMemo(() => groupActivityNotifications(activityRaw()));
103 const allMixed = createMemo(() => buildAllNotificationsFeed(mentionsRaw(), activityGrouped()));
104 const unreadAll = createMemo(() => notifications().filter((notification) => !notification.isRead).length);
105 const unreadMentions = createMemo(() => mentionsRaw().filter((notification) => !notification.isRead).length);
106 const unreadActivity = createMemo(() => activityRaw().filter((notification) => !notification.isRead).length);
107
108 async function markSeen() {
109 if (!hasUnreadNotifications(notifications()) || markSeenPending) {
110 return;
111 }
112
113 markSeenPending = true;
114
115 try {
116 await updateSeen();
117 setNotifications((previous) => previous.map((notification) => ({ ...notification, isRead: true })));
118 session.markNotificationsSeen();
119 } catch (err) {
120 const errorMessage = normalizeError(err);
121 logger.warn("failed to mark notifications as seen", { keyValues: { error: errorMessage } });
122 } finally {
123 markSeenPending = false;
124 }
125 }
126
127 async function load() {
128 const requestId = ++loadRequestId;
129 setLoading(true);
130 setError(null);
131
132 try {
133 const response: ListNotificationsResponse = await listNotifications();
134 if (requestId !== loadRequestId) {
135 return;
136 }
137
138 setNotifications(response.notifications);
139 } catch (err) {
140 if (requestId === loadRequestId) {
141 setError(normalizeError(err));
142 }
143 } finally {
144 if (requestId === loadRequestId) {
145 setLoading(false);
146 }
147 }
148 }
149
150 function reloadNotifications() {
151 void load();
152 }
153
154 function markReadByUris(uris: string[]) {
155 if (uris.length === 0) {
156 return;
157 }
158
159 const urisToRead = new Set(uris);
160 const previous = notifications();
161 let changed = false;
162 const next = previous.map((notification) => {
163 if (notification.isRead || !urisToRead.has(notification.uri)) {
164 return notification;
165 }
166
167 changed = true;
168 return { ...notification, isRead: true };
169 });
170
171 if (!changed) {
172 return;
173 }
174
175 setNotifications(next);
176 if (next.every((notification) => notification.isRead)) {
177 session.markNotificationsSeen();
178 }
179 }
180
181 onMount(() => {
182 reloadNotifications();
183
184 let unlisten: (() => void) | undefined;
185 void listen<number>(NOTIFICATIONS_UNREAD_COUNT_EVENT, reloadNotifications).then((dispose) => {
186 unlisten = dispose;
187 });
188
189 onCleanup(() => unlisten?.());
190 });
191
192 return (
193 <article class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]">
194 <NotificationsHeader
195 activeTab={tab()}
196 unreadActivity={unreadActivity()}
197 unreadAll={unreadAll()}
198 unreadMentions={unreadMentions()}
199 onMarkSeen={() => void markSeen()}
200 onSelectTab={setTab} />
201 <NotificationsViewport
202 activity={activityGrouped()}
203 all={allMixed()}
204 buildThreadHref={buildPostHref}
205 error={error()}
206 loading={loading()}
207 mentions={mentionsFeed()}
208 onMarkRead={markReadByUris}
209 onOpenThread={openPost}
210 tab={tab()} />
211 </article>
212 );
213}
214
215function NotificationsHeader(
216 props: {
217 activeTab: Tab;
218 unreadActivity: number;
219 unreadAll: number;
220 unreadMentions: number;
221 onMarkSeen: () => void;
222 onSelectTab: (tab: Tab) => void;
223 },
224) {
225 return (
226 <header class="grid gap-5 px-6 pb-4 pt-6">
227 <div class="flex items-center justify-between gap-4">
228 <div class="grid gap-1">
229 <p class="overline-copy text-xs text-on-surface-variant">Inbox</p>
230 <h1 class="m-0 text-xl font-semibold tracking-tight text-on-surface">Notifications</h1>
231 </div>
232 <button
233 type="button"
234 class="inline-flex h-10 items-center gap-2 rounded-full border-0 bg-surface-container-high px-4 text-sm font-medium text-on-surface-variant transition duration-150 hover:-translate-y-px hover:text-on-surface"
235 onClick={() => props.onMarkSeen()}
236 title="Mark all as read">
237 <Icon kind="complete" aria-hidden />
238 Mark all read
239 </button>
240 </div>
241
242 <nav class="flex flex-wrap gap-2" aria-label="Notification tabs">
243 <TabButton
244 active={props.activeTab === "all"}
245 badge={props.unreadAll}
246 label="All"
247 onClick={() => props.onSelectTab("all")} />
248 <TabButton
249 active={props.activeTab === "mentions"}
250 badge={props.unreadMentions}
251 label="Mentions"
252 onClick={() => props.onSelectTab("mentions")} />
253 <TabButton
254 active={props.activeTab === "activity"}
255 badge={props.unreadActivity}
256 label="Activity"
257 onClick={() => props.onSelectTab("activity")} />
258 </nav>
259 </header>
260 );
261}
262
263function NotificationsViewport(
264 props: {
265 activity: NotificationFeedItem[];
266 all: NotificationFeedItem[];
267 buildThreadHref: (uri: string | null) => string;
268 error: string | null;
269 loading: boolean;
270 mentions: SingleNotificationFeedItem[];
271 onMarkRead: (uris: string[]) => void;
272 onOpenThread: (uri: string) => void;
273 tab: Tab;
274 },
275) {
276 return (
277 <div class="min-h-0 overflow-y-auto px-3 pb-3">
278 <Show when={props.loading} fallback={<NotificationsState error={props.error} loading={false} />}>
279 <div class="grid gap-2 py-1">
280 <For each={Array.from({ length: 5 })}>{() => <NotificationSkeleton />}</For>
281 </div>
282 </Show>
283
284 <Show when={!props.loading && !props.error}>
285 <Presence>
286 <Show when={props.tab === "all"} keyed>
287 <NotificationList
288 ariaLabel="All notifications"
289 buildThreadHref={props.buildThreadHref}
290 emptyLabel="No notifications yet"
291 items={props.all}
292 onMarkRead={props.onMarkRead}
293 onOpenThread={props.onOpenThread} />
294 </Show>
295 <Show when={props.tab === "mentions"} keyed>
296 <NotificationList
297 ariaLabel="Mentions"
298 buildThreadHref={props.buildThreadHref}
299 emptyLabel="No mentions yet"
300 items={props.mentions}
301 onMarkRead={props.onMarkRead}
302 onOpenThread={props.onOpenThread} />
303 </Show>
304 <Show when={props.tab === "activity"} keyed>
305 <NotificationList
306 ariaLabel="Activity"
307 buildThreadHref={props.buildThreadHref}
308 emptyLabel="No activity yet"
309 items={props.activity}
310 onMarkRead={props.onMarkRead}
311 onOpenThread={props.onOpenThread} />
312 </Show>
313 </Presence>
314 </Show>
315 </div>
316 );
317}
318
319function NotificationsState(props: { error: string | null; loading: boolean }) {
320 return (
321 <Show when={!props.loading && props.error}>
322 {(message) => <div class="grid place-items-center px-6 py-16 text-sm text-on-surface-variant">{message()}</div>}
323 </Show>
324 );
325}
326
327function NotificationList(
328 props: {
329 ariaLabel: string;
330 buildThreadHref: (uri: string | null) => string;
331 emptyLabel: string;
332 items: NotificationFeedItem[];
333 onMarkRead: (uris: string[]) => void;
334 onOpenThread: (uri: string) => void;
335 },
336) {
337 const sections = createMemo(() => splitByReadState(props.items));
338
339 return (
340 <Motion.div
341 class="grid gap-2"
342 initial={{ opacity: 0 }}
343 animate={{ opacity: 1 }}
344 exit={{ opacity: 0 }}
345 transition={{ duration: 0.15 }}>
346 <Show when={props.items.length > 0} fallback={<EmptyState label={props.emptyLabel} />}>
347 <div class="grid gap-4">
348 <Show when={sections().newer.length > 0}>
349 <NotificationSection
350 ariaLabel={`${props.ariaLabel} new`}
351 buildThreadHref={props.buildThreadHref}
352 items={sections().newer}
353 label="New"
354 onMarkRead={props.onMarkRead}
355 onOpenThread={props.onOpenThread} />
356 </Show>
357 <Show when={sections().earlier.length > 0}>
358 <NotificationSection
359 ariaLabel={`${props.ariaLabel} earlier`}
360 buildThreadHref={props.buildThreadHref}
361 items={sections().earlier}
362 label="Earlier"
363 onMarkRead={props.onMarkRead}
364 onOpenThread={props.onOpenThread} />
365 </Show>
366 </div>
367 </Show>
368 </Motion.div>
369 );
370}
371
372function NotificationSection(
373 props: {
374 ariaLabel: string;
375 buildThreadHref: (uri: string | null) => string;
376 items: NotificationFeedItem[];
377 label: string;
378 onMarkRead: (uris: string[]) => void;
379 onOpenThread: (uri: string) => void;
380 },
381) {
382 return (
383 <section class="grid gap-2">
384 <h2 class="m-0 px-1 text-xs font-medium uppercase tracking-[0.14em] text-on-surface-variant">{props.label}</h2>
385 <div role="list" aria-label={props.ariaLabel} class="grid gap-2">
386 <For each={props.items}>
387 {(item, index) => (
388 <Motion.div
389 initial={{ opacity: 0, y: -6 }}
390 animate={{ opacity: 1, y: 0 }}
391 transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }}
392 role="listitem">
393 <NotificationFeedRow
394 buildThreadHref={props.buildThreadHref}
395 item={item}
396 onMarkRead={props.onMarkRead}
397 onOpenThread={props.onOpenThread} />
398 </Motion.div>
399 )}
400 </For>
401 </div>
402 </section>
403 );
404}
405
406function NotificationFeedRow(
407 props: {
408 buildThreadHref: (uri: string | null) => string;
409 item: NotificationFeedItem;
410 onMarkRead: (uris: string[]) => void;
411 onOpenThread: (uri: string) => void;
412 },
413) {
414 return (
415 <Switch>
416 <Match when={props.item.kind === "single"}>
417 <NotificationItem
418 buildThreadHref={props.buildThreadHref}
419 notification={(props.item as SingleNotificationFeedItem).notification}
420 onMarkRead={props.onMarkRead}
421 onOpenThread={props.onOpenThread} />
422 </Match>
423 <Match when={props.item.kind === "group"}>
424 <GroupedNotificationItem
425 item={props.item as GroupedNotificationFeedItem}
426 onMarkRead={props.onMarkRead}
427 onOpenThread={props.onOpenThread} />
428 </Match>
429 </Switch>
430 );
431}
432
433function GroupedReasonIcon(props: { reason: NotificationReason }) {
434 const icon = createMemo(() => notificationReasonIcon(props.reason));
435
436 return (
437 <div class="flex w-8 shrink-0 justify-center pt-0.5">
438 <Icon kind={icon().kind} class={icon().className} aria-hidden />
439 </div>
440 );
441}
442
443function GroupedAuthorAvatar(props: { actor: ProfileViewBasic; onClick: () => void }) {
444 const label = createMemo(() => getAvatarLabel(props.actor));
445 const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.actor)));
446 const labels = () => collectModerationLabels(props.actor);
447 const decision = useModerationDecision(labels, "avatar");
448
449 return (
450 <a
451 class="block no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-container"
452 href={`#${profileHref()}`}
453 aria-label={`View @${props.actor.handle}`}
454 onClick={(event) => {
455 event.stopPropagation();
456 props.onClick();
457 }}>
458 <ModeratedAvatar
459 avatar={props.actor.avatar}
460 class="inline-flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-surface-container-high text-xs font-semibold text-on-surface-variant shadow-[0_0_0_2px_var(--surface-container)]"
461 hidden={decision().filter || decision().blur !== "none"}
462 label={label()}
463 fallbackClass="text-xs font-semibold text-on-surface-variant" />
464 </a>
465 );
466}
467
468function GroupedNotificationItem(
469 props: {
470 item: GroupedNotificationFeedItem;
471 onMarkRead: (uris: string[]) => void;
472 onOpenThread: (uri: string) => void;
473 },
474) {
475 const time = createMemo(() => formatRelativeTime(props.item.latestIndexedAt));
476 const summary = createMemo(() => groupedSummary(props.item));
477 const actors = createMemo(() => props.item.actors.slice(0, 3));
478 const profileLabels = () => collectModerationLabels(...props.item.actors);
479 const profileDecision = useModerationDecision(profileLabels, "profileList");
480 const bodyTargetUri = createMemo(() => props.item.reasonSubject ?? null);
481 const bodyInteractive = createMemo(() => !!bodyTargetUri());
482 const memberUris = createMemo(() => props.item.notifications.map((notification) => notification.uri));
483
484 function openBodyTarget() {
485 const uri = bodyTargetUri();
486 if (!uri) {
487 return;
488 }
489
490 props.onMarkRead(memberUris());
491 props.onOpenThread(uri);
492 }
493
494 return (
495 <article
496 class="flex items-start gap-4 rounded-2xl px-4 py-4 transition-colors duration-150 hover:bg-surface-container-high"
497 classList={{ "opacity-60": !props.item.isUnread }}
498 aria-label={summary()}>
499 <GroupedReasonIcon reason={props.item.reason} />
500
501 <InteractiveBodyRegion active={bodyInteractive()} onActivate={openBodyTarget}>
502 <div class="mb-1 flex items-center gap-2">
503 <div class="flex -space-x-2">
504 <For each={actors()}>
505 {(actor) => <GroupedAuthorAvatar actor={actor} onClick={() => props.onMarkRead(memberUris())} />}
506 </For>
507 </div>
508 </div>
509
510 <p class="m-0 text-sm leading-relaxed text-on-surface">{summary()}</p>
511 <ModerationBadgeRow decision={profileDecision()} labels={profileLabels()} class="mt-1" />
512
513 <Show when={props.item.sampleRecordText}>
514 {(value) => <p class="mt-1 line-clamp-2 text-sm text-on-secondary-container">{value()}</p>}
515 </Show>
516
517 <p class="mt-2 text-xs text-on-surface-variant">{time()}</p>
518 </InteractiveBodyRegion>
519
520 <Show when={props.item.isUnread}>
521 <span class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" aria-label="Unread" role="status" />
522 </Show>
523 </article>
524 );
525}
526
527function InteractiveBodyRegion(props: ParentProps<{ active: boolean; onActivate: () => void }>) {
528 return (
529 <div
530 class="min-w-0 flex-1 rounded-xl p-1.5 transition duration-150"
531 classList={{
532 "cursor-pointer hover:bg-white/2 focus-visible:bg-white/3 focus-visible:ring-1 focus-visible:ring-primary/30":
533 props.active,
534 }}
535 role={props.active ? "button" : undefined}
536 tabIndex={props.active ? 0 : undefined}
537 onClick={() => props.onActivate()}
538 onKeyDown={(event) => {
539 if ((event.key === "Enter" || event.key === " ") && props.active) {
540 event.preventDefault();
541 props.onActivate();
542 }
543 }}>
544 {props.children}
545 </div>
546 );
547}
548
549function TabButton(props: { active: boolean; badge: number; label: string; onClick: () => void }) {
550 return (
551 <button
552 type="button"
553 aria-pressed={props.active}
554 class="inline-flex items-center gap-2 rounded-full border-0 px-4 py-2.5 text-sm font-medium transition duration-150"
555 classList={{
556 "bg-surface text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.18)]": props.active,
557 "bg-transparent text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface": !props.active,
558 }}
559 onClick={() => props.onClick()}>
560 {props.label}
561 <Show when={props.badge > 0}>
562 <span class="min-w-5 rounded-full bg-white/10 px-1.5 py-0.5 text-center text-[0.7rem] leading-none">
563 <Show when={props.badge > 99} fallback={props.badge}>{"99+"}</Show>
564 </span>
565 </Show>
566 </button>
567 );
568}
569
570function EmptyState(props: { label: string }) {
571 return (
572 <div class="grid place-items-center rounded-3xl bg-surface px-6 py-16 text-center text-sm text-on-surface-variant">
573 {props.label}
574 </div>
575 );
576}
577
578function NotificationSkeleton() {
579 return (
580 <div class="flex animate-pulse items-start gap-4 rounded-2xl bg-surface px-4 py-4" aria-hidden>
581 <div class="mt-1 h-5 w-5 shrink-0 rounded-full bg-white/5" />
582 <div class="h-8 w-8 shrink-0 rounded-full bg-white/5" />
583 <div class="min-w-0 flex-1 space-y-2">
584 <div class="h-4 w-48 rounded-full bg-white/5" />
585 <div class="h-3 w-36 rounded-full bg-white/5" />
586 </div>
587 </div>
588 );
589}