fork
Configure Feed
Select the types of activity you want to include in your feed.
mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
fork
Configure Feed
Select the types of activity you want to include in your feed.
1import {
2 AppBskyEmbedExternal,
3 AppBskyEmbedImages,
4 AppBskyEmbedRecord,
5 AppBskyEmbedRecordWithMedia,
6 AppBskyEmbedVideo,
7 AppBskyFeedDefs,
8 AppBskyFeedPost,
9 AppBskyGraphDefs,
10 AppBskyGraphStarterpack,
11 AppBskyLabelerDefs,
12} from '@atproto/api'
13import {ComponentChildren, h} from 'preact'
14import {useMemo} from 'preact/hooks'
15
16import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
17import playIcon from '../../assets/play_filled_corner2_rounded.svg'
18import starterPackIcon from '../../assets/starterPack.svg'
19import {CONTENT_LABELS, labelsToInfo} from '../labels'
20import {getRkey} from '../utils'
21import {Link} from './link'
22
23export function Embed({
24 content,
25 labels,
26 hideRecord,
27}: {
28 content: AppBskyFeedDefs.PostView['embed']
29 labels: AppBskyFeedDefs.PostView['labels']
30 hideRecord?: boolean
31}) {
32 const labelInfo = useMemo(() => labelsToInfo(labels), [labels])
33
34 if (!content) return null
35
36 try {
37 // Case 1: Image
38 if (AppBskyEmbedImages.isView(content)) {
39 return <ImageEmbed content={content} labelInfo={labelInfo} />
40 }
41
42 // Case 2: External link
43 if (AppBskyEmbedExternal.isView(content)) {
44 return <ExternalEmbed content={content} labelInfo={labelInfo} />
45 }
46
47 // Case 3: Record (quote or linked post)
48 if (AppBskyEmbedRecord.isView(content)) {
49 if (hideRecord) {
50 return null
51 }
52
53 const record = content.record
54
55 // Case 3.1: Post
56 if (AppBskyEmbedRecord.isViewRecord(record)) {
57 const pwiOptOut = !!record.author.labels?.find(
58 label => label.val === '!no-unauthenticated',
59 )
60 if (pwiOptOut) {
61 return (
62 <Info>
63 The author of the quoted post has requested their posts not be
64 displayed on external sites.
65 </Info>
66 )
67 }
68
69 let text
70 if (AppBskyFeedPost.isRecord(record.value)) {
71 text = record.value.text
72 }
73
74 const isAuthorLabeled = record.author.labels?.some(label =>
75 CONTENT_LABELS.includes(label.val),
76 )
77
78 return (
79 <Link
80 href={`/profile/${record.author.did}/post/${getRkey(record)}`}
81 className="transition-colors hover:bg-neutral-100 border rounded-lg p-2 gap-1.5 w-full flex flex-col">
82 <div className="flex gap-1.5 items-center">
83 <div className="w-4 h-4 overflow-hidden rounded-full bg-neutral-300 shrink-0">
84 <img
85 src={record.author.avatar}
86 style={isAuthorLabeled ? {filter: 'blur(1.5px)'} : undefined}
87 />
88 </div>
89 <p className="line-clamp-1 text-sm">
90 <span className="font-bold">{record.author.displayName}</span>
91 <span className="text-textLight ml-1">
92 @{record.author.handle}
93 </span>
94 </p>
95 </div>
96 {text && <p className="text-sm">{text}</p>}
97 {record.embeds?.map(embed => (
98 <Embed
99 key={embed.$type}
100 content={embed}
101 labels={record.labels}
102 hideRecord
103 />
104 ))}
105 </Link>
106 )
107 }
108
109 // Case 3.2: List
110 if (AppBskyGraphDefs.isListView(record)) {
111 return (
112 <GenericWithImageEmbed
113 image={record.avatar}
114 title={record.name}
115 href={`/profile/${record.creator.did}/lists/${getRkey(record)}`}
116 subtitle={
117 record.purpose === AppBskyGraphDefs.MODLIST
118 ? `Moderation list by @${record.creator.handle}`
119 : `User list by @${record.creator.handle}`
120 }
121 description={record.description}
122 />
123 )
124 }
125
126 // Case 3.3: Feed
127 if (AppBskyFeedDefs.isGeneratorView(record)) {
128 return (
129 <GenericWithImageEmbed
130 image={record.avatar}
131 title={record.displayName}
132 href={`/profile/${record.creator.did}/feed/${getRkey(record)}`}
133 subtitle={`Feed by @${record.creator.handle}`}
134 description={`Liked by ${record.likeCount ?? 0} users`}
135 />
136 )
137 }
138
139 // Case 3.4: Labeler
140 if (AppBskyLabelerDefs.isLabelerView(record)) {
141 // Embed type does not exist in the app, so show nothing
142 return null
143 }
144
145 // Case 3.5: Starter pack
146 if (AppBskyGraphDefs.isStarterPackViewBasic(record)) {
147 return <StarterPackEmbed content={record} />
148 }
149
150 // Case 3.6: Post not found
151 if (AppBskyEmbedRecord.isViewNotFound(record)) {
152 return <Info>Quoted post not found, it may have been deleted.</Info>
153 }
154
155 // Case 3.7: Post blocked
156 if (AppBskyEmbedRecord.isViewBlocked(record)) {
157 return <Info>The quoted post is blocked.</Info>
158 }
159
160 // Case 3.8: Detached quote post
161 if (AppBskyEmbedRecord.isViewDetached(record)) {
162 // Just don't show anything
163 return null
164 }
165
166 // Unknown embed type
167 return null
168 }
169
170 // Case 4: Video
171 if (AppBskyEmbedVideo.isView(content)) {
172 return <VideoEmbed content={content} />
173 }
174
175 // Case 5: Record with media
176 if (
177 AppBskyEmbedRecordWithMedia.isView(content) &&
178 AppBskyEmbedRecord.isViewRecord(content.record.record)
179 ) {
180 return (
181 <div className="flex flex-col gap-2">
182 <Embed
183 content={content.media}
184 labels={labels}
185 hideRecord={hideRecord}
186 />
187 <Embed
188 content={{
189 $type: 'app.bsky.embed.record#view',
190 record: content.record.record,
191 }}
192 labels={content.record.record.labels}
193 hideRecord={hideRecord}
194 />
195 </div>
196 )
197 }
198
199 // Unknown embed type
200 return null
201 } catch (err) {
202 return (
203 <Info>{err instanceof Error ? err.message : 'An error occurred'}</Info>
204 )
205 }
206}
207
208function Info({children}: {children: ComponentChildren}) {
209 return (
210 <div className="w-full rounded-lg border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50">
211 <img src={infoIcon} className="w-4 h-4 shrink-0 mt-0.5" />
212 <p className="text-sm text-textLight">{children}</p>
213 </div>
214 )
215}
216
217function ImageEmbed({
218 content,
219 labelInfo,
220}: {
221 content: AppBskyEmbedImages.View
222 labelInfo?: string
223}) {
224 if (labelInfo) {
225 return <Info>{labelInfo}</Info>
226 }
227
228 switch (content.images.length) {
229 case 1:
230 return (
231 <img
232 src={content.images[0].thumb}
233 alt={content.images[0].alt}
234 className="w-full rounded-lg overflow-hidden object-cover h-auto max-h-[1000px]"
235 />
236 )
237 case 2:
238 return (
239 <div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
240 {content.images.map((image, i) => (
241 <img
242 key={i}
243 src={image.thumb}
244 alt={image.alt}
245 className="w-1/2 h-full object-cover rounded-sm"
246 />
247 ))}
248 </div>
249 )
250 case 3:
251 return (
252 <div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
253 <img
254 src={content.images[0].thumb}
255 alt={content.images[0].alt}
256 className="flex-[3] object-cover rounded-sm"
257 />
258 <div className="flex flex-col gap-1 flex-[2]">
259 {content.images.slice(1).map((image, i) => (
260 <img
261 key={i}
262 src={image.thumb}
263 alt={image.alt}
264 className="w-full h-full object-cover rounded-sm"
265 />
266 ))}
267 </div>
268 </div>
269 )
270 case 4:
271 return (
272 <div className="grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
273 {content.images.map((image, i) => (
274 <img
275 key={i}
276 src={image.thumb}
277 alt={image.alt}
278 className="aspect-square w-full object-cover rounded-sm"
279 />
280 ))}
281 </div>
282 )
283 default:
284 return null
285 }
286}
287
288function ExternalEmbed({
289 content,
290 labelInfo,
291}: {
292 content: AppBskyEmbedExternal.View
293 labelInfo?: string
294}) {
295 function toNiceDomain(url: string): string {
296 try {
297 const urlp = new URL(url)
298 return urlp.host ? urlp.host : url
299 } catch (e) {
300 return url
301 }
302 }
303
304 if (labelInfo) {
305 return <Info>{labelInfo}</Info>
306 }
307
308 return (
309 <Link
310 href={content.external.uri}
311 className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch"
312 disableTracking>
313 {content.external.thumb && (
314 <img
315 src={content.external.thumb}
316 className="aspect-[1.91/1] object-cover"
317 />
318 )}
319 <div className="py-3 px-4">
320 <p className="text-sm text-textLight line-clamp-1">
321 {toNiceDomain(content.external.uri)}
322 </p>
323 <p className="font-semibold line-clamp-3">{content.external.title}</p>
324 <p className="text-sm text-textLight line-clamp-2 mt-0.5">
325 {content.external.description}
326 </p>
327 </div>
328 </Link>
329 )
330}
331
332function GenericWithImageEmbed({
333 title,
334 subtitle,
335 href,
336 image,
337 description,
338}: {
339 title: string
340 subtitle: string
341 href: string
342 image?: string
343 description?: string
344}) {
345 return (
346 <Link
347 href={href}
348 className="w-full rounded-lg border py-2 px-3 flex flex-col gap-2">
349 <div className="flex gap-2.5 items-center">
350 {image ? (
351 <img
352 src={image}
353 alt={title}
354 className="w-8 h-8 rounded-md bg-neutral-300 shrink-0"
355 />
356 ) : (
357 <div className="w-8 h-8 rounded-md bg-brand shrink-0" />
358 )}
359 <div className="flex-1">
360 <p className="font-bold text-sm">{title}</p>
361 <p className="text-textLight text-sm">{subtitle}</p>
362 </div>
363 </div>
364 {description && <p className="text-textLight text-sm">{description}</p>}
365 </Link>
366 )
367}
368
369// just the thumbnail and a play button
370function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
371 let aspectRatio = 1
372
373 if (content.aspectRatio) {
374 const {width, height} = content.aspectRatio
375 aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
376 }
377
378 return (
379 <div
380 className="w-full overflow-hidden rounded-lg aspect-square relative"
381 style={{aspectRatio: `${aspectRatio} / 1`}}>
382 <img
383 src={content.thumbnail}
384 alt={content.alt}
385 className="object-cover size-full"
386 />
387 <div className="size-24 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-black/50 flex items-center justify-center">
388 <img src={playIcon} className="object-cover size-3/5" />
389 </div>
390 </div>
391 )
392}
393
394function StarterPackEmbed({
395 content,
396}: {
397 content: AppBskyGraphDefs.StarterPackViewBasic
398}) {
399 if (!AppBskyGraphStarterpack.isRecord(content.record)) {
400 return null
401 }
402
403 const starterPackHref = getStarterPackHref(content)
404 const imageUri = getStarterPackImage(content)
405
406 return (
407 <Link
408 href={starterPackHref}
409 className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch">
410 <img src={imageUri} className="aspect-[1.91/1] object-cover" />
411 <div className="py-3 px-4">
412 <div className="flex space-x-2 items-center">
413 <img src={starterPackIcon} className="w-10 h-10" />
414 <div>
415 <p className="font-semibold leading-[21px]">
416 {content.record.name}
417 </p>
418 <p className="text-sm text-textLight line-clamp-2 leading-[18px]">
419 Starter pack by{' '}
420 {content.creator.displayName || `@${content.creator.handle}`}
421 </p>
422 </div>
423 </div>
424 {content.record.description && (
425 <p className="text-sm mt-1">{content.record.description}</p>
426 )}
427 {!!content.joinedAllTimeCount && content.joinedAllTimeCount > 50 && (
428 <p className="text-sm font-semibold text-textLight mt-1">
429 {content.joinedAllTimeCount} users have joined!
430 </p>
431 )}
432 </div>
433 </Link>
434 )
435}
436
437// from #/lib/strings/starter-pack.ts
438function getStarterPackImage(starterPack: AppBskyGraphDefs.StarterPackView) {
439 const rkey = getRkey({uri: starterPack.uri})
440 return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}`
441}
442
443function getStarterPackHref(
444 starterPack: AppBskyGraphDefs.StarterPackViewBasic,
445) {
446 const rkey = getRkey({uri: starterPack.uri})
447 const handleOrDid = starterPack.creator.handle || starterPack.creator.did
448 return `/starter-pack/${handleOrDid}/${rkey}`
449}
450
451function clamp(num: number, min: number, max: number) {
452 return Math.max(min, Math.min(num, max))
453}