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 469 lines 17 kB view raw
1import { AppTestProviders } from "$/test/providers"; 2import { HashRouter, Route } from "@solidjs/router"; 3import { fireEvent, render, screen, waitFor, within } from "@solidjs/testing-library"; 4import { beforeEach, describe, expect, it, vi } from "vitest"; 5import { NotificationsPanel } from "../NotificationsPanel"; 6 7const listNotificationsMock = vi.hoisted(() => vi.fn()); 8const updateSeenMock = vi.hoisted(() => vi.fn()); 9const listenMock = vi.hoisted(() => vi.fn()); 10const warnMock = vi.hoisted(() => vi.fn()); 11const moderateContentMock = vi.hoisted(() => vi.fn()); 12 13vi.mock("$/lib/api/notifications", () => ({ listNotifications: listNotificationsMock, updateSeen: updateSeenMock })); 14vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 15vi.mock("@tauri-apps/plugin-log", () => ({ warn: warnMock })); 16vi.mock("$/lib/api/moderation", () => ({ ModerationController: { moderateContent: moderateContentMock } })); 17 18function createNotification(reason: string, overrides: Record<string, unknown> = {}) { 19 return { 20 author: { did: `did:plc:${reason}`, displayName: `${reason} author`, handle: `${reason}.test` }, 21 cid: `cid-${reason}`, 22 indexedAt: "2026-03-29T12:00:00.000Z", 23 isRead: false, 24 reason, 25 record: { text: `${reason} detail` }, 26 uri: `at://did:plc:${reason}/app.bsky.notification/${reason}`, 27 ...overrides, 28 }; 29} 30 31function renderNotificationsPanelWithRouter() { 32 render(() => ( 33 <AppTestProviders> 34 <HashRouter> 35 <Route path="/notifications" component={() => <NotificationsPanel />} /> 36 </HashRouter> 37 </AppTestProviders> 38 )); 39} 40 41async function flushRouterNavigation() { 42 await vi.runAllTimersAsync(); 43 await Promise.resolve(); 44 await Promise.resolve(); 45} 46 47describe("NotificationsPanel", () => { 48 beforeEach(() => { 49 vi.useFakeTimers(); 50 vi.setSystemTime(new Date("2026-03-29T12:30:00.000Z")); 51 globalThis.location.hash = "#/notifications"; 52 listNotificationsMock.mockReset(); 53 updateSeenMock.mockReset(); 54 listenMock.mockReset(); 55 warnMock.mockReset(); 56 moderateContentMock.mockReset(); 57 updateSeenMock.mockResolvedValue(void 0); 58 listenMock.mockResolvedValue(() => {}); 59 moderateContentMock.mockResolvedValue({ 60 alert: false, 61 blur: "none", 62 filter: false, 63 inform: false, 64 noOverride: false, 65 }); 66 }); 67 68 it("defaults to the all tab and does not auto-mark seen", async () => { 69 listNotificationsMock.mockResolvedValue({ 70 cursor: null, 71 notifications: [ 72 createNotification("mention", { 73 indexedAt: "2026-03-29T12:10:00.000Z", 74 uri: "at://did:plc:mention/app.bsky.notification/1", 75 }), 76 createNotification("like", { 77 indexedAt: "2026-03-29T12:00:00.000Z", 78 reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 79 uri: "at://did:plc:like/app.bsky.notification/2", 80 }), 81 ], 82 seenAt: null, 83 }); 84 85 render(() => ( 86 <AppTestProviders> 87 <NotificationsPanel /> 88 </AppTestProviders> 89 )); 90 91 await screen.findByLabelText("mention author mentioned you"); 92 expect(screen.getByRole("button", { name: /^All/ })).toHaveAttribute("aria-pressed", "true"); 93 expect(screen.getByLabelText("like author liked your post")).toBeInTheDocument(); 94 expect(updateSeenMock).not.toHaveBeenCalled(); 95 }); 96 97 it("marks everything read only when the user clicks mark all read", async () => { 98 listNotificationsMock.mockResolvedValue({ 99 cursor: null, 100 notifications: [createNotification("mention")], 101 seenAt: null, 102 }); 103 104 const markNotificationsSeen = vi.fn(); 105 render(() => ( 106 <AppTestProviders session={{ markNotificationsSeen }}> 107 <NotificationsPanel /> 108 </AppTestProviders> 109 )); 110 111 await screen.findByLabelText("mention author mentioned you"); 112 expect(screen.getByRole("heading", { name: "New" })).toBeInTheDocument(); 113 114 fireEvent.click(screen.getByRole("button", { name: /mark all read/i })); 115 116 await waitFor(() => expect(updateSeenMock).toHaveBeenCalledOnce()); 117 await waitFor(() => expect(markNotificationsSeen).toHaveBeenCalledOnce()); 118 expect(screen.queryByLabelText("Unread")).not.toBeInTheDocument(); 119 expect(screen.getByRole("heading", { name: "Earlier" })).toBeInTheDocument(); 120 }); 121 122 it("renders new and earlier sections", async () => { 123 listNotificationsMock.mockResolvedValue({ 124 cursor: null, 125 notifications: [ 126 createNotification("mention", { 127 indexedAt: "2026-03-29T12:10:00.000Z", 128 uri: "at://did:plc:mention/app.bsky.notification/1", 129 }), 130 createNotification("reply", { 131 indexedAt: "2026-03-29T10:00:00.000Z", 132 isRead: true, 133 uri: "at://did:plc:reply/app.bsky.notification/2", 134 }), 135 ], 136 seenAt: null, 137 }); 138 139 render(() => ( 140 <AppTestProviders> 141 <NotificationsPanel /> 142 </AppTestProviders> 143 )); 144 145 await screen.findByLabelText("mention author mentioned you"); 146 expect(screen.getByRole("heading", { name: "New" })).toBeInTheDocument(); 147 expect(screen.getByRole("heading", { name: "Earlier" })).toBeInTheDocument(); 148 }); 149 150 it("groups activity by reason + reasonSubject in the activity tab", async () => { 151 listNotificationsMock.mockResolvedValue({ 152 cursor: null, 153 notifications: [ 154 createNotification("like", { 155 author: { did: "did:plc:alice", displayName: "Alice", handle: "alice.test" }, 156 indexedAt: "2026-03-29T12:10:00.000Z", 157 reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 158 uri: "at://did:plc:like/app.bsky.notification/1", 159 }), 160 createNotification("like", { 161 author: { did: "did:plc:bob", displayName: "Bob", handle: "bob.test" }, 162 indexedAt: "2026-03-29T12:05:00.000Z", 163 reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 164 uri: "at://did:plc:like/app.bsky.notification/2", 165 }), 166 ], 167 seenAt: null, 168 }); 169 170 render(() => ( 171 <AppTestProviders> 172 <NotificationsPanel /> 173 </AppTestProviders> 174 )); 175 176 await screen.findByText(/liked your post/i); 177 fireEvent.click(screen.getByRole("button", { name: /activity/i })); 178 179 await waitFor(() => { 180 const items = screen.getAllByRole("listitem"); 181 expect(items).toHaveLength(1); 182 }); 183 184 expect(screen.getByText("Alice and Bob liked your post")).toBeInTheDocument(); 185 const aliceLink = screen.getByRole("link", { name: "View @alice.test" }); 186 const bobLink = screen.getByRole("link", { name: "View @bob.test" }); 187 expect(aliceLink).toHaveAttribute("href", "#/profile/alice.test"); 188 expect(bobLink).toHaveAttribute("href", "#/profile/bob.test"); 189 expect(screen.queryByLabelText("like author liked your post")).not.toBeInTheDocument(); 190 }); 191 192 it("opens the responded post when clicking a notification body", async () => { 193 listNotificationsMock.mockResolvedValue({ 194 cursor: null, 195 notifications: [ 196 createNotification("like", { 197 reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 198 uri: "at://did:plc:like/app.bsky.notification/1", 199 }), 200 ], 201 seenAt: null, 202 }); 203 204 renderNotificationsPanelWithRouter(); 205 206 const body = await screen.findByRole("button", { name: /like author liked your post/i }); 207 fireEvent.click(body); 208 209 await flushRouterNavigation(); 210 expect(globalThis.location.hash).toBe( 211 "#/notifications?thread=at%3A%2F%2Fdid%3Aplc%3Apost%2Fapp.bsky.feed.post%2F1", 212 ); 213 expect(screen.queryByLabelText("Unread")).not.toBeInTheDocument(); 214 }); 215 216 it("opens the selected thread when clicking different notification rows", async () => { 217 listNotificationsMock.mockResolvedValue({ 218 cursor: null, 219 notifications: [ 220 createNotification("like", { 221 author: { did: "did:plc:alice", displayName: "Alice", handle: "alice.test" }, 222 reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 223 uri: "at://did:plc:like/app.bsky.notification/1", 224 }), 225 createNotification("like", { 226 author: { did: "did:plc:bob", displayName: "Bob", handle: "bob.test" }, 227 reasonSubject: "at://did:plc:post/app.bsky.feed.post/2", 228 uri: "at://did:plc:like/app.bsky.notification/2", 229 }), 230 ], 231 seenAt: null, 232 }); 233 234 renderNotificationsPanelWithRouter(); 235 236 const firstBody = await screen.findByRole("button", { name: /alice liked your post/i }); 237 fireEvent.click(firstBody); 238 await flushRouterNavigation(); 239 expect(globalThis.location.hash).toBe( 240 "#/notifications?thread=at%3A%2F%2Fdid%3Aplc%3Apost%2Fapp.bsky.feed.post%2F1", 241 ); 242 243 const secondBody = screen.getByRole("button", { name: /bob liked your post/i }); 244 fireEvent.click(secondBody); 245 await flushRouterNavigation(); 246 expect(globalThis.location.hash).toBe( 247 "#/notifications?thread=at%3A%2F%2Fdid%3Aplc%3Apost%2Fapp.bsky.feed.post%2F2", 248 ); 249 }); 250 251 it("opens reply/quote target on body click and links original as 'your post'", async () => { 252 listNotificationsMock.mockResolvedValue({ 253 cursor: null, 254 notifications: [ 255 createNotification("reply", { 256 author: { did: "did:plc:alice", displayName: "Alice", handle: "alice.test" }, 257 reasonSubject: "at://did:plc:post/app.bsky.feed.post/original", 258 uri: "at://did:plc:alice/app.bsky.feed.post/reply", 259 }), 260 ], 261 seenAt: null, 262 }); 263 264 renderNotificationsPanelWithRouter(); 265 266 const yourPost = await screen.findByRole("link", { name: "your post" }); 267 expect(yourPost).toHaveAttribute( 268 "href", 269 "#/notifications?thread=at%3A%2F%2Fdid%3Aplc%3Apost%2Fapp.bsky.feed.post%2Foriginal", 270 ); 271 272 const body = screen.getByRole("button", { name: /alice replied to.*your post/i }); 273 fireEvent.click(body); 274 await flushRouterNavigation(); 275 expect(globalThis.location.hash).toBe( 276 "#/notifications?thread=at%3A%2F%2Fdid%3Aplc%3Aalice%2Fapp.bsky.feed.post%2Freply", 277 ); 278 expect(screen.queryByLabelText("Unread")).not.toBeInTheDocument(); 279 }); 280 281 it("marks a notification read when profile avatar is clicked", async () => { 282 listNotificationsMock.mockResolvedValue({ 283 cursor: null, 284 notifications: [ 285 createNotification("mention", { 286 author: { did: "did:plc:alice", displayName: "Alice", handle: "alice.test" }, 287 uri: "at://did:plc:mention/app.bsky.notification/1", 288 }), 289 ], 290 seenAt: null, 291 }); 292 293 render(() => ( 294 <AppTestProviders> 295 <NotificationsPanel /> 296 </AppTestProviders> 297 )); 298 299 const avatarLink = await screen.findByRole("link", { name: "View @alice.test" }); 300 fireEvent.click(avatarLink); 301 expect(screen.queryByLabelText("Unread")).not.toBeInTheDocument(); 302 }); 303 304 it("keeps mentions ungrouped in the mentions tab", async () => { 305 listNotificationsMock.mockResolvedValue({ 306 cursor: null, 307 notifications: [ 308 createNotification("mention", { uri: "at://did:plc:mention/app.bsky.notification/1" }), 309 createNotification("reply", { uri: "at://did:plc:reply/app.bsky.notification/2" }), 310 ], 311 seenAt: null, 312 }); 313 314 render(() => ( 315 <AppTestProviders> 316 <NotificationsPanel /> 317 </AppTestProviders> 318 )); 319 320 await screen.findByLabelText("mention author mentioned you"); 321 fireEvent.click(screen.getByRole("button", { name: /mentions/i })); 322 323 await waitFor(() => { 324 const items = screen.getAllByRole("listitem"); 325 expect(items).toHaveLength(2); 326 }); 327 }); 328 329 it("sorts all-tab rows by newest timestamp across mentions and grouped activity", async () => { 330 listNotificationsMock.mockResolvedValue({ 331 cursor: null, 332 notifications: [ 333 createNotification("like", { 334 author: { did: "did:plc:alice", displayName: "Alice", handle: "alice.test" }, 335 indexedAt: "2026-03-29T12:10:00.000Z", 336 reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 337 uri: "at://did:plc:like/app.bsky.notification/1", 338 }), 339 createNotification("like", { 340 author: { did: "did:plc:bob", displayName: "Bob", handle: "bob.test" }, 341 indexedAt: "2026-03-29T12:08:00.000Z", 342 reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 343 uri: "at://did:plc:like/app.bsky.notification/2", 344 }), 345 createNotification("mention", { 346 author: { did: "did:plc:carol", displayName: "Carol", handle: "carol.test" }, 347 indexedAt: "2026-03-29T12:12:00.000Z", 348 uri: "at://did:plc:mention/app.bsky.notification/3", 349 }), 350 ], 351 seenAt: null, 352 }); 353 354 render(() => ( 355 <AppTestProviders> 356 <NotificationsPanel /> 357 </AppTestProviders> 358 )); 359 360 await screen.findByLabelText("Carol mentioned you"); 361 362 await waitFor(() => { 363 const items = screen.getAllByRole("listitem"); 364 expect(items).toHaveLength(2); 365 expect(within(items[0]).getByLabelText("Carol mentioned you")).toBeInTheDocument(); 366 expect(within(items[1]).getByText("Alice and Bob liked your post")).toBeInTheDocument(); 367 }); 368 }); 369 370 it("reloads when the unread-count event arrives", async () => { 371 let handleUnreadUpdate: (() => void) | undefined; 372 373 listNotificationsMock.mockResolvedValueOnce({ 374 cursor: null, 375 notifications: [createNotification("mention")], 376 seenAt: null, 377 }).mockResolvedValueOnce({ 378 cursor: null, 379 notifications: [createNotification("mention"), createNotification("reply")], 380 seenAt: null, 381 }); 382 383 listenMock.mockImplementation((_event: string, callback: () => void) => { 384 handleUnreadUpdate = callback; 385 return Promise.resolve(() => {}); 386 }); 387 388 render(() => ( 389 <AppTestProviders> 390 <NotificationsPanel /> 391 </AppTestProviders> 392 )); 393 394 await screen.findByLabelText("mention author mentioned you"); 395 396 handleUnreadUpdate?.(); 397 398 await waitFor(() => expect(listNotificationsMock).toHaveBeenCalledTimes(2)); 399 expect(await screen.findByLabelText("reply author replied to you")).toBeInTheDocument(); 400 expect(updateSeenMock).not.toHaveBeenCalled(); 401 }); 402 403 it("shows the error state when loading fails", async () => { 404 listNotificationsMock.mockRejectedValue(new Error("notification fetch failed")); 405 406 render(() => ( 407 <AppTestProviders> 408 <NotificationsPanel /> 409 </AppTestProviders> 410 )); 411 412 expect(await screen.findByText("notification fetch failed")).toBeInTheDocument(); 413 expect(updateSeenMock).not.toHaveBeenCalled(); 414 expect(warnMock).not.toHaveBeenCalled(); 415 }); 416 417 it("shows profile moderation badges for single and grouped activity rows", async () => { 418 listNotificationsMock.mockResolvedValue({ 419 cursor: null, 420 notifications: [ 421 createNotification("follow", { 422 author: { 423 did: "did:plc:single", 424 displayName: "Single Author", 425 handle: "single.test", 426 labels: [{ src: "did:plc:labeler", val: "sexual" }], 427 }, 428 uri: "at://did:plc:single/app.bsky.notification/1", 429 }), 430 createNotification("like", { 431 author: { 432 did: "did:plc:alice", 433 displayName: "Alice", 434 handle: "alice.test", 435 labels: [{ src: "did:plc:labeler", val: "sexual" }], 436 }, 437 reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 438 uri: "at://did:plc:like/app.bsky.notification/2", 439 }), 440 createNotification("like", { 441 author: { 442 did: "did:plc:bob", 443 displayName: "Bob", 444 handle: "bob.test", 445 labels: [{ src: "did:plc:labeler", val: "sexual" }], 446 }, 447 reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 448 uri: "at://did:plc:like/app.bsky.notification/3", 449 }), 450 ], 451 seenAt: null, 452 }); 453 moderateContentMock.mockImplementation(async (_labels, context: string) => { 454 if (context === "profileList") { 455 return { alert: true, blur: "none", filter: false, inform: false, noOverride: false }; 456 } 457 458 return { alert: false, blur: "none", filter: false, inform: false, noOverride: false }; 459 }); 460 461 renderNotificationsPanelWithRouter(); 462 await screen.findByLabelText("Single Author followed you"); 463 await waitFor(() => expect(screen.getAllByText("Alert").length).toBeGreaterThan(0)); 464 465 fireEvent.click(screen.getByRole("button", { name: /activity/i })); 466 await waitFor(() => expect(screen.getByText("Alice and Bob liked your post")).toBeInTheDocument()); 467 await waitFor(() => expect(screen.getAllByText("Alert").length).toBeGreaterThan(0)); 468 }); 469});