diff --git a/api/so/sprk/actor/getProfile.ts b/api/so/sprk/actor/getProfile.ts index 349be28..41d0b3f 100644 --- a/api/so/sprk/actor/getProfile.ts +++ b/api/so/sprk/actor/getProfile.ts @@ -10,41 +10,22 @@ import { QueryParams } from "../../../../lex/types/so/sprk/actor/getProfile.ts"; import { createPipeline, noRules } from "../../../../pipeline.ts"; import { Views } from "../../../../views/index.ts"; import { resHeaders } from "../../../util.ts"; -import { getLogger } from "@logtape/logtape"; - -const logger = getLogger(["appview", "getProfile"]); export default function (server: Server, ctx: AppContext) { const getProfile = createPipeline(skeleton, hydration, noRules, presentation); server.so.sprk.actor.getProfile({ auth: ctx.authVerifier.optionalStandardOrRole, handler: async ({ auth, params, req }) => { - const start = performance.now(); const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth); const labelers = ctx.reqLabelers(req); - - const t1 = performance.now(); const hydrateCtx = await ctx.hydrator.createContext({ labelers, viewer, includeTakedowns, }); - const t2 = performance.now(); - const result = await getProfile({ ...params, hydrateCtx }, ctx); - const t3 = performance.now(); - const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer); - const t4 = performance.now(); - - logger.info("getProfile timing", { - viewer: !!viewer, - createContext: Math.round(t2 - t1), - pipeline: Math.round(t3 - t2), - repoRev: Math.round(t4 - t3), - total: Math.round(t4 - start), - }); return { encoding: "application/json", diff --git a/api/so/sprk/notification/getUnreadCount.ts b/api/so/sprk/notification/getUnreadCount.ts index 14bb1c0..57213ab 100644 --- a/api/so/sprk/notification/getUnreadCount.ts +++ b/api/so/sprk/notification/getUnreadCount.ts @@ -19,7 +19,7 @@ export default function (server: Server, ctx: AppContext) { noRules, presentation, ); - server.app.bsky.notification.getUnreadCount({ + server.so.sprk.notification.getUnreadCount({ auth: ctx.authVerifier.standard, handler: async ({ auth, params }) => { const viewer = auth.credentials.iss; @@ -40,10 +40,15 @@ const skeleton = async ( throw new InvalidRequestError("The seenAt parameter is unsupported"); } const priority = params.priority ?? false; + + // Get the stored lastSeenNotifs timestamp + const lastSeenRes = await ctx.hydrator.dataplane.notifications + .getNotificationSeen(params.viewer, priority); + const res = await ctx.hydrator.dataplane.notifications .getUnreadNotificationCount( params.viewer, - undefined, + lastSeenRes.timestamp, priority, ); return { diff --git a/api/so/sprk/notification/listNotifications.ts b/api/so/sprk/notification/listNotifications.ts index d472a1d..9ccfd0e 100644 --- a/api/so/sprk/notification/listNotifications.ts +++ b/api/so/sprk/notification/listNotifications.ts @@ -13,7 +13,6 @@ import { RulesFnInput, SkeletonFnInput, } from "../../../../pipeline.ts"; -import { uriToDid as didFromUri } from "../../../../utils/uris.ts"; import { Views } from "../../../../views/index.ts"; import { resHeaders } from "../../../util.ts"; @@ -101,13 +100,13 @@ const paginateNotifications = async (opts: { */ export const delayCursor = ( cursorStr: string | undefined, - delayMs: number, -): string => { - const nowMinusDelay = Date.now() - delayMs; - if (cursorStr === undefined) return new Date(nowMinusDelay).toISOString(); - const cursor = new Date(cursorStr).getTime(); - if (isNaN(cursor)) return cursorStr; - return new Date(Math.min(cursor, nowMinusDelay)).toISOString(); + _delayMs: number, +): string | undefined => { + // The cursor is a packed keyset cursor (base36:cid), not an ISO timestamp. + // We can't apply time-based delays to it without unpacking/repacking. + // For now, just pass through the cursor as-is. + // If no cursor, return undefined to fetch from the beginning. + return cursorStr; }; const skeleton = async ( @@ -143,7 +142,13 @@ const skeleton = async ( // rather than all notifications. bit of a hack to be more graceful when seen times are out of sync. let lastSeenAt = lastSeenRes.timestamp; if (!lastSeenAt && !originalCursor) { - lastSeenAt = res.notifications.at(0)?.sortAt; + // Set to 1ms before the first notification so it shows as unread (since we use >= comparison) + const firstSortAt = res.notifications.at(0)?.sortAt; + if (firstSortAt) { + const firstTime = new Date(firstSortAt); + firstTime.setMilliseconds(firstTime.getMilliseconds() - 1); + lastSeenAt = firstTime.toISOString(); + } } return { notifs: res.notifications, @@ -168,7 +173,9 @@ const noBlockOrMutesOrNeedsFiltering = ( ) => { const { skeleton, hydration, ctx } = input; skeleton.notifs = skeleton.notifs.filter((item) => { - const did = didFromUri(item.uri); + // Use authorDid directly (the person who created the notification action) + // For likes, this is the liker; for replies, this is the replier, etc. + const did = item.authorDid; if ( ctx.views.viewerBlockExists(did, hydration) || ctx.views.viewerMuteExists(did, hydration) @@ -178,12 +185,15 @@ const noBlockOrMutesOrNeedsFiltering = ( // Filter out notifications from users that need review unless moots if ( item.reason === "reply" || - item.reason === "quote" || item.reason === "mention" || item.reason === "like" || item.reason === "follow" ) { - if (!ctx.views.viewerSeesNeedsReview({ did, uri: item.uri }, hydration)) { + const seesNeedsReview = ctx.views.viewerSeesNeedsReview( + { did, uri: item.uri }, + hydration, + ); + if (!seesNeedsReview) { return false; } } diff --git a/data-plane/db/models.ts b/data-plane/db/models.ts index c4349a6..1f783f8 100644 --- a/data-plane/db/models.ts +++ b/data-plane/db/models.ts @@ -497,6 +497,7 @@ export interface ActorDocument extends Document { upstreamStatus: string | null; keys: string[]; services: string; + lastSeenNotifs: string | null; } export const actorSchema = new Schema({ did: { type: String, required: true, unique: true, index: true }, @@ -506,6 +507,7 @@ export const actorSchema = new Schema({ upstreamStatus: { type: String, required: false }, keys: { type: [String], required: true }, services: { type: String, required: true }, + lastSeenNotifs: { type: String, required: false, default: null }, }); // preferences diff --git a/data-plane/routes/notifs.ts b/data-plane/routes/notifs.ts index 2ec3503..e7eecd8 100644 --- a/data-plane/routes/notifs.ts +++ b/data-plane/routes/notifs.ts @@ -142,14 +142,11 @@ export class Notifications { _priority?: boolean, ): Promise<{ timestamp?: string }> { const actor = await this.db.models.Actor.findOne({ did: actorDid }); - if (!actor) { + if (!actor || !actor.lastSeenNotifs) { return {}; } - // For now, we don't have lastSeenNotifs on Actor model - // This would need to be added to track notification seen status - // Returning empty for now - return {}; + return { timestamp: actor.lastSeenNotifs }; } async getUnreadNotificationCount( @@ -182,13 +179,15 @@ export class Notifications { } async updateNotificationSeen( - _actorDid: string, - _timestamp: string, + actorDid: string, + timestamp: string, _priority?: boolean, ): Promise { - // This would require adding notification seen tracking to the Actor model - // or creating a separate ActorState model - // For now, this is a no-op + await this.db.models.Actor.findOneAndUpdate( + { did: actorDid }, + { $set: { lastSeenNotifs: timestamp } }, + { upsert: false }, + ); } // Helper methods @@ -226,9 +225,10 @@ export class Notifications { } const subjectUris = notifsWithSubject.map((n) => n.reasonSubject as string); + const existingRecords = await this.db.models.Record.find({ uri: { $in: subjectUris }, - }).select("uri"); + }).select("uri").lean(); const existingUris = new Set(existingRecords.map((r) => r.uri)); diff --git a/data-plane/routes/records.ts b/data-plane/routes/records.ts index abd5f2b..3acfa4b 100644 --- a/data-plane/routes/records.ts +++ b/data-plane/routes/records.ts @@ -146,7 +146,7 @@ export class Records { } async getRepostRecords(uris: string[]) { - const result = await getRecords(this.db, uris, ids.AppBskyFeedRepost); + const result = await getRecords(this.db, uris, ids.SoSprkFeedRepost); return result; } diff --git a/data-plane/util.ts b/data-plane/util.ts index b7d8d83..9e02d33 100644 --- a/data-plane/util.ts +++ b/data-plane/util.ts @@ -97,17 +97,31 @@ export const getAncestorsAndSelf = async ( let height = 1; while (currentUri && height <= parentHeight) { - const parentReply = await db.models.Reply.findOne({ uri: currentUri }) - .lean(); - if (!parentReply) break; - - ancestors.push({ - uri: parentReply.uri, - height, - }); - - currentUri = parentReply.reply?.parent?.uri; - height++; + // Check if parent is a Post (root) or Reply + const [parentPost, parentReply] = await Promise.all([ + db.models.Post.findOne({ uri: currentUri }).lean(), + db.models.Reply.findOne({ uri: currentUri }).lean(), + ]); + + if (parentPost) { + // Found root post - add it and stop traversing + ancestors.push({ + uri: parentPost.uri, + height, + }); + break; + } else if (parentReply) { + // Found a reply - add it and continue traversing + ancestors.push({ + uri: parentReply.uri, + height, + }); + currentUri = parentReply.reply?.parent?.uri; + height++; + } else { + // Parent not found - stop traversing + break; + } } return ancestors; diff --git a/hydration/index.ts b/hydration/index.ts index aa38260..bbc91b5 100644 --- a/hydration/index.ts +++ b/hydration/index.ts @@ -668,6 +668,24 @@ export class Hydrator { const likeUris = collections.get(ids.SoSprkFeedLike) ?? []; const repostUris = collections.get(ids.SoSprkFeedRepost) ?? []; const followUris = collections.get(ids.SoSprkGraphFollow) ?? []; + + // Collect subject URIs for like/repost notifications to hydrate their content + const subjectPostUris: string[] = []; + const subjectReplyUris: string[] = []; + for (const notif of notifs) { + if ( + notif.reasonSubject && + (notif.reason === "like" || notif.reason === "repost") + ) { + const subjectUri = new AtUri(notif.reasonSubject); + if (subjectUri.collection === ids.SoSprkFeedPost) { + subjectPostUris.push(notif.reasonSubject); + } else if (subjectUri.collection === ids.SoSprkFeedReply) { + subjectReplyUris.push(notif.reasonSubject); + } + } + } + const [ posts, replies, @@ -676,6 +694,8 @@ export class Hydrator { follows, labels, profileState, + subjectPosts, + subjectReplies, ] = await Promise.all([ this.feed.getPosts(postUris), // reason: mention, quote this.feed.getReplies(replyUris), // reason: reply @@ -684,6 +704,8 @@ export class Hydrator { this.graph.getFollows(followUris), // reason: follow this.label.getLabelsForSubjects(uris, ctx.labelers), this.hydrateProfiles(uris.map(didFromUri), ctx), + this.feed.getPosts(subjectPostUris), // subjects of likes/reposts + this.feed.getReplies(subjectReplyUris), // subjects of likes/reposts ]); const viewerRootPostUris = new Set(); for (const notif of notifs) { @@ -700,9 +722,11 @@ export class Hydrator { } actionTakedownLabels(postUris, posts, labels); actionTakedownLabels(replyUris, replies, labels); + actionTakedownLabels(subjectPostUris, subjectPosts, labels); + actionTakedownLabels(subjectReplyUris, subjectReplies, labels); return mergeStates(profileState, { - posts, - replies, + posts: mergeMaps(posts, subjectPosts), + replies: mergeMaps(replies, subjectReplies), likes, reposts, follows, diff --git a/views/index.ts b/views/index.ts index 00066eb..9928b6e 100644 --- a/views/index.ts +++ b/views/index.ts @@ -1111,17 +1111,62 @@ export class Views { }) : []; const indexedAt = notif.sortAt; + + // For like/repost notifications, include the subject record (post/reply) in the response + let recordWithSubject = recordInfo.record; + if ( + (notif.reason === "like" || notif.reason === "repost") && + notif.reasonSubject + ) { + const subjectUri = new AtUri(notif.reasonSubject); + let subjectRecord: Post | Reply | undefined; + const isSubjectReply = subjectUri.collection === ids.SoSprkFeedReply; + if (subjectUri.collection === ids.SoSprkFeedPost) { + subjectRecord = state.posts?.get(notif.reasonSubject) ?? undefined; + } else if (isSubjectReply) { + subjectRecord = state.replies?.get(notif.reasonSubject) ?? undefined; + } + + // Embed subject record and media view in the notification record for client access + // This allows the client to display the subject's text and media preview + if (subjectRecord) { + // Get the raw media from the record and convert to view with URLs + const rawMedia = subjectRecord.record.media; + let mediaView: unknown; + if (rawMedia) { + if (isSubjectReply) { + // Replies only support image media + if (isImageMedia(rawMedia)) { + mediaView = this.imageMedia( + subjectUri.hostname, + rawMedia as ImageMedia, + ); + } + } else { + // Posts support images or video + mediaView = this.media(notif.reasonSubject, rawMedia as Media); + } + } + + recordWithSubject = { + ...recordInfo.record, + subject: subjectRecord.record, + subjectMedia: mediaView, + } as typeof recordInfo.record; + } + } + return { uri: notif.uri, cid: recordInfo.cid, author, reason: notif.reason as NotificationView["reason"], reasonSubject: notif.reasonSubject || undefined, - record: recordInfo.record, + record: recordWithSubject, // @NOTE works with a hack in listNotifications so that when there's no last-seen time, // the user's first notification is marked unread, and all previous read. in this case, // the last seen time will be equal to the first notification's indexed time. - isRead: lastSeenAt ? lastSeenAt > indexedAt : true, + isRead: lastSeenAt ? lastSeenAt >= indexedAt : true, indexedAt: notif.sortAt, labels: [...labels, ...selfLabels], };