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 type: 'ref', 2442 ref: 'lex:social.grain.actor.defs#profileView', 2443 }, 2444 reason: { 2445 type: 'string', 2446 description: ··· 2455 'unknown', 2456 ], 2457 }, 2458 - reasonSubject: { 2459 type: 'string', 2460 format: 'at-uri', 2461 }, 2462 record: { 2463 type: 'unknown', 2464 }, ··· 2508 type: 'array', 2509 items: { 2510 type: 'ref', 2511 - ref: 'lex:social.grain.notification.defs#notificationView', 2512 }, 2513 }, 2514 seenAt: {
··· 2441 type: 'ref', 2442 ref: 'lex:social.grain.actor.defs#profileView', 2443 }, 2444 + reasonSubject: { 2445 + type: 'string', 2446 + format: 'at-uri', 2447 + }, 2448 reason: { 2449 type: 'string', 2450 description: ··· 2459 'unknown', 2460 ], 2461 }, 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: { 2487 type: 'string', 2488 format: 'at-uri', 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 + }, 2520 record: { 2521 type: 'unknown', 2522 }, ··· 2566 type: 'array', 2567 items: { 2568 type: 'ref', 2569 + ref: 'lex:social.grain.notification.defs#notificationViewDetailed', 2570 }, 2571 }, 2572 seenAt: {
+42 -1
__generated__/types/social/grain/notification/defs.ts
··· 10 type OmitKey, 11 } from '../../../../util.ts' 12 import type * as SocialGrainActorDefs from '../actor/defs.ts' 13 14 const is$typed = _is$typed, 15 validate = _validate ··· 20 uri: string 21 cid: string 22 author: SocialGrainActorDefs.ProfileView 23 /** The reason why this notification was delivered - e.g. your gallery was favd, or you received a new follower. */ 24 reason: 25 | 'follow' ··· 30 | 'gallery-comment-mention' 31 | 'unknown' 32 | (string & {}) 33 - reasonSubject?: string 34 record: { [_ in string]: unknown } 35 isRead: boolean 36 indexedAt: string ··· 45 export function validateNotificationView<V>(v: V) { 46 return validate<NotificationView & V>(v, id, hashNotificationView) 47 }
··· 10 type OmitKey, 11 } from '../../../../util.ts' 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' 15 16 const is$typed = _is$typed, 17 validate = _validate ··· 22 uri: string 23 cid: string 24 author: SocialGrainActorDefs.ProfileView 25 + reasonSubject?: string 26 /** The reason why this notification was delivered - e.g. your gallery was favd, or you received a new follower. */ 27 reason: 28 | 'follow' ··· 33 | 'gallery-comment-mention' 34 | 'unknown' 35 | (string & {}) 36 record: { [_ in string]: unknown } 37 isRead: boolean 38 indexedAt: string ··· 47 export function validateNotificationView<V>(v: V) { 48 return validate<NotificationView & V>(v, id, hashNotificationView) 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 21 export interface OutputSchema { 22 cursor?: string; 23 - notifications: SocialGrainNotificationDefs.NotificationView[]; 24 seenAt?: string; 25 } 26
··· 20 21 export interface OutputSchema { 22 cursor?: string; 23 + notifications: SocialGrainNotificationDefs.NotificationViewDetailed[]; 24 seenAt?: string; 25 } 26
+45 -1
lexicons/social/grain/notification/defs.json
··· 20 "type": "ref", 21 "ref": "social.grain.actor.defs#profileView" 22 }, 23 "reason": { 24 "type": "string", 25 "description": "The reason why this notification was delivered - e.g. your gallery was favd, or you received a new follower.", ··· 33 "unknown" 34 ] 35 }, 36 - "reasonSubject": { "type": "string", "format": "at-uri" }, 37 "record": { "type": "unknown" }, 38 "isRead": { "type": "boolean" }, 39 "indexedAt": { "type": "string", "format": "datetime" }
··· 20 "type": "ref", 21 "ref": "social.grain.actor.defs#profileView" 22 }, 23 + "reasonSubject": { "type": "string", "format": "at-uri" }, 24 "reason": { 25 "type": "string", 26 "description": "The reason why this notification was delivered - e.g. your gallery was favd, or you received a new follower.", ··· 34 "unknown" 35 ] 36 }, 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 + }, 81 "record": { "type": "unknown" }, 82 "isRead": { "type": "boolean" }, 83 "indexedAt": { "type": "string", "format": "datetime" }
+1 -1
lexicons/social/grain/notification/getNotifications.json
··· 28 "type": "array", 29 "items": { 30 "type": "ref", 31 - "ref": "social.grain.notification.defs#notificationView" 32 } 33 }, 34 "seenAt": { "type": "string", "format": "datetime" }
··· 28 "type": "array", 29 "items": { 30 "type": "ref", 31 + "ref": "social.grain.notification.defs#notificationViewDetailed" 32 } 33 }, 34 "seenAt": { "type": "string", "format": "datetime" }
+2 -2
src/api/mod.ts
··· 54 getFollowingWithProfiles, 55 } from "../lib/follow.ts"; 56 import { getGalleriesByHashtag, getGallery } from "../lib/gallery.ts"; 57 - import { getNotifications } from "../lib/notifications.ts"; 58 import { getTimeline } from "../lib/timeline.ts"; 59 import { getGalleryComments } from "../modules/comments.tsx"; 60 ··· 141 (_req, _params, ctx) => { 142 // @TODO: this redirects, we should have a json response 143 ctx.requireAuth(); 144 - const notifications = getNotifications( 145 ctx, 146 ); 147 return ctx.json(
··· 54 getFollowingWithProfiles, 55 } from "../lib/follow.ts"; 56 import { getGalleriesByHashtag, getGallery } from "../lib/gallery.ts"; 57 + import { getNotificationsDetailed } from "../lib/notifications.ts"; 58 import { getTimeline } from "../lib/timeline.ts"; 59 import { getGalleryComments } from "../modules/comments.tsx"; 60 ··· 141 (_req, _params, ctx) => { 142 // @TODO: this redirects, we should have a json response 143 ctx.requireAuth(); 144 + const notifications = getNotificationsDetailed( 145 ctx, 146 ); 147 return ctx.json(
+5 -3
src/lib/actor.ts
··· 12 import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; 13 import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 14 import { Record as PhotoExif } from "$lexicon/types/social/grain/photo/exif.ts"; 15 - import { Un$Typed } from "$lexicon/util.ts"; 16 import { BffContext, WithBffMeta } from "@bigmoves/bff"; 17 import { getFollow, getFollowersCount, getFollowsCount } from "./follow.ts"; 18 import { ··· 75 export function profileToView( 76 record: WithBffMeta<GrainProfile>, 77 handle: string, 78 - ): Un$Typed<ProfileView> { 79 return { 80 cid: record.cid, 81 did: record.did, 82 handle, ··· 96 galleryCount: number; 97 viewer: ViewerState; 98 cameras?: string[]; 99 - }): Un$Typed<ProfileViewDetailed> { 100 const { 101 record, 102 handle, ··· 107 cameras, 108 } = params; 109 return { 110 cid: record.cid, 111 did: record.did, 112 handle,
··· 12 import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; 13 import { isPhotoView } from "$lexicon/types/social/grain/photo/defs.ts"; 14 import { Record as PhotoExif } from "$lexicon/types/social/grain/photo/exif.ts"; 15 + import { $Typed } from "$lexicon/util.ts"; 16 import { BffContext, WithBffMeta } from "@bigmoves/bff"; 17 import { getFollow, getFollowersCount, getFollowsCount } from "./follow.ts"; 18 import { ··· 75 export function profileToView( 76 record: WithBffMeta<GrainProfile>, 77 handle: string, 78 + ): $Typed<ProfileView> { 79 return { 80 + $type: "social.grain.actor.defs#profileView", 81 cid: record.cid, 82 did: record.did, 83 handle, ··· 97 galleryCount: number; 98 viewer: ViewerState; 99 cameras?: string[]; 100 + }): $Typed<ProfileViewDetailed> { 101 const { 102 record, 103 handle, ··· 108 cameras, 109 } = params; 110 return { 111 + $type: "social.grain.actor.defs#profileViewDetailed", 112 cid: record.cid, 113 did: record.did, 114 handle,
+133 -2
src/lib/notifications.ts
··· 7 isRecord as isComment, 8 Record as Comment, 9 } from "$lexicon/types/social/grain/comment.ts"; 10 import { 11 isRecord as isFavorite, 12 Record as Favorite, ··· 15 isRecord as isGallery, 16 Record as Gallery, 17 } from "$lexicon/types/social/grain/gallery.ts"; 18 import { 19 isRecord as isFollow, 20 Record as Follow, 21 } 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 { ActorTable, BffContext, WithBffMeta } from "@bigmoves/bff"; 25 import { getActorProfile } from "./actor.ts"; 26 27 export type NotificationRecords = WithBffMeta< 28 Favorite | Follow | Comment | Gallery ··· 136 } 137 return false; 138 }
··· 7 isRecord as isComment, 8 Record as Comment, 9 } from "$lexicon/types/social/grain/comment.ts"; 10 + import { CommentView } from "$lexicon/types/social/grain/comment/defs.ts"; 11 import { 12 isRecord as isFavorite, 13 Record as Favorite, ··· 16 isRecord as isGallery, 17 Record as Gallery, 18 } from "$lexicon/types/social/grain/gallery.ts"; 19 + import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 20 import { 21 isRecord as isFollow, 22 Record as Follow, 23 } from "$lexicon/types/social/grain/graph/follow.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"; 30 import { ActorTable, BffContext, WithBffMeta } from "@bigmoves/bff"; 31 + import { getComment } from "../modules/comments.tsx"; 32 import { getActorProfile } from "./actor.ts"; 33 + import { getGallery } from "./gallery.ts"; 34 35 export type NotificationRecords = WithBffMeta< 36 Favorite | Follow | Comment | Gallery ··· 144 } 145 return false; 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 isPhotoView, 10 PhotoView, 11 } from "$lexicon/types/social/grain/photo/defs.ts"; 12 import { Facet } from "@atproto/api"; 13 import { AtUri } from "@atproto/syntax"; 14 import { BffContext, BffMiddleware, route, WithBffMeta } from "@bigmoves/bff"; ··· 589 ); 590 } 591 592 - function getComment(uri: string, ctx: BffContext) { 593 const { items: comments } = ctx.indexService.getRecords<WithBffMeta<Comment>>( 594 "social.grain.comment", 595 { ··· 611 return commentToView(comment, author, subject, focus); 612 } 613 614 - function commentToView( 615 record: WithBffMeta<Comment>, 616 author: ProfileView, 617 subject?: GalleryView, 618 focus?: PhotoView, 619 - ): CommentView { 620 return { 621 uri: record.uri, 622 cid: record.cid, 623 text: record.text,
··· 9 isPhotoView, 10 PhotoView, 11 } from "$lexicon/types/social/grain/photo/defs.ts"; 12 + import { $Typed } from "$lexicon/util.ts"; 13 import { Facet } from "@atproto/api"; 14 import { AtUri } from "@atproto/syntax"; 15 import { BffContext, BffMiddleware, route, WithBffMeta } from "@bigmoves/bff"; ··· 590 ); 591 } 592 593 + export function getComment( 594 + uri: string, 595 + ctx: BffContext, 596 + ) { 597 const { items: comments } = ctx.indexService.getRecords<WithBffMeta<Comment>>( 598 "social.grain.comment", 599 { ··· 615 return commentToView(comment, author, subject, focus); 616 } 617 618 + export function commentToView( 619 record: WithBffMeta<Comment>, 620 author: ProfileView, 621 subject?: GalleryView, 622 focus?: PhotoView, 623 + ): $Typed<CommentView> { 624 return { 625 + $type: "social.grain.comment.defs#commentView", 626 uri: record.uri, 627 cid: record.cid, 628 text: record.text,