grain.social is a photo sharing platform built on atproto.

feat: enhance notification system with detailed views and reason subjects; update related schemas and functions

Changed files
+297 -16
__generated__
types
social
grain
lexicons
social
grain
src
+60 -2
__generated__/lexicons.ts
··· 2441 2441 type: 'ref', 2442 2442 ref: 'lex:social.grain.actor.defs#profileView', 2443 2443 }, 2444 + reasonSubject: { 2445 + type: 'string', 2446 + format: 'at-uri', 2447 + }, 2444 2448 reason: { 2445 2449 type: 'string', 2446 2450 description: ··· 2455 2459 'unknown', 2456 2460 ], 2457 2461 }, 2458 - reasonSubject: { 2462 + record: { 2463 + type: 'unknown', 2464 + }, 2465 + isRead: { 2466 + type: 'boolean', 2467 + }, 2468 + indexedAt: { 2469 + type: 'string', 2470 + format: 'datetime', 2471 + }, 2472 + }, 2473 + }, 2474 + notificationViewDetailed: { 2475 + type: 'object', 2476 + required: [ 2477 + 'uri', 2478 + 'cid', 2479 + 'author', 2480 + 'reason', 2481 + 'record', 2482 + 'isRead', 2483 + 'indexedAt', 2484 + ], 2485 + properties: { 2486 + uri: { 2459 2487 type: 'string', 2460 2488 format: 'at-uri', 2461 2489 }, 2490 + cid: { 2491 + type: 'string', 2492 + format: 'cid', 2493 + }, 2494 + author: { 2495 + type: 'ref', 2496 + ref: 'lex:social.grain.actor.defs#profileView', 2497 + }, 2498 + reason: { 2499 + type: 'string', 2500 + description: 2501 + 'The reason why this notification was delivered - e.g. your gallery was favd, or you received a new follower.', 2502 + knownValues: [ 2503 + 'follow', 2504 + 'gallery-favorite', 2505 + 'gallery-comment', 2506 + 'reply', 2507 + 'gallery-mention', 2508 + 'gallery-comment-mention', 2509 + 'unknown', 2510 + ], 2511 + }, 2512 + reasonSubject: { 2513 + type: 'union', 2514 + refs: [ 2515 + 'lex:social.grain.actor.defs#profileView', 2516 + 'lex:social.grain.comment.defs#commentView', 2517 + 'lex:social.grain.gallery.defs#galleryView', 2518 + ], 2519 + }, 2462 2520 record: { 2463 2521 type: 'unknown', 2464 2522 }, ··· 2508 2566 type: 'array', 2509 2567 items: { 2510 2568 type: 'ref', 2511 - ref: 'lex:social.grain.notification.defs#notificationView', 2569 + ref: 'lex:social.grain.notification.defs#notificationViewDetailed', 2512 2570 }, 2513 2571 }, 2514 2572 seenAt: {
+42 -1
__generated__/types/social/grain/notification/defs.ts
··· 10 10 type OmitKey, 11 11 } from '../../../../util.ts' 12 12 import type * as SocialGrainActorDefs from '../actor/defs.ts' 13 + import type * as SocialGrainCommentDefs from '../comment/defs.ts' 14 + import type * as SocialGrainGalleryDefs from '../gallery/defs.ts' 13 15 14 16 const is$typed = _is$typed, 15 17 validate = _validate ··· 20 22 uri: string 21 23 cid: string 22 24 author: SocialGrainActorDefs.ProfileView 25 + reasonSubject?: string 23 26 /** The reason why this notification was delivered - e.g. your gallery was favd, or you received a new follower. */ 24 27 reason: 25 28 | 'follow' ··· 30 33 | 'gallery-comment-mention' 31 34 | 'unknown' 32 35 | (string & {}) 33 - reasonSubject?: string 34 36 record: { [_ in string]: unknown } 35 37 isRead: boolean 36 38 indexedAt: string ··· 45 47 export function validateNotificationView<V>(v: V) { 46 48 return validate<NotificationView & V>(v, id, hashNotificationView) 47 49 } 50 + 51 + export interface NotificationViewDetailed { 52 + $type?: 'social.grain.notification.defs#notificationViewDetailed' 53 + uri: string 54 + cid: string 55 + author: SocialGrainActorDefs.ProfileView 56 + /** The reason why this notification was delivered - e.g. your gallery was favd, or you received a new follower. */ 57 + reason: 58 + | 'follow' 59 + | 'gallery-favorite' 60 + | 'gallery-comment' 61 + | 'reply' 62 + | 'gallery-mention' 63 + | 'gallery-comment-mention' 64 + | 'unknown' 65 + | (string & {}) 66 + reasonSubject?: 67 + | $Typed<SocialGrainActorDefs.ProfileView> 68 + | $Typed<SocialGrainCommentDefs.CommentView> 69 + | $Typed<SocialGrainGalleryDefs.GalleryView> 70 + | { $type: string } 71 + record: { [_ in string]: unknown } 72 + isRead: boolean 73 + indexedAt: string 74 + } 75 + 76 + const hashNotificationViewDetailed = 'notificationViewDetailed' 77 + 78 + export function isNotificationViewDetailed<V>(v: V) { 79 + return is$typed(v, id, hashNotificationViewDetailed) 80 + } 81 + 82 + export function validateNotificationViewDetailed<V>(v: V) { 83 + return validate<NotificationViewDetailed & V>( 84 + v, 85 + id, 86 + hashNotificationViewDetailed, 87 + ) 88 + }
+1 -1
__generated__/types/social/grain/notification/getNotifications.ts
··· 20 20 21 21 export interface OutputSchema { 22 22 cursor?: string; 23 - notifications: SocialGrainNotificationDefs.NotificationView[]; 23 + notifications: SocialGrainNotificationDefs.NotificationViewDetailed[]; 24 24 seenAt?: string; 25 25 } 26 26
+45 -1
lexicons/social/grain/notification/defs.json
··· 20 20 "type": "ref", 21 21 "ref": "social.grain.actor.defs#profileView" 22 22 }, 23 + "reasonSubject": { "type": "string", "format": "at-uri" }, 23 24 "reason": { 24 25 "type": "string", 25 26 "description": "The reason why this notification was delivered - e.g. your gallery was favd, or you received a new follower.", ··· 33 34 "unknown" 34 35 ] 35 36 }, 36 - "reasonSubject": { "type": "string", "format": "at-uri" }, 37 + "record": { "type": "unknown" }, 38 + "isRead": { "type": "boolean" }, 39 + "indexedAt": { "type": "string", "format": "datetime" } 40 + } 41 + }, 42 + "notificationViewDetailed": { 43 + "type": "object", 44 + "required": [ 45 + "uri", 46 + "cid", 47 + "author", 48 + "reason", 49 + "record", 50 + "isRead", 51 + "indexedAt" 52 + ], 53 + "properties": { 54 + "uri": { "type": "string", "format": "at-uri" }, 55 + "cid": { "type": "string", "format": "cid" }, 56 + "author": { 57 + "type": "ref", 58 + "ref": "social.grain.actor.defs#profileView" 59 + }, 60 + "reason": { 61 + "type": "string", 62 + "description": "The reason why this notification was delivered - e.g. your gallery was favd, or you received a new follower.", 63 + "knownValues": [ 64 + "follow", 65 + "gallery-favorite", 66 + "gallery-comment", 67 + "reply", 68 + "gallery-mention", 69 + "gallery-comment-mention", 70 + "unknown" 71 + ] 72 + }, 73 + "reasonSubject": { 74 + "type": "union", 75 + "refs": [ 76 + "social.grain.actor.defs#profileView", 77 + "social.grain.comment.defs#commentView", 78 + "social.grain.gallery.defs#galleryView" 79 + ] 80 + }, 37 81 "record": { "type": "unknown" }, 38 82 "isRead": { "type": "boolean" }, 39 83 "indexedAt": { "type": "string", "format": "datetime" }
+1 -1
lexicons/social/grain/notification/getNotifications.json
··· 28 28 "type": "array", 29 29 "items": { 30 30 "type": "ref", 31 - "ref": "social.grain.notification.defs#notificationView" 31 + "ref": "social.grain.notification.defs#notificationViewDetailed" 32 32 } 33 33 }, 34 34 "seenAt": { "type": "string", "format": "datetime" }
+2 -2
src/api/mod.ts
··· 54 54 getFollowingWithProfiles, 55 55 } from "../lib/follow.ts"; 56 56 import { getGalleriesByHashtag, getGallery } from "../lib/gallery.ts"; 57 - import { getNotifications } from "../lib/notifications.ts"; 57 + import { getNotificationsDetailed } from "../lib/notifications.ts"; 58 58 import { getTimeline } from "../lib/timeline.ts"; 59 59 import { getGalleryComments } from "../modules/comments.tsx"; 60 60 ··· 141 141 (_req, _params, ctx) => { 142 142 // @TODO: this redirects, we should have a json response 143 143 ctx.requireAuth(); 144 - const notifications = getNotifications( 144 + const notifications = getNotificationsDetailed( 145 145 ctx, 146 146 ); 147 147 return ctx.json(
+5 -3
src/lib/actor.ts
··· 12 12 import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; 13 13 import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 14 14 import { Record as PhotoExif } from "$lexicon/types/social/grain/photo/exif.ts"; 15 - import { Un$Typed } from "$lexicon/util.ts"; 15 + import { $Typed } from "$lexicon/util.ts"; 16 16 import { BffContext, WithBffMeta } from "@bigmoves/bff"; 17 17 import { getFollow, getFollowersCount, getFollowsCount } from "./follow.ts"; 18 18 import { ··· 75 75 export function profileToView( 76 76 record: WithBffMeta<GrainProfile>, 77 77 handle: string, 78 - ): Un$Typed<ProfileView> { 78 + ): $Typed<ProfileView> { 79 79 return { 80 + $type: "social.grain.actor.defs#profileView", 80 81 cid: record.cid, 81 82 did: record.did, 82 83 handle, ··· 96 97 galleryCount: number; 97 98 viewer: ViewerState; 98 99 cameras?: string[]; 99 - }): Un$Typed<ProfileViewDetailed> { 100 + }): $Typed<ProfileViewDetailed> { 100 101 const { 101 102 record, 102 103 handle, ··· 107 108 cameras, 108 109 } = params; 109 110 return { 111 + $type: "social.grain.actor.defs#profileViewDetailed", 110 112 cid: record.cid, 111 113 did: record.did, 112 114 handle,
+133 -2
src/lib/notifications.ts
··· 7 7 isRecord as isComment, 8 8 Record as Comment, 9 9 } from "$lexicon/types/social/grain/comment.ts"; 10 + import { CommentView } from "$lexicon/types/social/grain/comment/defs.ts"; 10 11 import { 11 12 isRecord as isFavorite, 12 13 Record as Favorite, ··· 15 16 isRecord as isGallery, 16 17 Record as Gallery, 17 18 } from "$lexicon/types/social/grain/gallery.ts"; 19 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 18 20 import { 19 21 isRecord as isFollow, 20 22 Record as Follow, 21 23 } from "$lexicon/types/social/grain/graph/follow.ts"; 22 - import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts"; 23 - import { Un$Typed } from "$lexicon/util.ts"; 24 + import { 25 + NotificationView, 26 + NotificationViewDetailed, 27 + } from "$lexicon/types/social/grain/notification/defs.ts"; 28 + import { $Typed, Un$Typed } from "$lexicon/util.ts"; 29 + import { AtUri } from "@atproto/syntax"; 24 30 import { ActorTable, BffContext, WithBffMeta } from "@bigmoves/bff"; 31 + import { getComment } from "../modules/comments.tsx"; 25 32 import { getActorProfile } from "./actor.ts"; 33 + import { getGallery } from "./gallery.ts"; 26 34 27 35 export type NotificationRecords = WithBffMeta< 28 36 Favorite | Follow | Comment | Gallery ··· 136 144 } 137 145 return false; 138 146 } 147 + 148 + export function getNotificationsDetailed( 149 + ctx: BffContext, 150 + ): Un$Typed<NotificationViewDetailed>[] { 151 + const notifications = ctx.getNotifications<NotificationRecords>(); 152 + return notifications 153 + .filter( 154 + (notification) => 155 + notification.$type === "social.grain.favorite" || 156 + notification.$type === "social.grain.graph.follow" || 157 + notification.$type === "social.grain.comment" || 158 + notification.$type === "social.grain.gallery", 159 + ) 160 + .map((notification) => { 161 + const actor = ctx.indexService.getActor(notification.did); 162 + const authorProfile = getActorProfile(notification.did, ctx); 163 + if (!actor || !authorProfile) return null; 164 + 165 + let reasonSubject: 166 + | $Typed<GalleryView | CommentView | ProfileView> 167 + | undefined = undefined; 168 + if (notification.$type === "social.grain.favorite") { 169 + // Favorite: reasonSubject is the gallery view 170 + const galleryUri = notification.subject; 171 + try { 172 + const atUri = new AtUri(galleryUri); 173 + const did = atUri.hostname; 174 + const rkey = atUri.rkey; 175 + reasonSubject = getGallery(did, rkey, ctx) ?? undefined; 176 + } catch { 177 + reasonSubject = undefined; 178 + } 179 + } else if (notification.$type === "social.grain.graph.follow") { 180 + reasonSubject = getActorProfile( 181 + notification.subject, 182 + ctx, 183 + ) ?? undefined; 184 + } else if (notification.$type === "social.grain.comment") { 185 + // Hydrate comment with author, subject, and focus 186 + reasonSubject = getComment(notification.uri, ctx) ?? undefined; 187 + } else if (notification.$type === "social.grain.gallery") { 188 + try { 189 + const atUri = new AtUri(notification.uri); 190 + const did = atUri.hostname; 191 + const rkey = atUri.rkey; 192 + reasonSubject = getGallery(did, rkey, ctx) ?? undefined; 193 + } catch { 194 + reasonSubject = undefined; 195 + } 196 + } 197 + 198 + return notificationDetailedToView( 199 + notification, 200 + authorProfile, 201 + ctx.currentUser, 202 + reasonSubject, 203 + ); 204 + }) 205 + .filter((view): view is Un$Typed<NotificationViewDetailed> => 206 + Boolean(view) 207 + ); 208 + } 209 + 210 + export function notificationDetailedToView( 211 + record: NotificationRecords, 212 + author: $Typed<ProfileView>, 213 + currentUser?: ActorTable, 214 + reasonSubject?: $Typed<GalleryView | CommentView | ProfileView>, 215 + ): Un$Typed<NotificationViewDetailed> { 216 + let reason: string; 217 + if (isFavorite(record)) { 218 + reason = "gallery-favorite"; 219 + } else if (isFollow(record)) { 220 + reason = "follow"; 221 + } else if ( 222 + isComment(record) && 223 + record.replyTo 224 + ) { 225 + if ( 226 + recordHasMentionFacet( 227 + record, 228 + currentUser?.did, 229 + ) 230 + ) { 231 + reason = "gallery-comment-mention"; 232 + } else { 233 + reason = "reply"; 234 + } 235 + } else if (isComment(record)) { 236 + if ( 237 + recordHasMentionFacet( 238 + record, 239 + currentUser?.did, 240 + ) 241 + ) { 242 + reason = "gallery-comment-mention"; 243 + } else { 244 + reason = "gallery-comment"; 245 + } 246 + } else if ( 247 + isGallery(record) && recordHasMentionFacet( 248 + record, 249 + currentUser?.did, 250 + ) 251 + ) { 252 + reason = "gallery-mention"; 253 + } else { 254 + reason = "unknown"; 255 + } 256 + const isRead = currentUser?.lastSeenNotifs 257 + ? record.createdAt <= currentUser.lastSeenNotifs 258 + : false; 259 + return { 260 + uri: record.uri, 261 + cid: record.cid, 262 + author, 263 + reason, 264 + reasonSubject, 265 + record, 266 + isRead, 267 + indexedAt: record.indexedAt, 268 + }; 269 + }
+8 -3
src/modules/comments.tsx
··· 9 9 isPhotoView, 10 10 PhotoView, 11 11 } from "$lexicon/types/social/grain/photo/defs.ts"; 12 + import { $Typed } from "$lexicon/util.ts"; 12 13 import { Facet } from "@atproto/api"; 13 14 import { AtUri } from "@atproto/syntax"; 14 15 import { BffContext, BffMiddleware, route, WithBffMeta } from "@bigmoves/bff"; ··· 589 590 ); 590 591 } 591 592 592 - function getComment(uri: string, ctx: BffContext) { 593 + export function getComment( 594 + uri: string, 595 + ctx: BffContext, 596 + ) { 593 597 const { items: comments } = ctx.indexService.getRecords<WithBffMeta<Comment>>( 594 598 "social.grain.comment", 595 599 { ··· 611 615 return commentToView(comment, author, subject, focus); 612 616 } 613 617 614 - function commentToView( 618 + export function commentToView( 615 619 record: WithBffMeta<Comment>, 616 620 author: ProfileView, 617 621 subject?: GalleryView, 618 622 focus?: PhotoView, 619 - ): CommentView { 623 + ): $Typed<CommentView> { 620 624 return { 625 + $type: "social.grain.comment.defs#commentView", 621 626 uri: record.uri, 622 627 cid: record.cid, 623 628 text: record.text,