BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 589 lines 20 kB view raw
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}