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.

feat: implement notification grouping and reason copy functionality

+1162 -119
+94 -48
src/components/notifications/NotificationItem.tsx
··· 2 2 import { formatRelativeTime, getAvatarLabel, getDisplayName } from "$/lib/feeds"; 3 3 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 4 4 import type { NotificationReason, NotificationView } from "$/lib/types"; 5 - import { createMemo, Match, Show, Switch } from "solid-js"; 5 + import { createMemo, Show } from "solid-js"; 6 + import { 7 + notificationBodyTargetUri, 8 + notificationOriginalPostUri, 9 + notificationReasonCopy, 10 + notificationReasonIcon, 11 + } from "./notification-copy"; 6 12 7 13 function ReasonIcon(props: { reason: NotificationReason }) { 14 + const icon = createMemo(() => notificationReasonIcon(props.reason)); 15 + 8 16 return ( 9 17 <div class="flex w-8 shrink-0 justify-center pt-0.5"> 10 - <Switch fallback={<Icon kind="notifications" class="text-on-surface-variant" aria-hidden="true" />}> 11 - <Match when={props.reason === "like"}> 12 - <Icon kind="heart" class="text-[#ff6b6b]" aria-hidden="true" /> 13 - </Match> 14 - <Match when={props.reason === "repost"}> 15 - <Icon kind="repost" class="text-[#4cd964]" aria-hidden="true" /> 16 - </Match> 17 - <Match when={props.reason === "mention" || props.reason === "reply"}> 18 - <Icon kind="reply" class="text-primary" aria-hidden="true" /> 19 - </Match> 20 - <Match when={props.reason === "quote"}> 21 - <Icon kind="quote" class="text-primary" aria-hidden="true" /> 22 - </Match> 23 - <Match when={props.reason === "follow"}> 24 - <Icon kind="follow" class="text-primary" aria-hidden="true" /> 25 - </Match> 26 - </Switch> 18 + <Icon kind={icon().kind} class={icon().className} aria-hidden="true" /> 27 19 </div> 28 20 ); 29 21 } ··· 39 31 } 40 32 41 33 type NotificationItemProps = { notification: NotificationView }; 34 + type NotificationInteractionProps = { 35 + buildThreadHref?: (uri: string | null) => string; 36 + onMarkRead?: (uris: string[]) => void; 37 + onOpenThread?: (uri: string) => void; 38 + }; 42 39 43 - export function NotificationItem(props: NotificationItemProps) { 40 + export function NotificationItem(props: NotificationItemProps & NotificationInteractionProps) { 44 41 const name = createMemo(() => getDisplayName(props.notification.author)); 45 - const description = createMemo(() => { 46 - switch (props.notification.reason) { 47 - case "like": { 48 - return "liked your post"; 49 - } 50 - case "repost": { 51 - return "reposted your post"; 52 - } 53 - case "mention": { 54 - return "mentioned you"; 55 - } 56 - case "reply": { 57 - return "replied to you"; 58 - } 59 - case "quote": { 60 - return "quoted your post"; 61 - } 62 - case "follow": { 63 - return "followed you"; 64 - } 65 - default: { 66 - return "interacted with your post"; 67 - } 68 - } 69 - }); 42 + const description = createMemo(() => notificationReasonCopy(props.notification.reason)); 70 43 const time = createMemo(() => formatRelativeTime(props.notification.indexedAt)); 71 44 const avatarLabel = createMemo(() => getAvatarLabel(props.notification.author)); 72 45 const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.notification.author))); 46 + const bodyTargetUri = createMemo(() => notificationBodyTargetUri(props.notification)); 47 + const originalPostUri = createMemo(() => notificationOriginalPostUri(props.notification)); 48 + const originalPostHref = createMemo(() => props.buildThreadHref?.(originalPostUri() ?? null) ?? null); 49 + const bodyInteractive = createMemo(() => !!props.onOpenThread && !!bodyTargetUri()); 73 50 const postText = createMemo<string | null>(() => { 74 51 const record = props.notification.record; 75 52 const text = record["text"]; ··· 77 54 }); 78 55 const detail = createMemo(() => postText() ?? followDetail(props.notification)); 79 56 57 + function openBodyTarget() { 58 + const uri = bodyTargetUri(); 59 + if (!uri || !props.onOpenThread) { 60 + return; 61 + } 62 + 63 + props.onMarkRead?.([props.notification.uri]); 64 + props.onOpenThread(uri); 65 + } 66 + 67 + function markRead() { 68 + props.onMarkRead?.([props.notification.uri]); 69 + } 70 + 80 71 return ( 81 72 <article 82 73 class="flex items-start gap-4 rounded-2xl px-4 py-4 transition-colors duration-150 hover:bg-surface-container-high" 83 74 classList={{ "opacity-60": props.notification.isRead }} 84 75 aria-label={`${name()} ${description()}`}> 85 76 <ReasonIcon reason={props.notification.reason} /> 86 - <a class="shrink-0 no-underline" href={`#${profileHref()}`}> 77 + <a 78 + class="shrink-0 no-underline" 79 + href={`#${profileHref()}`} 80 + aria-label={`View @${props.notification.author.handle}`} 81 + onClick={() => markRead()}> 87 82 <AuthorAvatar avatar={props.notification.author.avatar} label={avatarLabel()} /> 88 83 </a> 89 84 90 - <div class="min-w-0 flex-1"> 85 + <div 86 + class="min-w-0 flex-1 rounded-xl p-1.5 transition duration-150" 87 + classList={{ 88 + "cursor-pointer hover:bg-white/2 focus-visible:bg-white/3 focus-visible:ring-1 focus-visible:ring-primary/30": 89 + bodyInteractive(), 90 + }} 91 + role={bodyInteractive() ? "button" : undefined} 92 + tabIndex={bodyInteractive() ? 0 : undefined} 93 + onClick={() => openBodyTarget()} 94 + onKeyDown={(event) => { 95 + if ((event.key === "Enter" || event.key === " ") && bodyInteractive()) { 96 + event.preventDefault(); 97 + openBodyTarget(); 98 + } 99 + }}> 91 100 <p class="m-0 text-sm leading-relaxed text-on-surface"> 92 101 <a 93 102 class="font-semibold text-on-surface no-underline transition hover:text-primary" 94 - href={`#${profileHref()}`}> 103 + href={`#${profileHref()}`} 104 + onClick={(event) => { 105 + event.stopPropagation(); 106 + markRead(); 107 + }}> 95 108 {name()} 96 109 </a>{" "} 97 - <span class="text-on-surface-variant">{description()}</span> 110 + <NotificationDescription 111 + description={description()} 112 + onOpenOriginalPost={() => markRead()} 113 + originalPostHref={originalPostHref()} 114 + reason={props.notification.reason} /> 98 115 </p> 99 116 100 117 <Show when={detail()}> ··· 108 125 <span class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" aria-label="Unread" role="status" /> 109 126 </Show> 110 127 </article> 128 + ); 129 + } 130 + 131 + function NotificationDescription( 132 + props: { 133 + description: string; 134 + onOpenOriginalPost: () => void; 135 + originalPostHref: string | null; 136 + reason: NotificationReason; 137 + }, 138 + ) { 139 + const postHref = createMemo(() => props.originalPostHref); 140 + const shouldLinkToOriginal = createMemo(() => (props.reason === "reply" || props.reason === "quote") && !!postHref()); 141 + 142 + return ( 143 + <Show when={shouldLinkToOriginal()} fallback={<span class="text-on-surface-variant">{props.description}</span>}> 144 + <span class="text-on-surface-variant"> 145 + <span>{props.reason === "reply" ? "replied to " : "quoted "}</span> 146 + <a 147 + class="font-medium text-on-surface no-underline transition hover:text-primary hover:underline" 148 + href={`#${postHref()}`} 149 + onClick={(event) => { 150 + event.stopPropagation(); 151 + props.onOpenOriginalPost(); 152 + }}> 153 + your post 154 + </a> 155 + </span> 156 + </Show> 111 157 ); 112 158 } 113 159
+251 -24
src/components/notifications/NotificationsPanel.test.tsx
··· 1 1 import { AppTestProviders } from "$/test/providers"; 2 - import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 + import { fireEvent, render, screen, waitFor, within } from "@solidjs/testing-library"; 3 3 import { beforeEach, describe, expect, it, vi } from "vitest"; 4 4 import { NotificationsPanel } from "./NotificationsPanel"; 5 5 ··· 29 29 beforeEach(() => { 30 30 vi.useFakeTimers(); 31 31 vi.setSystemTime(new Date("2026-03-29T12:30:00.000Z")); 32 + globalThis.location.hash = "#/notifications"; 32 33 listNotificationsMock.mockReset(); 33 34 updateSeenMock.mockReset(); 34 35 listenMock.mockReset(); ··· 37 38 listenMock.mockResolvedValue(() => {}); 38 39 }); 39 40 40 - it("loads notifications, marks them seen, and switches between tabs", async () => { 41 + it("defaults to the all tab and does not auto-mark seen", async () => { 42 + listNotificationsMock.mockResolvedValue({ 43 + cursor: null, 44 + notifications: [ 45 + createNotification("mention", { 46 + indexedAt: "2026-03-29T12:10:00.000Z", 47 + uri: "at://did:plc:mention/app.bsky.notification/1", 48 + }), 49 + createNotification("like", { 50 + indexedAt: "2026-03-29T12:00:00.000Z", 51 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 52 + uri: "at://did:plc:like/app.bsky.notification/2", 53 + }), 54 + ], 55 + seenAt: null, 56 + }); 57 + 58 + render(() => ( 59 + <AppTestProviders> 60 + <NotificationsPanel /> 61 + </AppTestProviders> 62 + )); 63 + 64 + await screen.findByLabelText("mention author mentioned you"); 65 + expect(screen.getByRole("button", { name: /^All/ })).toHaveAttribute("aria-pressed", "true"); 66 + expect(screen.getByLabelText("like author liked your post")).toBeInTheDocument(); 67 + expect(updateSeenMock).not.toHaveBeenCalled(); 68 + }); 69 + 70 + it("marks everything read only when the user clicks mark all read", async () => { 41 71 listNotificationsMock.mockResolvedValue({ 42 72 cursor: null, 43 - notifications: [createNotification("mention"), createNotification("like")], 73 + notifications: [createNotification("mention")], 44 74 seenAt: null, 45 75 }); 46 76 ··· 52 82 )); 53 83 54 84 await screen.findByLabelText("mention author mentioned you"); 85 + expect(screen.getByRole("heading", { name: "New" })).toBeInTheDocument(); 86 + 87 + fireEvent.click(screen.getByRole("button", { name: /mark all read/i })); 88 + 55 89 await waitFor(() => expect(updateSeenMock).toHaveBeenCalledOnce()); 56 90 await waitFor(() => expect(markNotificationsSeen).toHaveBeenCalledOnce()); 91 + expect(screen.queryByLabelText("Unread")).not.toBeInTheDocument(); 92 + expect(screen.getByRole("heading", { name: "Earlier" })).toBeInTheDocument(); 93 + }); 57 94 95 + it("renders new and earlier sections", async () => { 96 + listNotificationsMock.mockResolvedValue({ 97 + cursor: null, 98 + notifications: [ 99 + createNotification("mention", { 100 + indexedAt: "2026-03-29T12:10:00.000Z", 101 + uri: "at://did:plc:mention/app.bsky.notification/1", 102 + }), 103 + createNotification("reply", { 104 + indexedAt: "2026-03-29T10:00:00.000Z", 105 + isRead: true, 106 + uri: "at://did:plc:reply/app.bsky.notification/2", 107 + }), 108 + ], 109 + seenAt: null, 110 + }); 111 + 112 + render(() => ( 113 + <AppTestProviders> 114 + <NotificationsPanel /> 115 + </AppTestProviders> 116 + )); 117 + 118 + await screen.findByLabelText("mention author mentioned you"); 119 + expect(screen.getByRole("heading", { name: "New" })).toBeInTheDocument(); 120 + expect(screen.getByRole("heading", { name: "Earlier" })).toBeInTheDocument(); 121 + }); 122 + 123 + it("groups activity by reason + reasonSubject in the activity tab", async () => { 124 + listNotificationsMock.mockResolvedValue({ 125 + cursor: null, 126 + notifications: [ 127 + createNotification("like", { 128 + author: { did: "did:plc:alice", displayName: "Alice", handle: "alice.test" }, 129 + indexedAt: "2026-03-29T12:10:00.000Z", 130 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 131 + uri: "at://did:plc:like/app.bsky.notification/1", 132 + }), 133 + createNotification("like", { 134 + author: { did: "did:plc:bob", displayName: "Bob", handle: "bob.test" }, 135 + indexedAt: "2026-03-29T12:05:00.000Z", 136 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 137 + uri: "at://did:plc:like/app.bsky.notification/2", 138 + }), 139 + ], 140 + seenAt: null, 141 + }); 142 + 143 + render(() => ( 144 + <AppTestProviders> 145 + <NotificationsPanel /> 146 + </AppTestProviders> 147 + )); 148 + 149 + await screen.findByText(/liked your post/i); 150 + fireEvent.click(screen.getByRole("button", { name: /activity/i })); 151 + 152 + await waitFor(() => { 153 + const items = screen.getAllByRole("listitem"); 154 + expect(items).toHaveLength(1); 155 + }); 156 + 157 + expect(screen.getByText("Alice and Bob liked your post")).toBeInTheDocument(); 158 + const aliceLink = screen.getByRole("link", { name: "View @alice.test" }); 159 + const bobLink = screen.getByRole("link", { name: "View @bob.test" }); 160 + expect(aliceLink).toHaveAttribute("href", "#/profile/alice.test"); 161 + expect(bobLink).toHaveAttribute("href", "#/profile/bob.test"); 58 162 expect(screen.queryByLabelText("like author liked your post")).not.toBeInTheDocument(); 163 + }); 59 164 60 - fireEvent.click(screen.getByRole("button", { name: /activity/i })); 165 + it("opens the responded post when clicking a notification body", async () => { 166 + listNotificationsMock.mockResolvedValue({ 167 + cursor: null, 168 + notifications: [ 169 + createNotification("like", { 170 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 171 + uri: "at://did:plc:like/app.bsky.notification/1", 172 + }), 173 + ], 174 + seenAt: null, 175 + }); 176 + 177 + render(() => ( 178 + <AppTestProviders> 179 + <NotificationsPanel /> 180 + </AppTestProviders> 181 + )); 182 + 183 + const body = await screen.findByRole("button", { name: /like author liked your post/i }); 184 + fireEvent.click(body); 185 + 186 + expect(globalThis.location.hash).toContain("thread=at%3A%2F%2Fdid%3Aplc%3Apost%2Fapp.bsky.feed.post%2F1"); 187 + expect(screen.queryByLabelText("Unread")).not.toBeInTheDocument(); 188 + }); 189 + 190 + it("opens reply/quote target on body click and links original as 'your post'", async () => { 191 + listNotificationsMock.mockResolvedValue({ 192 + cursor: null, 193 + notifications: [ 194 + createNotification("reply", { 195 + author: { did: "did:plc:alice", displayName: "Alice", handle: "alice.test" }, 196 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/original", 197 + uri: "at://did:plc:alice/app.bsky.feed.post/reply", 198 + }), 199 + ], 200 + seenAt: null, 201 + }); 202 + 203 + render(() => ( 204 + <AppTestProviders> 205 + <NotificationsPanel /> 206 + </AppTestProviders> 207 + )); 208 + 209 + const yourPost = await screen.findByRole("link", { name: "your post" }); 210 + expect(yourPost).toHaveAttribute( 211 + "href", 212 + "#/notifications?thread=at%3A%2F%2Fdid%3Aplc%3Apost%2Fapp.bsky.feed.post%2Foriginal", 213 + ); 214 + 215 + const body = screen.getByRole("button", { name: /alice replied to.*your post/i }); 216 + fireEvent.click(body); 217 + expect(globalThis.location.hash).toContain("thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Freply"); 218 + expect(screen.queryByLabelText("Unread")).not.toBeInTheDocument(); 219 + }); 220 + 221 + it("marks a notification read when profile avatar is clicked", async () => { 222 + listNotificationsMock.mockResolvedValue({ 223 + cursor: null, 224 + notifications: [ 225 + createNotification("mention", { 226 + author: { did: "did:plc:alice", displayName: "Alice", handle: "alice.test" }, 227 + uri: "at://did:plc:mention/app.bsky.notification/1", 228 + }), 229 + ], 230 + seenAt: null, 231 + }); 232 + 233 + render(() => ( 234 + <AppTestProviders> 235 + <NotificationsPanel /> 236 + </AppTestProviders> 237 + )); 238 + 239 + const avatarLink = await screen.findByRole("link", { name: "View @alice.test" }); 240 + fireEvent.click(avatarLink); 241 + expect(screen.queryByLabelText("Unread")).not.toBeInTheDocument(); 242 + }); 243 + 244 + it("keeps mentions ungrouped in the mentions tab", async () => { 245 + listNotificationsMock.mockResolvedValue({ 246 + cursor: null, 247 + notifications: [ 248 + createNotification("mention", { uri: "at://did:plc:mention/app.bsky.notification/1" }), 249 + createNotification("reply", { uri: "at://did:plc:reply/app.bsky.notification/2" }), 250 + ], 251 + seenAt: null, 252 + }); 253 + 254 + render(() => ( 255 + <AppTestProviders> 256 + <NotificationsPanel /> 257 + </AppTestProviders> 258 + )); 259 + 260 + await screen.findByLabelText("mention author mentioned you"); 261 + fireEvent.click(screen.getByRole("button", { name: /mentions/i })); 61 262 62 - expect(await screen.findByLabelText("like author liked your post")).toBeInTheDocument(); 263 + await waitFor(() => { 264 + const items = screen.getAllByRole("listitem"); 265 + expect(items).toHaveLength(2); 266 + }); 267 + }); 268 + 269 + it("sorts all-tab rows by newest timestamp across mentions and grouped activity", async () => { 270 + listNotificationsMock.mockResolvedValue({ 271 + cursor: null, 272 + notifications: [ 273 + createNotification("like", { 274 + author: { did: "did:plc:alice", displayName: "Alice", handle: "alice.test" }, 275 + indexedAt: "2026-03-29T12:10:00.000Z", 276 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 277 + uri: "at://did:plc:like/app.bsky.notification/1", 278 + }), 279 + createNotification("like", { 280 + author: { did: "did:plc:bob", displayName: "Bob", handle: "bob.test" }, 281 + indexedAt: "2026-03-29T12:08:00.000Z", 282 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 283 + uri: "at://did:plc:like/app.bsky.notification/2", 284 + }), 285 + createNotification("mention", { 286 + author: { did: "did:plc:carol", displayName: "Carol", handle: "carol.test" }, 287 + indexedAt: "2026-03-29T12:12:00.000Z", 288 + uri: "at://did:plc:mention/app.bsky.notification/3", 289 + }), 290 + ], 291 + seenAt: null, 292 + }); 293 + 294 + render(() => ( 295 + <AppTestProviders> 296 + <NotificationsPanel /> 297 + </AppTestProviders> 298 + )); 299 + 300 + await screen.findByLabelText("Carol mentioned you"); 301 + 302 + await waitFor(() => { 303 + const items = screen.getAllByRole("listitem"); 304 + expect(items).toHaveLength(2); 305 + expect(within(items[0]).getByLabelText("Carol mentioned you")).toBeInTheDocument(); 306 + expect(within(items[1]).getByText("Alice and Bob liked your post")).toBeInTheDocument(); 307 + }); 63 308 }); 64 309 65 310 it("reloads when the unread-count event arrives", async () => { ··· 92 337 93 338 await waitFor(() => expect(listNotificationsMock).toHaveBeenCalledTimes(2)); 94 339 expect(await screen.findByLabelText("reply author replied to you")).toBeInTheDocument(); 340 + expect(updateSeenMock).not.toHaveBeenCalled(); 95 341 }); 96 342 97 343 it("shows the error state when loading fails", async () => { ··· 105 351 106 352 expect(await screen.findByText("notification fetch failed")).toBeInTheDocument(); 107 353 expect(updateSeenMock).not.toHaveBeenCalled(); 108 - expect(warnMock).not.toHaveBeenCalled(); 109 - }); 110 - 111 - it("does not warn when automatic mark-seen fails after a successful load", async () => { 112 - listNotificationsMock.mockResolvedValue({ 113 - cursor: null, 114 - notifications: [createNotification("mention")], 115 - seenAt: null, 116 - }); 117 - updateSeenMock.mockRejectedValue(new Error("transport error")); 118 - 119 - render(() => ( 120 - <AppTestProviders> 121 - <NotificationsPanel /> 122 - </AppTestProviders> 123 - )); 124 - 125 - expect(await screen.findByLabelText("mention author mentioned you")).toBeInTheDocument(); 126 - await waitFor(() => expect(updateSeenMock).toHaveBeenCalledOnce()); 127 354 expect(warnMock).not.toHaveBeenCalled(); 128 355 }); 129 356 });
+345 -47
src/components/notifications/NotificationsPanel.tsx
··· 1 1 import { useAppSession } from "$/contexts/app-session"; 2 2 import { listNotifications, updateSeen } from "$/lib/api/notifications"; 3 3 import { NOTIFICATIONS_UNREAD_COUNT_EVENT } from "$/lib/constants/events"; 4 - import type { ListNotificationsResponse, NotificationView } from "$/lib/types"; 4 + import { buildThreadOverlayRoute, formatRelativeTime, getAvatarLabel, getDisplayName } from "$/lib/feeds"; 5 + import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 6 + import type { ListNotificationsResponse, NotificationReason, NotificationView, ProfileViewBasic } from "$/lib/types"; 5 7 import { normalizeError } from "$/lib/utils/text"; 6 8 import { listen } from "@tauri-apps/api/event"; 7 9 import * as logger from "@tauri-apps/plugin-log"; 8 - import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 10 + import { createMemo, createSignal, For, Match, onCleanup, onMount, type ParentProps, Show, Switch } from "solid-js"; 9 11 import { Motion, Presence } from "solid-motionone"; 10 12 import { Icon } from "../shared/Icon"; 13 + import { notificationReasonCopy, notificationReasonIcon } from "./notification-copy"; 14 + import { 15 + buildAllNotificationsFeed, 16 + groupActivityNotifications, 17 + type GroupedNotificationFeedItem, 18 + isMentionNotification, 19 + type NotificationFeedItem, 20 + type SingleNotificationFeedItem, 21 + splitByReadState, 22 + toSingleFeedItems, 23 + } from "./notification-grouping"; 11 24 import { NotificationItem } from "./NotificationItem"; 12 25 13 - type Tab = "mentions" | "activity"; 26 + type Tab = "all" | "mentions" | "activity"; 27 + 28 + function getCurrentRouteFromHash() { 29 + const rawHash = globalThis.location.hash.replace(/^#/, ""); 30 + const hashRoute = rawHash.length > 0 ? rawHash : "/notifications"; 31 + const [pathname, ...searchTokens] = hashRoute.split("?"); 32 + const search = searchTokens.length > 0 ? `?${searchTokens.join("?")}` : ""; 33 + 34 + return { pathname: pathname || "/notifications", search }; 35 + } 14 36 15 - const MENTION_REASONS = new Set(["mention", "reply", "quote"]); 37 + function buildThreadHrefFromHash(uri: string | null) { 38 + const { pathname, search } = getCurrentRouteFromHash(); 39 + return buildThreadOverlayRoute(pathname, search, uri); 40 + } 16 41 17 42 function hasUnreadNotifications(items: NotificationView[]) { 18 43 return items.some((notification) => !notification.isRead); 19 44 } 20 45 46 + function groupedSummary(item: GroupedNotificationFeedItem) { 47 + const [first, second] = item.actors; 48 + const action = notificationReasonCopy(item.reason); 49 + 50 + if (!first) { 51 + return `${item.count} accounts ${action}`; 52 + } 53 + 54 + const firstName = getDisplayName(first); 55 + if (!second) { 56 + return `${firstName} ${action}`; 57 + } 58 + 59 + const secondName = getDisplayName(second); 60 + if (item.actorCount === 2) { 61 + return `${firstName} and ${secondName} ${action}`; 62 + } 63 + 64 + const others = item.actorCount - 2; 65 + const label = others === 1 ? "other" : "others"; 66 + return `${firstName}, ${secondName}, and ${others} ${label} ${action}`; 67 + } 68 + 21 69 export function NotificationsPanel() { 22 70 const session = useAppSession(); 23 - // TODO: NotificationsStore via createStore 24 - const [tab, setTab] = createSignal<Tab>("mentions"); 71 + const [tab, setTab] = createSignal<Tab>("all"); 25 72 const [notifications, setNotifications] = createSignal<NotificationView[]>([]); 26 73 const [loading, setLoading] = createSignal(true); 27 74 const [error, setError] = createSignal<string | null>(null); 28 75 let loadRequestId = 0; 29 76 let markSeenPending = false; 30 77 31 - const mentions = createMemo(() => notifications().filter((n) => MENTION_REASONS.has(n.reason))); 32 - const activity = createMemo(() => notifications().filter((n) => !MENTION_REASONS.has(n.reason))); 33 - const unreadMentions = createMemo(() => mentions().filter((n) => !n.isRead).length); 34 - const unreadActivity = createMemo(() => activity().filter((n) => !n.isRead).length); 78 + const mentionsRaw = createMemo(() => notifications().filter((notification) => isMentionNotification(notification))); 79 + const activityRaw = createMemo(() => notifications().filter((notification) => !isMentionNotification(notification))); 80 + const mentionsFeed = createMemo(() => toSingleFeedItems(mentionsRaw())); 81 + const activityGrouped = createMemo(() => groupActivityNotifications(activityRaw())); 82 + const allMixed = createMemo(() => buildAllNotificationsFeed(mentionsRaw(), activityGrouped())); 83 + const unreadAll = createMemo(() => notifications().filter((notification) => !notification.isRead).length); 84 + const unreadMentions = createMemo(() => mentionsRaw().filter((notification) => !notification.isRead).length); 85 + const unreadActivity = createMemo(() => activityRaw().filter((notification) => !notification.isRead).length); 35 86 36 - async function markSeen(options?: { notifications?: NotificationView[]; silent?: boolean }) { 37 - const items = options?.notifications ?? notifications(); 38 - if (!hasUnreadNotifications(items) || markSeenPending) { 87 + async function markSeen() { 88 + if (!hasUnreadNotifications(notifications()) || markSeenPending) { 39 89 return; 40 90 } 41 91 ··· 43 93 44 94 try { 45 95 await updateSeen(); 46 - setNotifications((prev) => prev.map((notification) => ({ ...notification, isRead: true }))); 96 + setNotifications((previous) => previous.map((notification) => ({ ...notification, isRead: true }))); 47 97 session.markNotificationsSeen(); 48 98 } catch (err) { 49 - const error = normalizeError(err); 50 - if (!options?.silent) { 51 - logger.warn("failed to mark notifications as seen", { keyValues: { error } }); 52 - } 99 + const errorMessage = normalizeError(err); 100 + logger.warn("failed to mark notifications as seen", { keyValues: { error: errorMessage } }); 53 101 } finally { 54 102 markSeenPending = false; 55 103 } 56 104 } 57 105 58 - async function load(options?: { markSeen?: boolean }) { 106 + async function load() { 59 107 const requestId = ++loadRequestId; 60 108 setLoading(true); 61 109 setError(null); ··· 67 115 } 68 116 69 117 setNotifications(response.notifications); 70 - 71 - if (options?.markSeen) { 72 - await markSeen({ notifications: response.notifications, silent: true }); 73 - } 74 118 } catch (err) { 75 119 if (requestId === loadRequestId) { 76 120 setError(normalizeError(err)); ··· 83 127 } 84 128 85 129 function reloadNotifications() { 86 - void load({ markSeen: true }); 130 + void load(); 131 + } 132 + 133 + function markReadByUris(uris: string[]) { 134 + if (uris.length === 0) { 135 + return; 136 + } 137 + 138 + const urisToRead = new Set(uris); 139 + const previous = notifications(); 140 + let changed = false; 141 + const next = previous.map((notification) => { 142 + if (notification.isRead || !urisToRead.has(notification.uri)) { 143 + return notification; 144 + } 145 + 146 + changed = true; 147 + return { ...notification, isRead: true }; 148 + }); 149 + 150 + if (!changed) { 151 + return; 152 + } 153 + 154 + setNotifications(next); 155 + if (next.every((notification) => notification.isRead)) { 156 + session.markNotificationsSeen(); 157 + } 158 + } 159 + 160 + function openThread(uri: string) { 161 + globalThis.location.hash = buildThreadHrefFromHash(uri); 87 162 } 88 163 89 164 onMount(() => { ··· 102 177 <NotificationsHeader 103 178 activeTab={tab()} 104 179 unreadActivity={unreadActivity()} 180 + unreadAll={unreadAll()} 105 181 unreadMentions={unreadMentions()} 106 182 onMarkSeen={() => void markSeen()} 107 183 onSelectTab={setTab} /> 108 184 <NotificationsViewport 109 - activity={activity()} 185 + activity={activityGrouped()} 186 + all={allMixed()} 187 + buildThreadHref={buildThreadHrefFromHash} 110 188 error={error()} 111 189 loading={loading()} 112 - mentions={mentions()} 190 + mentions={mentionsFeed()} 191 + onMarkRead={markReadByUris} 192 + onOpenThread={openThread} 113 193 tab={tab()} /> 114 194 </article> 115 195 ); ··· 119 199 props: { 120 200 activeTab: Tab; 121 201 unreadActivity: number; 202 + unreadAll: number; 122 203 unreadMentions: number; 123 204 onMarkSeen: () => void; 124 205 onSelectTab: (tab: Tab) => void; ··· 143 224 144 225 <nav class="flex flex-wrap gap-2" aria-label="Notification tabs"> 145 226 <TabButton 227 + active={props.activeTab === "all"} 228 + badge={props.unreadAll} 229 + label="All" 230 + onClick={() => props.onSelectTab("all")} /> 231 + <TabButton 146 232 active={props.activeTab === "mentions"} 147 233 badge={props.unreadMentions} 148 234 label="Mentions" ··· 159 245 160 246 function NotificationsViewport( 161 247 props: { 162 - activity: NotificationView[]; 248 + activity: NotificationFeedItem[]; 249 + all: NotificationFeedItem[]; 250 + buildThreadHref: (uri: string | null) => string; 163 251 error: string | null; 164 252 loading: boolean; 165 - mentions: NotificationView[]; 253 + mentions: SingleNotificationFeedItem[]; 254 + onMarkRead: (uris: string[]) => void; 255 + onOpenThread: (uri: string) => void; 166 256 tab: Tab; 167 257 }, 168 258 ) { 169 - const activeItems = createMemo(() => (props.tab === "mentions" ? props.mentions : props.activity)); 170 - const emptyLabel = createMemo(() => (props.tab === "mentions" ? "No mentions yet" : "No activity yet")); 171 - const ariaLabel = createMemo(() => (props.tab === "mentions" ? "Mentions" : "Activity")); 172 - 173 259 return ( 174 260 <div class="min-h-0 overflow-y-auto px-3 pb-3"> 175 261 <Show when={props.loading} fallback={<NotificationsState error={props.error} loading={false} />}> ··· 180 266 181 267 <Show when={!props.loading && !props.error}> 182 268 <Presence> 269 + <Show when={props.tab === "all"} keyed> 270 + <NotificationList 271 + ariaLabel="All notifications" 272 + buildThreadHref={props.buildThreadHref} 273 + emptyLabel="No notifications yet" 274 + items={props.all} 275 + onMarkRead={props.onMarkRead} 276 + onOpenThread={props.onOpenThread} /> 277 + </Show> 183 278 <Show when={props.tab === "mentions"} keyed> 184 - <NotificationList ariaLabel={ariaLabel()} emptyLabel={emptyLabel()} items={activeItems()} /> 279 + <NotificationList 280 + ariaLabel="Mentions" 281 + buildThreadHref={props.buildThreadHref} 282 + emptyLabel="No mentions yet" 283 + items={props.mentions} 284 + onMarkRead={props.onMarkRead} 285 + onOpenThread={props.onOpenThread} /> 185 286 </Show> 186 287 <Show when={props.tab === "activity"} keyed> 187 - <NotificationList ariaLabel={ariaLabel()} emptyLabel={emptyLabel()} items={activeItems()} /> 288 + <NotificationList 289 + ariaLabel="Activity" 290 + buildThreadHref={props.buildThreadHref} 291 + emptyLabel="No activity yet" 292 + items={props.activity} 293 + onMarkRead={props.onMarkRead} 294 + onOpenThread={props.onOpenThread} /> 188 295 </Show> 189 296 </Presence> 190 297 </Show> ··· 200 307 ); 201 308 } 202 309 203 - function NotificationList(props: { ariaLabel: string; emptyLabel: string; items: NotificationView[] }) { 310 + function NotificationList( 311 + props: { 312 + ariaLabel: string; 313 + buildThreadHref: (uri: string | null) => string; 314 + emptyLabel: string; 315 + items: NotificationFeedItem[]; 316 + onMarkRead: (uris: string[]) => void; 317 + onOpenThread: (uri: string) => void; 318 + }, 319 + ) { 320 + const sections = createMemo(() => splitByReadState(props.items)); 321 + 204 322 return ( 205 323 <Motion.div 206 324 class="grid gap-2" ··· 209 327 exit={{ opacity: 0 }} 210 328 transition={{ duration: 0.15 }}> 211 329 <Show when={props.items.length > 0} fallback={<EmptyState label={props.emptyLabel} />}> 212 - <div role="list" aria-label={props.ariaLabel} class="grid gap-2"> 213 - <For each={props.items}> 214 - {(notification, index) => ( 215 - <Motion.div 216 - initial={{ opacity: 0, y: -6 }} 217 - animate={{ opacity: 1, y: 0 }} 218 - transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }} 219 - role="listitem"> 220 - <NotificationItem notification={notification} /> 221 - </Motion.div> 222 - )} 223 - </For> 330 + <div class="grid gap-4"> 331 + <Show when={sections().newer.length > 0}> 332 + <NotificationSection 333 + ariaLabel={`${props.ariaLabel} new`} 334 + buildThreadHref={props.buildThreadHref} 335 + items={sections().newer} 336 + label="New" 337 + onMarkRead={props.onMarkRead} 338 + onOpenThread={props.onOpenThread} /> 339 + </Show> 340 + <Show when={sections().earlier.length > 0}> 341 + <NotificationSection 342 + ariaLabel={`${props.ariaLabel} earlier`} 343 + buildThreadHref={props.buildThreadHref} 344 + items={sections().earlier} 345 + label="Earlier" 346 + onMarkRead={props.onMarkRead} 347 + onOpenThread={props.onOpenThread} /> 348 + </Show> 224 349 </div> 225 350 </Show> 226 351 </Motion.div> 352 + ); 353 + } 354 + 355 + function NotificationSection( 356 + props: { 357 + ariaLabel: string; 358 + buildThreadHref: (uri: string | null) => string; 359 + items: NotificationFeedItem[]; 360 + label: string; 361 + onMarkRead: (uris: string[]) => void; 362 + onOpenThread: (uri: string) => void; 363 + }, 364 + ) { 365 + return ( 366 + <section class="grid gap-2"> 367 + <h2 class="m-0 px-1 text-xs font-medium uppercase tracking-[0.14em] text-on-surface-variant">{props.label}</h2> 368 + <div role="list" aria-label={props.ariaLabel} class="grid gap-2"> 369 + <For each={props.items}> 370 + {(item, index) => ( 371 + <Motion.div 372 + initial={{ opacity: 0, y: -6 }} 373 + animate={{ opacity: 1, y: 0 }} 374 + transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }} 375 + role="listitem"> 376 + <NotificationFeedRow 377 + buildThreadHref={props.buildThreadHref} 378 + item={item} 379 + onMarkRead={props.onMarkRead} 380 + onOpenThread={props.onOpenThread} /> 381 + </Motion.div> 382 + )} 383 + </For> 384 + </div> 385 + </section> 386 + ); 387 + } 388 + 389 + function NotificationFeedRow( 390 + props: { 391 + buildThreadHref: (uri: string | null) => string; 392 + item: NotificationFeedItem; 393 + onMarkRead: (uris: string[]) => void; 394 + onOpenThread: (uri: string) => void; 395 + }, 396 + ) { 397 + return ( 398 + <Switch> 399 + <Match when={props.item.kind === "single"}> 400 + <NotificationItem 401 + buildThreadHref={props.buildThreadHref} 402 + notification={(props.item as SingleNotificationFeedItem).notification} 403 + onMarkRead={props.onMarkRead} 404 + onOpenThread={props.onOpenThread} /> 405 + </Match> 406 + <Match when={props.item.kind === "group"}> 407 + <GroupedNotificationItem 408 + item={props.item as GroupedNotificationFeedItem} 409 + onMarkRead={props.onMarkRead} 410 + onOpenThread={props.onOpenThread} /> 411 + </Match> 412 + </Switch> 413 + ); 414 + } 415 + 416 + function GroupedReasonIcon(props: { reason: NotificationReason }) { 417 + const icon = createMemo(() => notificationReasonIcon(props.reason)); 418 + 419 + return ( 420 + <div class="flex w-8 shrink-0 justify-center pt-0.5"> 421 + <Icon kind={icon().kind} class={icon().className} aria-hidden="true" /> 422 + </div> 423 + ); 424 + } 425 + 426 + function GroupedAuthorAvatar(props: { actor: ProfileViewBasic; onClick: () => void }) { 427 + const label = createMemo(() => getAvatarLabel(props.actor)); 428 + const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.actor))); 429 + 430 + return ( 431 + <a 432 + 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" 433 + href={`#${profileHref()}`} 434 + aria-label={`View @${props.actor.handle}`} 435 + onClick={(event) => { 436 + event.stopPropagation(); 437 + props.onClick(); 438 + }}> 439 + <span 440 + 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)]" 441 + aria-hidden="true"> 442 + <Show when={props.actor.avatar} fallback={label()}> 443 + {(avatar) => <img src={avatar()} alt="" class="h-full w-full object-cover" />} 444 + </Show> 445 + </span> 446 + </a> 447 + ); 448 + } 449 + 450 + function GroupedNotificationItem( 451 + props: { 452 + item: GroupedNotificationFeedItem; 453 + onMarkRead: (uris: string[]) => void; 454 + onOpenThread: (uri: string) => void; 455 + }, 456 + ) { 457 + const time = createMemo(() => formatRelativeTime(props.item.latestIndexedAt)); 458 + const summary = createMemo(() => groupedSummary(props.item)); 459 + const actors = createMemo(() => props.item.actors.slice(0, 3)); 460 + const bodyTargetUri = createMemo(() => props.item.reasonSubject ?? null); 461 + const bodyInteractive = createMemo(() => !!bodyTargetUri()); 462 + const memberUris = createMemo(() => props.item.notifications.map((notification) => notification.uri)); 463 + 464 + function openBodyTarget() { 465 + const uri = bodyTargetUri(); 466 + if (!uri) { 467 + return; 468 + } 469 + 470 + props.onMarkRead(memberUris()); 471 + props.onOpenThread(uri); 472 + } 473 + 474 + return ( 475 + <article 476 + class="flex items-start gap-4 rounded-2xl px-4 py-4 transition-colors duration-150 hover:bg-surface-container-high" 477 + classList={{ "opacity-60": !props.item.isUnread }} 478 + aria-label={summary()}> 479 + <GroupedReasonIcon reason={props.item.reason} /> 480 + 481 + <InteractiveBodyRegion active={bodyInteractive()} onActivate={openBodyTarget}> 482 + <div class="mb-1 flex items-center gap-2"> 483 + <div class="flex -space-x-2"> 484 + <For each={actors()}> 485 + {(actor) => <GroupedAuthorAvatar actor={actor} onClick={() => props.onMarkRead(memberUris())} />} 486 + </For> 487 + </div> 488 + </div> 489 + 490 + <p class="m-0 text-sm leading-relaxed text-on-surface">{summary()}</p> 491 + 492 + <Show when={props.item.sampleRecordText}> 493 + {(value) => <p class="mt-1 line-clamp-2 text-sm text-on-secondary-container">{value()}</p>} 494 + </Show> 495 + 496 + <p class="mt-2 text-xs text-on-surface-variant">{time()}</p> 497 + </InteractiveBodyRegion> 498 + 499 + <Show when={props.item.isUnread}> 500 + <span class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" aria-label="Unread" role="status" /> 501 + </Show> 502 + </article> 503 + ); 504 + } 505 + 506 + function InteractiveBodyRegion(props: ParentProps<{ active: boolean; onActivate: () => void }>) { 507 + return ( 508 + <div 509 + class="min-w-0 flex-1 rounded-xl p-1.5 transition duration-150" 510 + classList={{ 511 + "cursor-pointer hover:bg-white/2 focus-visible:bg-white/3 focus-visible:ring-1 focus-visible:ring-primary/30": 512 + props.active, 513 + }} 514 + role={props.active ? "button" : undefined} 515 + tabIndex={props.active ? 0 : undefined} 516 + onClick={() => props.onActivate()} 517 + onKeyDown={(event) => { 518 + if ((event.key === "Enter" || event.key === " ") && props.active) { 519 + event.preventDefault(); 520 + props.onActivate(); 521 + } 522 + }}> 523 + {props.children} 524 + </div> 227 525 ); 228 526 } 229 527
+74
src/components/notifications/notification-copy.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { 3 + notificationBodyTargetUri, 4 + notificationOriginalPostUri, 5 + notificationReasonCopy, 6 + notificationReasonIcon, 7 + } from "./notification-copy"; 8 + 9 + describe("notification-copy", () => { 10 + it("returns copy for known reasons", () => { 11 + expect(notificationReasonCopy("like")).toBe("liked your post"); 12 + expect(notificationReasonCopy("reply")).toBe("replied to you"); 13 + expect(notificationReasonCopy("follow")).toBe("followed you"); 14 + }); 15 + 16 + it("returns icon mapping for known reasons", () => { 17 + expect(notificationReasonIcon("like")).toEqual({ className: "text-[#ff6b6b]", kind: "heart" }); 18 + expect(notificationReasonIcon("repost")).toEqual({ className: "text-[#4cd964]", kind: "repost" }); 19 + expect(notificationReasonIcon("mention")).toEqual({ className: "text-primary", kind: "reply" }); 20 + expect(notificationReasonIcon("quote")).toEqual({ className: "text-primary", kind: "quote" }); 21 + }); 22 + 23 + it("falls back for unknown reasons", () => { 24 + expect(notificationReasonCopy("unexpected-reason")).toBe("interacted with your post"); 25 + expect(notificationReasonIcon("unexpected-reason")).toEqual({ 26 + className: "text-on-surface-variant", 27 + kind: "notifications", 28 + }); 29 + }); 30 + 31 + it("chooses body target uri based on reason semantics", () => { 32 + expect( 33 + notificationBodyTargetUri({ 34 + reason: "like", 35 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/original", 36 + uri: "at://did:plc:alice/app.bsky.feed.like/1", 37 + }), 38 + ).toBe("at://did:plc:post/app.bsky.feed.post/original"); 39 + 40 + expect( 41 + notificationBodyTargetUri({ 42 + reason: "reply", 43 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/original", 44 + uri: "at://did:plc:alice/app.bsky.feed.post/reply", 45 + }), 46 + ).toBe("at://did:plc:alice/app.bsky.feed.post/reply"); 47 + 48 + expect( 49 + notificationBodyTargetUri({ 50 + reason: "follow", 51 + reasonSubject: null, 52 + uri: "at://did:plc:alice/app.bsky.graph.follow/1", 53 + }), 54 + ).toBeNull(); 55 + }); 56 + 57 + it("returns original post uri for reply/quote links", () => { 58 + expect( 59 + notificationOriginalPostUri({ 60 + reason: "reply", 61 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/original", 62 + uri: "at://did:plc:alice/app.bsky.feed.post/reply", 63 + }), 64 + ).toBe("at://did:plc:post/app.bsky.feed.post/original"); 65 + 66 + expect( 67 + notificationOriginalPostUri({ 68 + reason: "like", 69 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/original", 70 + uri: "at://did:plc:alice/app.bsky.feed.like/1", 71 + }), 72 + ).toBeNull(); 73 + }); 74 + });
+92
src/components/notifications/notification-copy.ts
··· 1 + import type { NotificationReason, NotificationView } from "$/lib/types"; 2 + import type { IconKind } from "../shared/Icon"; 3 + 4 + type NotificationIconDescriptor = { className: string; kind: IconKind }; 5 + type NotificationTargetSource = Pick<NotificationView, "reason" | "reasonSubject" | "uri">; 6 + 7 + export function notificationReasonCopy(reason: NotificationReason) { 8 + switch (reason) { 9 + case "like": { 10 + return "liked your post"; 11 + } 12 + case "repost": { 13 + return "reposted your post"; 14 + } 15 + case "mention": { 16 + return "mentioned you"; 17 + } 18 + case "reply": { 19 + return "replied to you"; 20 + } 21 + case "quote": { 22 + return "quoted your post"; 23 + } 24 + case "follow": { 25 + return "followed you"; 26 + } 27 + case "starterpack-joined": { 28 + return "joined via your starter pack"; 29 + } 30 + case "verified": { 31 + return "triggered a verification update"; 32 + } 33 + case "unverified": { 34 + return "triggered a verification update"; 35 + } 36 + default: { 37 + return "interacted with your post"; 38 + } 39 + } 40 + } 41 + 42 + export function notificationReasonIcon(reason: NotificationReason): NotificationIconDescriptor { 43 + switch (reason) { 44 + case "like": { 45 + return { className: "text-[#ff6b6b]", kind: "heart" }; 46 + } 47 + case "repost": { 48 + return { className: "text-[#4cd964]", kind: "repost" }; 49 + } 50 + case "mention": 51 + case "reply": { 52 + return { className: "text-primary", kind: "reply" }; 53 + } 54 + case "quote": { 55 + return { className: "text-primary", kind: "quote" }; 56 + } 57 + case "follow": { 58 + return { className: "text-primary", kind: "follow" }; 59 + } 60 + default: { 61 + return { className: "text-on-surface-variant", kind: "notifications" }; 62 + } 63 + } 64 + } 65 + 66 + function normalizeUri(value: string | null | undefined) { 67 + if (typeof value !== "string") { 68 + return null; 69 + } 70 + 71 + const uri = value.trim(); 72 + return uri.length > 0 ? uri : null; 73 + } 74 + 75 + export function notificationOriginalPostUri(notification: NotificationTargetSource) { 76 + if (notification.reason !== "reply" && notification.reason !== "quote") { 77 + return null; 78 + } 79 + 80 + return normalizeUri(notification.reasonSubject); 81 + } 82 + 83 + export function notificationBodyTargetUri(notification: NotificationTargetSource) { 84 + const sourceUri = normalizeUri(notification.uri); 85 + const subjectUri = normalizeUri(notification.reasonSubject); 86 + 87 + if (notification.reason === "reply" || notification.reason === "quote") { 88 + return sourceUri ?? subjectUri; 89 + } 90 + 91 + return subjectUri; 92 + }
+141
src/components/notifications/notification-grouping.test.ts
··· 1 + import type { NotificationView } from "$/lib/types"; 2 + import { describe, expect, it } from "vitest"; 3 + import { 4 + buildAllNotificationsFeed, 5 + groupActivityNotifications, 6 + splitByReadState, 7 + toSingleFeedItems, 8 + } from "./notification-grouping"; 9 + 10 + function createNotification(reason: string, overrides: Partial<NotificationView> = {}): NotificationView { 11 + return { 12 + author: { did: `did:plc:${reason}`, displayName: `${reason} author`, handle: `${reason}.test` }, 13 + cid: `cid-${reason}`, 14 + indexedAt: "2026-03-29T12:00:00.000Z", 15 + isRead: false, 16 + reason, 17 + record: { text: `${reason} text` }, 18 + uri: `at://did:plc:${reason}/app.bsky.notification/${reason}`, 19 + ...overrides, 20 + }; 21 + } 22 + 23 + describe("notification-grouping", () => { 24 + it("groups activity by reason + reasonSubject", () => { 25 + const notifications = [ 26 + createNotification("like", { 27 + author: { did: "did:plc:alice", displayName: "Alice", handle: "alice.test" }, 28 + indexedAt: "2026-03-29T12:10:00.000Z", 29 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 30 + uri: "at://did:plc:alice/app.bsky.notification/1", 31 + }), 32 + createNotification("like", { 33 + author: { did: "did:plc:bob", displayName: "Bob", handle: "bob.test" }, 34 + indexedAt: "2026-03-29T12:08:00.000Z", 35 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 36 + uri: "at://did:plc:bob/app.bsky.notification/2", 37 + }), 38 + createNotification("repost", { 39 + author: { did: "did:plc:carol", displayName: "Carol", handle: "carol.test" }, 40 + indexedAt: "2026-03-29T12:09:00.000Z", 41 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 42 + uri: "at://did:plc:carol/app.bsky.notification/3", 43 + }), 44 + ]; 45 + 46 + const grouped = groupActivityNotifications(notifications); 47 + 48 + expect(grouped).toHaveLength(2); 49 + const likeGroup = grouped.find((item) => item.kind === "group" && item.reason === "like"); 50 + if (!likeGroup || likeGroup.kind !== "group") { 51 + throw new Error("expected grouped like activity"); 52 + } 53 + 54 + expect(likeGroup.count).toBe(2); 55 + expect(likeGroup.actorCount).toBe(2); 56 + expect(grouped.some((item) => item.kind === "single")).toBe(true); 57 + }); 58 + 59 + it("does not group notifications without reasonSubject", () => { 60 + const notifications = [ 61 + createNotification("like", { uri: "at://did:plc:alice/app.bsky.notification/1" }), 62 + createNotification("like", { uri: "at://did:plc:bob/app.bsky.notification/2" }), 63 + ]; 64 + 65 + const grouped = groupActivityNotifications(notifications); 66 + 67 + expect(grouped).toHaveLength(2); 68 + expect(grouped.every((item) => item.kind === "single")).toBe(true); 69 + }); 70 + 71 + it("propagates unread state and chooses the newest timestamp for grouped activity", () => { 72 + const notifications = [ 73 + createNotification("like", { 74 + indexedAt: "2026-03-29T12:10:00.000Z", 75 + isRead: true, 76 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 77 + uri: "at://did:plc:alice/app.bsky.notification/1", 78 + }), 79 + createNotification("like", { 80 + indexedAt: "2026-03-29T12:08:00.000Z", 81 + isRead: false, 82 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 83 + uri: "at://did:plc:bob/app.bsky.notification/2", 84 + }), 85 + ]; 86 + 87 + const grouped = groupActivityNotifications(notifications); 88 + 89 + expect(grouped).toHaveLength(1); 90 + expect(grouped[0].kind).toBe("group"); 91 + expect(grouped[0].isUnread).toBe(true); 92 + expect(grouped[0].latestIndexedAt).toBe("2026-03-29T12:10:00.000Z"); 93 + }); 94 + 95 + it("sorts all feed items by newest timestamp across mentions and grouped activity", () => { 96 + const mentions = [ 97 + createNotification("mention", { 98 + indexedAt: "2026-03-29T12:12:00.000Z", 99 + uri: "at://did:plc:mention/app.bsky.notification/1", 100 + }), 101 + ]; 102 + 103 + const activity = groupActivityNotifications([ 104 + createNotification("like", { 105 + indexedAt: "2026-03-29T12:10:00.000Z", 106 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 107 + uri: "at://did:plc:alice/app.bsky.notification/2", 108 + }), 109 + createNotification("like", { 110 + indexedAt: "2026-03-29T12:08:00.000Z", 111 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 112 + uri: "at://did:plc:bob/app.bsky.notification/3", 113 + }), 114 + ]); 115 + 116 + const all = buildAllNotificationsFeed(mentions, activity); 117 + 118 + expect(all).toHaveLength(2); 119 + if (all[0].kind !== "single") { 120 + throw new Error("expected mention row first"); 121 + } 122 + 123 + if (all[1].kind !== "group") { 124 + throw new Error("expected grouped activity row second"); 125 + } 126 + 127 + expect(all[0].notification.reason).toBe("mention"); 128 + }); 129 + 130 + it("splits feed rows into new and earlier sections", () => { 131 + const items = toSingleFeedItems([ 132 + createNotification("mention", { isRead: false, uri: "at://did:plc:mention/app.bsky.notification/1" }), 133 + createNotification("reply", { isRead: true, uri: "at://did:plc:reply/app.bsky.notification/2" }), 134 + ]); 135 + 136 + const sections = splitByReadState(items); 137 + 138 + expect(sections.newer).toHaveLength(1); 139 + expect(sections.earlier).toHaveLength(1); 140 + }); 141 + });
+165
src/components/notifications/notification-grouping.ts
··· 1 + import { asRecord } from "$/lib/type-guards"; 2 + import type { NotificationReason, NotificationView, ProfileViewBasic } from "$/lib/types"; 3 + 4 + export type NotificationFeedItem = SingleNotificationFeedItem | GroupedNotificationFeedItem; 5 + 6 + export type SingleNotificationFeedItem = { 7 + isUnread: boolean; 8 + key: string; 9 + kind: "single"; 10 + latestIndexedAt: string; 11 + notification: NotificationView; 12 + }; 13 + 14 + export type GroupedNotificationFeedItem = { 15 + actorCount: number; 16 + actors: ProfileViewBasic[]; 17 + count: number; 18 + isUnread: boolean; 19 + key: string; 20 + kind: "group"; 21 + latestIndexedAt: string; 22 + notifications: NotificationView[]; 23 + reason: NotificationReason; 24 + reasonSubject: string; 25 + sampleRecordText: string | null; 26 + }; 27 + 28 + export const MENTION_REASONS = new Set(["mention", "reply", "quote"]); 29 + 30 + export function isMentionNotification(notification: NotificationView) { 31 + return MENTION_REASONS.has(notification.reason); 32 + } 33 + 34 + function toTimestamp(value: string) { 35 + const timestamp = Date.parse(value); 36 + return Number.isNaN(timestamp) ? 0 : timestamp; 37 + } 38 + 39 + export function compareByNewest(left: { latestIndexedAt: string }, right: { latestIndexedAt: string }) { 40 + const timestampDelta = toTimestamp(right.latestIndexedAt) - toTimestamp(left.latestIndexedAt); 41 + if (timestampDelta !== 0) { 42 + return timestampDelta; 43 + } 44 + 45 + return right.latestIndexedAt.localeCompare(left.latestIndexedAt); 46 + } 47 + 48 + function compareNotificationsByNewest(left: NotificationView, right: NotificationView) { 49 + return compareByNewest({ latestIndexedAt: left.indexedAt }, { latestIndexedAt: right.indexedAt }); 50 + } 51 + 52 + function recordText(notification: NotificationView) { 53 + const record = asRecord(notification.record); 54 + const text = record?.text; 55 + return typeof text === "string" && text.trim() ? text.trim() : null; 56 + } 57 + 58 + function isGroupableActivity(notification: NotificationView) { 59 + if (isMentionNotification(notification)) { 60 + return false; 61 + } 62 + 63 + return typeof notification.reasonSubject === "string" && notification.reasonSubject.trim().length > 0; 64 + } 65 + 66 + function dedupeActors(notifications: NotificationView[]) { 67 + const actorByDid = new Map<string, ProfileViewBasic>(); 68 + 69 + for (const notification of notifications) { 70 + const actorDid = notification.author.did; 71 + if (!actorByDid.has(actorDid)) { 72 + actorByDid.set(actorDid, notification.author); 73 + } 74 + } 75 + 76 + return [...actorByDid.values()]; 77 + } 78 + 79 + function toSingleNotificationFeedItem(notification: NotificationView): SingleNotificationFeedItem { 80 + return { 81 + isUnread: !notification.isRead, 82 + key: `single:${notification.uri}`, 83 + kind: "single", 84 + latestIndexedAt: notification.indexedAt, 85 + notification, 86 + }; 87 + } 88 + 89 + export function groupActivityNotifications(notifications: NotificationView[]): NotificationFeedItem[] { 90 + const grouped = new Map<string, NotificationView[]>(); 91 + const singles: NotificationFeedItem[] = []; 92 + 93 + for (const notification of notifications) { 94 + if (!isGroupableActivity(notification)) { 95 + singles.push(toSingleNotificationFeedItem(notification)); 96 + continue; 97 + } 98 + 99 + const reasonSubject = notification.reasonSubject!.trim(); 100 + const groupKey = `${notification.reason}:${reasonSubject}`; 101 + const current = grouped.get(groupKey); 102 + 103 + if (current) { 104 + current.push(notification); 105 + } else { 106 + grouped.set(groupKey, [notification]); 107 + } 108 + } 109 + 110 + const groupedItems: NotificationFeedItem[] = []; 111 + 112 + for (const [groupKey, items] of grouped) { 113 + if (items.length === 1) { 114 + groupedItems.push(toSingleNotificationFeedItem(items[0])); 115 + continue; 116 + } 117 + 118 + const sorted = [...items].toSorted(compareNotificationsByNewest); 119 + const latest = sorted[0]; 120 + const actors = dedupeActors(sorted); 121 + 122 + groupedItems.push({ 123 + actorCount: actors.length, 124 + actors, 125 + count: sorted.length, 126 + isUnread: sorted.some((notification) => !notification.isRead), 127 + key: `group:${groupKey}`, 128 + kind: "group", 129 + latestIndexedAt: latest.indexedAt, 130 + notifications: sorted, 131 + reason: latest.reason, 132 + reasonSubject: latest.reasonSubject!.trim(), 133 + sampleRecordText: sorted.map((notification) => recordText(notification)).find((text) => text !== null) ?? null, 134 + }); 135 + } 136 + 137 + return [...singles, ...groupedItems].toSorted(compareByNewest); 138 + } 139 + 140 + export function toSingleFeedItems(notifications: NotificationView[]): SingleNotificationFeedItem[] { 141 + return notifications.map((notification) => toSingleNotificationFeedItem(notification)); 142 + } 143 + 144 + export function buildAllNotificationsFeed( 145 + mentions: NotificationView[], 146 + activityItems: NotificationFeedItem[], 147 + ): NotificationFeedItem[] { 148 + const mentionItems = toSingleFeedItems(mentions); 149 + return [...mentionItems, ...activityItems].toSorted(compareByNewest); 150 + } 151 + 152 + export function splitByReadState(items: NotificationFeedItem[]) { 153 + const newer: NotificationFeedItem[] = []; 154 + const earlier: NotificationFeedItem[] = []; 155 + 156 + for (const item of items) { 157 + if (item.isUnread) { 158 + newer.push(item); 159 + } else { 160 + earlier.push(item); 161 + } 162 + } 163 + 164 + return { earlier, newer }; 165 + }