BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import type { NotificationView } from "$/lib/types";
2import { describe, expect, it } from "vitest";
3import {
4 buildAllNotificationsFeed,
5 groupActivityNotifications,
6 splitByReadState,
7 toSingleFeedItems,
8} from "../notification-grouping";
9
10function 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
23describe("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});