BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { asRecord } from "$/lib/type-guards";
2import type { NotificationReason, NotificationView, ProfileViewBasic } from "$/lib/types";
3
4export type NotificationFeedItem = SingleNotificationFeedItem | GroupedNotificationFeedItem;
5
6export type SingleNotificationFeedItem = {
7 isUnread: boolean;
8 key: string;
9 kind: "single";
10 latestIndexedAt: string;
11 notification: NotificationView;
12};
13
14export 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
28const MENTION_REASONS = new Set(["mention", "reply", "quote"]);
29
30export function isMentionNotification(notification: NotificationView) {
31 return MENTION_REASONS.has(notification.reason);
32}
33
34function toTimestamp(value: string) {
35 const timestamp = Date.parse(value);
36 return Number.isNaN(timestamp) ? 0 : timestamp;
37}
38
39function 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
48function compareNotificationsByNewest(left: NotificationView, right: NotificationView) {
49 return compareByNewest({ latestIndexedAt: left.indexedAt }, { latestIndexedAt: right.indexedAt });
50}
51
52function 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
58function 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
66function 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
79function 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
89export 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
140export function toSingleFeedItems(notifications: NotificationView[]): SingleNotificationFeedItem[] {
141 return notifications.map((notification) => toSingleNotificationFeedItem(notification));
142}
143
144export function buildAllNotificationsFeed(
145 mentions: NotificationView[],
146 activityItems: NotificationFeedItem[],
147): NotificationFeedItem[] {
148 const mentionItems = toSingleFeedItems(mentions);
149 return [...mentionItems, ...activityItems].toSorted(compareByNewest);
150}
151
152export 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}