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 165 lines 5.0 kB view raw
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}