···1313 RulesFnInput,
1414 SkeletonFnInput,
1515} from "../../../../pipeline.ts";
1616-import { uriToDid as didFromUri } from "../../../../utils/uris.ts";
1716import { Views } from "../../../../views/index.ts";
1817import { resHeaders } from "../../../util.ts";
1918···101100 */
102101export const delayCursor = (
103102 cursorStr: string | undefined,
104104- delayMs: number,
105105-): string => {
106106- const nowMinusDelay = Date.now() - delayMs;
107107- if (cursorStr === undefined) return new Date(nowMinusDelay).toISOString();
108108- const cursor = new Date(cursorStr).getTime();
109109- if (isNaN(cursor)) return cursorStr;
110110- return new Date(Math.min(cursor, nowMinusDelay)).toISOString();
103103+ _delayMs: number,
104104+): string | undefined => {
105105+ // The cursor is a packed keyset cursor (base36:cid), not an ISO timestamp.
106106+ // We can't apply time-based delays to it without unpacking/repacking.
107107+ // For now, just pass through the cursor as-is.
108108+ // If no cursor, return undefined to fetch from the beginning.
109109+ return cursorStr;
111110};
112111113112const skeleton = async (
···143142 // rather than all notifications. bit of a hack to be more graceful when seen times are out of sync.
144143 let lastSeenAt = lastSeenRes.timestamp;
145144 if (!lastSeenAt && !originalCursor) {
146146- lastSeenAt = res.notifications.at(0)?.sortAt;
145145+ // Set to 1ms before the first notification so it shows as unread (since we use >= comparison)
146146+ const firstSortAt = res.notifications.at(0)?.sortAt;
147147+ if (firstSortAt) {
148148+ const firstTime = new Date(firstSortAt);
149149+ firstTime.setMilliseconds(firstTime.getMilliseconds() - 1);
150150+ lastSeenAt = firstTime.toISOString();
151151+ }
147152 }
148153 return {
149154 notifs: res.notifications,
···168173) => {
169174 const { skeleton, hydration, ctx } = input;
170175 skeleton.notifs = skeleton.notifs.filter((item) => {
171171- const did = didFromUri(item.uri);
176176+ // Use authorDid directly (the person who created the notification action)
177177+ // For likes, this is the liker; for replies, this is the replier, etc.
178178+ const did = item.authorDid;
172179 if (
173180 ctx.views.viewerBlockExists(did, hydration) ||
174181 ctx.views.viewerMuteExists(did, hydration)
···178185 // Filter out notifications from users that need review unless moots
179186 if (
180187 item.reason === "reply" ||
181181- item.reason === "quote" ||
182188 item.reason === "mention" ||
183189 item.reason === "like" ||
184190 item.reason === "follow"
185191 ) {
186186- if (!ctx.views.viewerSeesNeedsReview({ did, uri: item.uri }, hydration)) {
192192+ const seesNeedsReview = ctx.views.viewerSeesNeedsReview(
193193+ { did, uri: item.uri },
194194+ hydration,
195195+ );
196196+ if (!seesNeedsReview) {
187197 return false;
188198 }
189199 }
···11111111 })
11121112 : [];
11131113 const indexedAt = notif.sortAt;
11141114+11151115+ // For like/repost notifications, include the subject record (post/reply) in the response
11161116+ let recordWithSubject = recordInfo.record;
11171117+ if (
11181118+ (notif.reason === "like" || notif.reason === "repost") &&
11191119+ notif.reasonSubject
11201120+ ) {
11211121+ const subjectUri = new AtUri(notif.reasonSubject);
11221122+ let subjectRecord: Post | Reply | undefined;
11231123+ const isSubjectReply = subjectUri.collection === ids.SoSprkFeedReply;
11241124+ if (subjectUri.collection === ids.SoSprkFeedPost) {
11251125+ subjectRecord = state.posts?.get(notif.reasonSubject) ?? undefined;
11261126+ } else if (isSubjectReply) {
11271127+ subjectRecord = state.replies?.get(notif.reasonSubject) ?? undefined;
11281128+ }
11291129+11301130+ // Embed subject record and media view in the notification record for client access
11311131+ // This allows the client to display the subject's text and media preview
11321132+ if (subjectRecord) {
11331133+ // Get the raw media from the record and convert to view with URLs
11341134+ const rawMedia = subjectRecord.record.media;
11351135+ let mediaView: unknown;
11361136+ if (rawMedia) {
11371137+ if (isSubjectReply) {
11381138+ // Replies only support image media
11391139+ if (isImageMedia(rawMedia)) {
11401140+ mediaView = this.imageMedia(
11411141+ subjectUri.hostname,
11421142+ rawMedia as ImageMedia,
11431143+ );
11441144+ }
11451145+ } else {
11461146+ // Posts support images or video
11471147+ mediaView = this.media(notif.reasonSubject, rawMedia as Media);
11481148+ }
11491149+ }
11501150+11511151+ recordWithSubject = {
11521152+ ...recordInfo.record,
11531153+ subject: subjectRecord.record,
11541154+ subjectMedia: mediaView,
11551155+ } as typeof recordInfo.record;
11561156+ }
11571157+ }
11581158+11141159 return {
11151160 uri: notif.uri,
11161161 cid: recordInfo.cid,
11171162 author,
11181163 reason: notif.reason as NotificationView["reason"],
11191164 reasonSubject: notif.reasonSubject || undefined,
11201120- record: recordInfo.record,
11651165+ record: recordWithSubject,
11211166 // @NOTE works with a hack in listNotifications so that when there's no last-seen time,
11221167 // the user's first notification is marked unread, and all previous read. in this case,
11231168 // the last seen time will be equal to the first notification's indexed time.
11241124- isRead: lastSeenAt ? lastSeenAt > indexedAt : true,
11691169+ isRead: lastSeenAt ? lastSeenAt >= indexedAt : true,
11251170 indexedAt: notif.sortAt,
11261171 labels: [...labels, ...selfLabels],
11271172 };