grain.social is a photo sharing platform built on atproto.
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}