BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
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});