grain.social is a photo sharing platform built on atproto.
at main 314 lines 11 kB view raw
1import { Record as Comment } from "$lexicon/types/social/grain/comment.ts"; 2import { CommentView } from "$lexicon/types/social/grain/comment/defs.ts"; 3import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 4import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 5import { GalleryView } from "$lexicon/types/social/grain/gallery/defs.ts"; 6import { Record as Follow } from "$lexicon/types/social/grain/graph/follow.ts"; 7import { NotificationView } from "$lexicon/types/social/grain/notification/defs.ts"; 8import { 9 isPhotoView, 10 PhotoView, 11} from "$lexicon/types/social/grain/photo/defs.ts"; 12import { Un$Typed } from "$lexicon/util.ts"; 13import { AtUri } from "@atproto/syntax"; 14import { WithBffMeta } from "@bigmoves/bff"; 15import { formatRelativeTime, galleryLink, profileLink } from "../utils.ts"; 16import { ActorAvatar } from "./ActorAvatar.tsx"; 17import { GalleryPreviewLink } from "./GalleryPreviewLink.tsx"; 18import { Header } from "./Header.tsx"; 19import { RenderFacetedText } from "./RenderFacetedText.tsx"; 20 21export function NotificationsPage( 22 { photosMap, galleriesMap, notifications, commentsMap }: Readonly< 23 { 24 photosMap: Map<string, Un$Typed<PhotoView>>; 25 galleriesMap: Map<string, Un$Typed<GalleryView>>; 26 commentsMap: Map<string, Un$Typed<CommentView>>; 27 notifications: Un$Typed<NotificationView>[]; 28 } 29 >, 30) { 31 return ( 32 <div class="px-4 mb-4"> 33 <div hx-post="/actions/update-seen" hx-trigger="load delay:1s" /> 34 <div class="my-4"> 35 <Header>Notifications</Header> 36 </div> 37 <ul class="space-y-4 relative divide-zinc-200 dark:divide-zinc-800 divide-y"> 38 {notifications.length 39 ? ( 40 notifications.map((notification) => ( 41 <li 42 key={notification.uri} 43 class="flex flex-col gap-4 pb-4" 44 > 45 <div class="flex flex-wrap items-center gap-1"> 46 <a 47 href={profileLink(notification.author.handle)} 48 class="flex items-center gap-2 hover:underline" 49 > 50 <ActorAvatar 51 profile={notification.author} 52 size={32} 53 /> 54 <span class="font-semibold break-words"> 55 {notification.author.displayName || 56 notification.author.handle} 57 </span> 58 </a> 59 <span class="break-words"> 60 {notification.reason === "gallery-favorite" && ( 61 <> 62 favorited your gallery · {formatRelativeTime( 63 new Date((notification.record as Favorite).createdAt), 64 )} 65 </> 66 )} 67 {notification.reason === "gallery-comment" && ( 68 <> 69 commented on your gallery · {formatRelativeTime( 70 new Date((notification.record as Comment).createdAt), 71 )} 72 </> 73 )} 74 {(notification.reason === "gallery-mention" || 75 notification.reason === "gallery-comment-mention") && ( 76 <> 77 mentioned you in a gallery · {formatRelativeTime( 78 new Date( 79 (notification.record as Comment).createdAt, 80 ), 81 )} 82 </> 83 )} 84 {notification.reason === "reply" && ( 85 <> 86 replied to your comment · {formatRelativeTime( 87 new Date((notification.record as Comment).createdAt), 88 )} 89 </> 90 )} 91 {notification.reason === "follow" && ( 92 <> 93 followed you · {formatRelativeTime( 94 new Date((notification.record as Follow).createdAt), 95 )} 96 </> 97 )} 98 </span> 99 </div> 100 {notification.reason === "gallery-favorite" && 101 ( 102 <GalleryFavoriteNotification 103 notification={notification} 104 galleriesMap={galleriesMap} 105 /> 106 )} 107 {notification.reason === "gallery-comment" && 108 ( 109 <GalleryCommentNotification 110 notification={notification} 111 galleriesMap={galleriesMap} 112 photosMap={photosMap} 113 /> 114 )} 115 {notification.reason === "gallery-comment-mention" && 116 ( 117 <GalleryCommentNotification 118 notification={notification} 119 galleriesMap={galleriesMap} 120 photosMap={photosMap} 121 /> 122 )} 123 {notification.reason === "reply" && 124 ( 125 <ReplyNotification 126 notification={notification} 127 galleriesMap={galleriesMap} 128 photosMap={photosMap} 129 commentsMap={commentsMap} 130 /> 131 )} 132 {notification.reason === "gallery-mention" && 133 ( 134 <GalleryMentionNotification 135 notification={notification} 136 galleriesMap={galleriesMap} 137 /> 138 )} 139 </li> 140 )) 141 ) 142 : <li>No notifications yet.</li>} 143 </ul> 144 </div> 145 ); 146} 147 148function GalleryCommentNotification( 149 { notification, galleriesMap, photosMap }: Readonly<{ 150 notification: NotificationView; 151 galleriesMap: Map<string, GalleryView>; 152 photosMap: Map<string, PhotoView>; 153 }>, 154) { 155 const comment = notification.record as Comment; 156 const gallery = galleriesMap.get(comment.subject) as GalleryView | undefined; 157 if (!gallery) return null; 158 return ( 159 <> 160 {<RenderFacetedText text={comment.text} facets={comment.facets} />} 161 {comment.focus 162 ? ( 163 <a 164 href={galleryLink( 165 gallery.creator.handle, 166 new AtUri(gallery.uri).rkey, 167 )} 168 class="w-[200px]" 169 > 170 <img 171 src={photosMap.get(comment.focus ?? "")?.thumb} 172 alt={photosMap.get(comment.focus ?? "")?.alt} 173 class="rounded-md" 174 /> 175 </a> 176 ) 177 : ( 178 <div class="w-[200px]"> 179 <GalleryPreviewLink 180 gallery={gallery} 181 size="small" 182 /> 183 </div> 184 )} 185 </> 186 ); 187} 188 189function ReplyNotification( 190 { notification, galleriesMap, photosMap, commentsMap }: Readonly<{ 191 notification: NotificationView; 192 galleriesMap: Map<string, GalleryView>; 193 photosMap: Map<string, PhotoView>; 194 commentsMap: Map<string, CommentView>; 195 }>, 196) { 197 const comment = notification.record as Comment; 198 const gallery = galleriesMap.get(comment.subject) as GalleryView | undefined; 199 let replyToComment: CommentView | undefined = undefined; 200 if (comment.replyTo) { 201 replyToComment = commentsMap.get(comment.replyTo); 202 } 203 if (!gallery) return null; 204 return ( 205 <> 206 {replyToComment && ( 207 <div class="text-sm border-l-2 border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-500 pl-2"> 208 <RenderFacetedText 209 text={replyToComment.text} 210 facets={(replyToComment.record as Comment).facets} 211 /> 212 {isPhotoView(replyToComment?.focus) 213 ? ( 214 <a 215 class="block mt-2 max-w-[200px]" 216 href={galleryLink( 217 gallery.creator.handle, 218 new AtUri(gallery.uri).rkey, 219 )} 220 > 221 <img 222 src={photosMap.get(replyToComment.focus.uri)?.thumb} 223 alt={photosMap.get(replyToComment.focus.uri)?.alt} 224 class="rounded-md" 225 /> 226 </a> 227 ) 228 : ( 229 <div class="mt-2 max-w-[200px]"> 230 <GalleryPreviewLink 231 class="mt-2" 232 gallery={gallery} 233 size="small" 234 /> 235 </div> 236 )} 237 </div> 238 )} 239 <RenderFacetedText text={comment.text} facets={comment.facets} /> 240 {comment.focus 241 ? ( 242 <a 243 href={galleryLink( 244 gallery.creator.handle, 245 new AtUri(gallery.uri).rkey, 246 )} 247 class="max-w-[200px]" 248 > 249 <img 250 src={photosMap.get(comment.focus ?? "")?.thumb} 251 alt={photosMap.get(comment.focus ?? "")?.alt} 252 class="rounded-md" 253 /> 254 </a> 255 ) 256 : !replyToComment 257 ? ( 258 <div class="w-[200px]"> 259 <GalleryPreviewLink 260 gallery={gallery} 261 size="small" 262 /> 263 </div> 264 ) 265 : null} 266 </> 267 ); 268} 269 270function GalleryFavoriteNotification( 271 { notification, galleriesMap }: Readonly<{ 272 notification: NotificationView; 273 galleriesMap: Map<string, GalleryView>; 274 }>, 275) { 276 const favorite = notification.record as Favorite; 277 const gallery = galleriesMap.get(favorite.subject) as GalleryView | undefined; 278 if (!gallery) return null; 279 return ( 280 <div class="w-[200px]"> 281 <GalleryPreviewLink 282 gallery={gallery} 283 size="small" 284 /> 285 </div> 286 ); 287} 288 289function GalleryMentionNotification( 290 { notification, galleriesMap }: Readonly<{ 291 notification: NotificationView; 292 galleriesMap: Map<string, GalleryView>; 293 }>, 294) { 295 const galleryRecord = notification.record as WithBffMeta<Gallery>; 296 const gallery = galleriesMap.get(galleryRecord.uri) as 297 | GalleryView 298 | undefined; 299 if (!gallery) return null; 300 return ( 301 <div class="text-sm border-l-2 border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-500 pl-2"> 302 <RenderFacetedText 303 text={gallery.description ?? ""} 304 facets={galleryRecord.facets} 305 /> 306 <div class="mt-2 max-w-[200px]"> 307 <GalleryPreviewLink 308 gallery={gallery} 309 size="small" 310 /> 311 </div> 312 </div> 313 ); 314}