[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(notifications): lastSeenNotifs

+140 -59
-19
api/so/sprk/actor/getProfile.ts
··· 10 10 import { createPipeline, noRules } from "../../../../pipeline.ts"; 11 11 import { Views } from "../../../../views/index.ts"; 12 12 import { resHeaders } from "../../../util.ts"; 13 - import { getLogger } from "@logtape/logtape"; 14 - 15 - const logger = getLogger(["appview", "getProfile"]); 16 13 17 14 export default function (server: Server, ctx: AppContext) { 18 15 const getProfile = createPipeline(skeleton, hydration, noRules, presentation); 19 16 server.so.sprk.actor.getProfile({ 20 17 auth: ctx.authVerifier.optionalStandardOrRole, 21 18 handler: async ({ auth, params, req }) => { 22 - const start = performance.now(); 23 19 24 20 const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth); 25 21 const labelers = ctx.reqLabelers(req); 26 - 27 - const t1 = performance.now(); 28 22 const hydrateCtx = await ctx.hydrator.createContext({ 29 23 labelers, 30 24 viewer, 31 25 includeTakedowns, 32 26 }); 33 - const t2 = performance.now(); 34 - 35 27 const result = await getProfile({ ...params, hydrateCtx }, ctx); 36 - const t3 = performance.now(); 37 - 38 28 const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer); 39 - const t4 = performance.now(); 40 - 41 - logger.info("getProfile timing", { 42 - viewer: !!viewer, 43 - createContext: Math.round(t2 - t1), 44 - pipeline: Math.round(t3 - t2), 45 - repoRev: Math.round(t4 - t3), 46 - total: Math.round(t4 - start), 47 - }); 48 29 49 30 return { 50 31 encoding: "application/json",
+7 -2
api/so/sprk/notification/getUnreadCount.ts
··· 19 19 noRules, 20 20 presentation, 21 21 ); 22 - server.app.bsky.notification.getUnreadCount({ 22 + server.so.sprk.notification.getUnreadCount({ 23 23 auth: ctx.authVerifier.standard, 24 24 handler: async ({ auth, params }) => { 25 25 const viewer = auth.credentials.iss; ··· 40 40 throw new InvalidRequestError("The seenAt parameter is unsupported"); 41 41 } 42 42 const priority = params.priority ?? false; 43 + 44 + // Get the stored lastSeenNotifs timestamp 45 + const lastSeenRes = await ctx.hydrator.dataplane.notifications 46 + .getNotificationSeen(params.viewer, priority); 47 + 43 48 const res = await ctx.hydrator.dataplane.notifications 44 49 .getUnreadNotificationCount( 45 50 params.viewer, 46 - undefined, 51 + lastSeenRes.timestamp, 47 52 priority, 48 53 ); 49 54 return {
+22 -12
api/so/sprk/notification/listNotifications.ts
··· 13 13 RulesFnInput, 14 14 SkeletonFnInput, 15 15 } from "../../../../pipeline.ts"; 16 - import { uriToDid as didFromUri } from "../../../../utils/uris.ts"; 17 16 import { Views } from "../../../../views/index.ts"; 18 17 import { resHeaders } from "../../../util.ts"; 19 18 ··· 101 100 */ 102 101 export const delayCursor = ( 103 102 cursorStr: string | undefined, 104 - delayMs: number, 105 - ): string => { 106 - const nowMinusDelay = Date.now() - delayMs; 107 - if (cursorStr === undefined) return new Date(nowMinusDelay).toISOString(); 108 - const cursor = new Date(cursorStr).getTime(); 109 - if (isNaN(cursor)) return cursorStr; 110 - return new Date(Math.min(cursor, nowMinusDelay)).toISOString(); 103 + _delayMs: number, 104 + ): string | undefined => { 105 + // The cursor is a packed keyset cursor (base36:cid), not an ISO timestamp. 106 + // We can't apply time-based delays to it without unpacking/repacking. 107 + // For now, just pass through the cursor as-is. 108 + // If no cursor, return undefined to fetch from the beginning. 109 + return cursorStr; 111 110 }; 112 111 113 112 const skeleton = async ( ··· 143 142 // rather than all notifications. bit of a hack to be more graceful when seen times are out of sync. 144 143 let lastSeenAt = lastSeenRes.timestamp; 145 144 if (!lastSeenAt && !originalCursor) { 146 - lastSeenAt = res.notifications.at(0)?.sortAt; 145 + // Set to 1ms before the first notification so it shows as unread (since we use >= comparison) 146 + const firstSortAt = res.notifications.at(0)?.sortAt; 147 + if (firstSortAt) { 148 + const firstTime = new Date(firstSortAt); 149 + firstTime.setMilliseconds(firstTime.getMilliseconds() - 1); 150 + lastSeenAt = firstTime.toISOString(); 151 + } 147 152 } 148 153 return { 149 154 notifs: res.notifications, ··· 168 173 ) => { 169 174 const { skeleton, hydration, ctx } = input; 170 175 skeleton.notifs = skeleton.notifs.filter((item) => { 171 - const did = didFromUri(item.uri); 176 + // Use authorDid directly (the person who created the notification action) 177 + // For likes, this is the liker; for replies, this is the replier, etc. 178 + const did = item.authorDid; 172 179 if ( 173 180 ctx.views.viewerBlockExists(did, hydration) || 174 181 ctx.views.viewerMuteExists(did, hydration) ··· 178 185 // Filter out notifications from users that need review unless moots 179 186 if ( 180 187 item.reason === "reply" || 181 - item.reason === "quote" || 182 188 item.reason === "mention" || 183 189 item.reason === "like" || 184 190 item.reason === "follow" 185 191 ) { 186 - if (!ctx.views.viewerSeesNeedsReview({ did, uri: item.uri }, hydration)) { 192 + const seesNeedsReview = ctx.views.viewerSeesNeedsReview( 193 + { did, uri: item.uri }, 194 + hydration, 195 + ); 196 + if (!seesNeedsReview) { 187 197 return false; 188 198 } 189 199 }
+2
data-plane/db/models.ts
··· 497 497 upstreamStatus: string | null; 498 498 keys: string[]; 499 499 services: string; 500 + lastSeenNotifs: string | null; 500 501 } 501 502 export const actorSchema = new Schema<ActorDocument>({ 502 503 did: { type: String, required: true, unique: true, index: true }, ··· 506 507 upstreamStatus: { type: String, required: false }, 507 508 keys: { type: [String], required: true }, 508 509 services: { type: String, required: true }, 510 + lastSeenNotifs: { type: String, required: false, default: null }, 509 511 }); 510 512 511 513 // preferences
+11 -11
data-plane/routes/notifs.ts
··· 142 142 _priority?: boolean, 143 143 ): Promise<{ timestamp?: string }> { 144 144 const actor = await this.db.models.Actor.findOne({ did: actorDid }); 145 - if (!actor) { 145 + if (!actor || !actor.lastSeenNotifs) { 146 146 return {}; 147 147 } 148 148 149 - // For now, we don't have lastSeenNotifs on Actor model 150 - // This would need to be added to track notification seen status 151 - // Returning empty for now 152 - return {}; 149 + return { timestamp: actor.lastSeenNotifs }; 153 150 } 154 151 155 152 async getUnreadNotificationCount( ··· 182 179 } 183 180 184 181 async updateNotificationSeen( 185 - _actorDid: string, 186 - _timestamp: string, 182 + actorDid: string, 183 + timestamp: string, 187 184 _priority?: boolean, 188 185 ): Promise<void> { 189 - // This would require adding notification seen tracking to the Actor model 190 - // or creating a separate ActorState model 191 - // For now, this is a no-op 186 + await this.db.models.Actor.findOneAndUpdate( 187 + { did: actorDid }, 188 + { $set: { lastSeenNotifs: timestamp } }, 189 + { upsert: false }, 190 + ); 192 191 } 193 192 194 193 // Helper methods ··· 226 225 } 227 226 228 227 const subjectUris = notifsWithSubject.map((n) => n.reasonSubject as string); 228 + 229 229 const existingRecords = await this.db.models.Record.find({ 230 230 uri: { $in: subjectUris }, 231 - }).select("uri"); 231 + }).select("uri").lean(); 232 232 233 233 const existingUris = new Set(existingRecords.map((r) => r.uri)); 234 234
+1 -1
data-plane/routes/records.ts
··· 146 146 } 147 147 148 148 async getRepostRecords(uris: string[]) { 149 - const result = await getRecords(this.db, uris, ids.AppBskyFeedRepost); 149 + const result = await getRecords(this.db, uris, ids.SoSprkFeedRepost); 150 150 return result; 151 151 } 152 152
+24 -10
data-plane/util.ts
··· 97 97 let height = 1; 98 98 99 99 while (currentUri && height <= parentHeight) { 100 - const parentReply = await db.models.Reply.findOne({ uri: currentUri }) 101 - .lean(); 102 - if (!parentReply) break; 100 + // Check if parent is a Post (root) or Reply 101 + const [parentPost, parentReply] = await Promise.all([ 102 + db.models.Post.findOne({ uri: currentUri }).lean(), 103 + db.models.Reply.findOne({ uri: currentUri }).lean(), 104 + ]); 103 105 104 - ancestors.push({ 105 - uri: parentReply.uri, 106 - height, 107 - }); 108 - 109 - currentUri = parentReply.reply?.parent?.uri; 110 - height++; 106 + if (parentPost) { 107 + // Found root post - add it and stop traversing 108 + ancestors.push({ 109 + uri: parentPost.uri, 110 + height, 111 + }); 112 + break; 113 + } else if (parentReply) { 114 + // Found a reply - add it and continue traversing 115 + ancestors.push({ 116 + uri: parentReply.uri, 117 + height, 118 + }); 119 + currentUri = parentReply.reply?.parent?.uri; 120 + height++; 121 + } else { 122 + // Parent not found - stop traversing 123 + break; 124 + } 111 125 } 112 126 113 127 return ancestors;
+26 -2
hydration/index.ts
··· 668 668 const likeUris = collections.get(ids.SoSprkFeedLike) ?? []; 669 669 const repostUris = collections.get(ids.SoSprkFeedRepost) ?? []; 670 670 const followUris = collections.get(ids.SoSprkGraphFollow) ?? []; 671 + 672 + // Collect subject URIs for like/repost notifications to hydrate their content 673 + const subjectPostUris: string[] = []; 674 + const subjectReplyUris: string[] = []; 675 + for (const notif of notifs) { 676 + if ( 677 + notif.reasonSubject && 678 + (notif.reason === "like" || notif.reason === "repost") 679 + ) { 680 + const subjectUri = new AtUri(notif.reasonSubject); 681 + if (subjectUri.collection === ids.SoSprkFeedPost) { 682 + subjectPostUris.push(notif.reasonSubject); 683 + } else if (subjectUri.collection === ids.SoSprkFeedReply) { 684 + subjectReplyUris.push(notif.reasonSubject); 685 + } 686 + } 687 + } 688 + 671 689 const [ 672 690 posts, 673 691 replies, ··· 676 694 follows, 677 695 labels, 678 696 profileState, 697 + subjectPosts, 698 + subjectReplies, 679 699 ] = await Promise.all([ 680 700 this.feed.getPosts(postUris), // reason: mention, quote 681 701 this.feed.getReplies(replyUris), // reason: reply ··· 684 704 this.graph.getFollows(followUris), // reason: follow 685 705 this.label.getLabelsForSubjects(uris, ctx.labelers), 686 706 this.hydrateProfiles(uris.map(didFromUri), ctx), 707 + this.feed.getPosts(subjectPostUris), // subjects of likes/reposts 708 + this.feed.getReplies(subjectReplyUris), // subjects of likes/reposts 687 709 ]); 688 710 const viewerRootPostUris = new Set<string>(); 689 711 for (const notif of notifs) { ··· 700 722 } 701 723 actionTakedownLabels(postUris, posts, labels); 702 724 actionTakedownLabels(replyUris, replies, labels); 725 + actionTakedownLabels(subjectPostUris, subjectPosts, labels); 726 + actionTakedownLabels(subjectReplyUris, subjectReplies, labels); 703 727 return mergeStates(profileState, { 704 - posts, 705 - replies, 728 + posts: mergeMaps(posts, subjectPosts), 729 + replies: mergeMaps(replies, subjectReplies), 706 730 likes, 707 731 reposts, 708 732 follows,
+47 -2
views/index.ts
··· 1111 1111 }) 1112 1112 : []; 1113 1113 const indexedAt = notif.sortAt; 1114 + 1115 + // For like/repost notifications, include the subject record (post/reply) in the response 1116 + let recordWithSubject = recordInfo.record; 1117 + if ( 1118 + (notif.reason === "like" || notif.reason === "repost") && 1119 + notif.reasonSubject 1120 + ) { 1121 + const subjectUri = new AtUri(notif.reasonSubject); 1122 + let subjectRecord: Post | Reply | undefined; 1123 + const isSubjectReply = subjectUri.collection === ids.SoSprkFeedReply; 1124 + if (subjectUri.collection === ids.SoSprkFeedPost) { 1125 + subjectRecord = state.posts?.get(notif.reasonSubject) ?? undefined; 1126 + } else if (isSubjectReply) { 1127 + subjectRecord = state.replies?.get(notif.reasonSubject) ?? undefined; 1128 + } 1129 + 1130 + // Embed subject record and media view in the notification record for client access 1131 + // This allows the client to display the subject's text and media preview 1132 + if (subjectRecord) { 1133 + // Get the raw media from the record and convert to view with URLs 1134 + const rawMedia = subjectRecord.record.media; 1135 + let mediaView: unknown; 1136 + if (rawMedia) { 1137 + if (isSubjectReply) { 1138 + // Replies only support image media 1139 + if (isImageMedia(rawMedia)) { 1140 + mediaView = this.imageMedia( 1141 + subjectUri.hostname, 1142 + rawMedia as ImageMedia, 1143 + ); 1144 + } 1145 + } else { 1146 + // Posts support images or video 1147 + mediaView = this.media(notif.reasonSubject, rawMedia as Media); 1148 + } 1149 + } 1150 + 1151 + recordWithSubject = { 1152 + ...recordInfo.record, 1153 + subject: subjectRecord.record, 1154 + subjectMedia: mediaView, 1155 + } as typeof recordInfo.record; 1156 + } 1157 + } 1158 + 1114 1159 return { 1115 1160 uri: notif.uri, 1116 1161 cid: recordInfo.cid, 1117 1162 author, 1118 1163 reason: notif.reason as NotificationView["reason"], 1119 1164 reasonSubject: notif.reasonSubject || undefined, 1120 - record: recordInfo.record, 1165 + record: recordWithSubject, 1121 1166 // @NOTE works with a hack in listNotifications so that when there's no last-seen time, 1122 1167 // the user's first notification is marked unread, and all previous read. in this case, 1123 1168 // the last seen time will be equal to the first notification's indexed time. 1124 - isRead: lastSeenAt ? lastSeenAt > indexedAt : true, 1169 + isRead: lastSeenAt ? lastSeenAt >= indexedAt : true, 1125 1170 indexedAt: notif.sortAt, 1126 1171 labels: [...labels, ...selfLabels], 1127 1172 };