mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
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 dark:hover:bg-slate-700 border dark:border-slate-600 rounded-xl 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 dark:bg-slate-700 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 dark:text-textDimmed 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-xl 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 dark:text-textDimmed">{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-xl overflow-hidden object-cover h-auto max-h-[1000px]"
235 />
236 )
237 case 2:
238 return (
239 <div className="flex gap-1 rounded-xl 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-xl overflow-hidden w-full aspect-[2/1]">
253 <div className="flex-1 aspect-square">
254 <img
255 src={content.images[0].thumb}
256 alt={content.images[0].alt}
257 className="w-full h-full object-cover rounded-sm"
258 />
259 </div>
260 <div className="flex flex-col gap-1 flex-1">
261 {content.images.slice(1).map((image, i) => (
262 <img
263 key={i}
264 src={image.thumb}
265 alt={image.alt}
266 className="flex-1 object-cover rounded-sm min-h-0"
267 />
268 ))}
269 </div>
270 </div>
271 )
272 case 4:
273 return (
274 <div className="grid grid-cols-2 gap-1 rounded-xl overflow-hidden">
275 {content.images.map((image, i) => (
276 <img
277 key={i}
278 src={image.thumb}
279 alt={image.alt}
280 className="aspect-[3/2] w-full object-cover rounded-sm"
281 />
282 ))}
283 </div>
284 )
285 default:
286 return null
287 }
288}
289
290function ExternalEmbed({
291 content,
292 labelInfo,
293}: {
294 content: AppBskyEmbedExternal.View
295 labelInfo?: string
296}) {
297 function toNiceDomain(url: string): string {
298 try {
299 const urlp = new URL(url)
300 return urlp.host ? urlp.host : url
301 } catch (e) {
302 return url
303 }
304 }
305
306 if (labelInfo) {
307 return <Info>{labelInfo}</Info>
308 }
309
310 return (
311 <Link
312 href={content.external.uri}
313 className="w-full rounded-xl overflow-hidden border dark:border-slate-600 flex flex-col items-stretch"
314 disableTracking>
315 {content.external.thumb && (
316 <img
317 src={content.external.thumb}
318 className="aspect-[1.91/1] object-cover"
319 />
320 )}
321 <div className="py-3 px-4">
322 <p className="text-sm text-textLight dark:text-textDimmed line-clamp-1">
323 {toNiceDomain(content.external.uri)}
324 </p>
325 <p className="font-semibold line-clamp-3">{content.external.title}</p>
326 <p className="text-sm text-textLight dark:text-textDimmed line-clamp-2 mt-0.5">
327 {content.external.description}
328 </p>
329 </div>
330 </Link>
331 )
332}
333
334function GenericWithImageEmbed({
335 title,
336 subtitle,
337 href,
338 image,
339 description,
340}: {
341 title: string
342 subtitle: string
343 href: string
344 image?: string
345 description?: string
346}) {
347 return (
348 <Link
349 href={href}
350 className="w-full rounded-xl border dark:border-slate-600 py-2 px-3 flex flex-col gap-2">
351 <div className="flex gap-2.5 items-center">
352 {image ? (
353 <img
354 src={image}
355 alt={title}
356 className="w-8 h-8 rounded-md bg-neutral-300 dark:bg-slate-700 shrink-0"
357 />
358 ) : (
359 <div className="w-8 h-8 rounded-md bg-brand shrink-0" />
360 )}
361 <div className="flex-1">
362 <p className="font-bold text-sm">{title}</p>
363 <p className="text-textLight dark:text-textDimmed text-sm">
364 {subtitle}
365 </p>
366 </div>
367 </div>
368 {description && (
369 <p className="text-textLight dark:text-textDimmed text-sm">
370 {description}
371 </p>
372 )}
373 </Link>
374 )
375}
376
377// just the thumbnail and a play button
378function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
379 let aspectRatio = 1
380
381 if (content.aspectRatio) {
382 const {width, height} = content.aspectRatio
383 aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
384 }
385
386 return (
387 <div
388 className="w-full overflow-hidden rounded-xl aspect-square relative"
389 style={{aspectRatio: `${aspectRatio} / 1`}}>
390 <img
391 src={content.thumbnail}
392 alt={content.alt}
393 className="object-cover size-full"
394 />
395 <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">
396 <img src={playIcon} className="object-cover size-3/5" />
397 </div>
398 </div>
399 )
400}
401
402function StarterPackEmbed({
403 content,
404}: {
405 content: AppBskyGraphDefs.StarterPackViewBasic
406}) {
407 if (!AppBskyGraphStarterpack.isRecord(content.record)) {
408 return null
409 }
410
411 const starterPackHref = getStarterPackHref(content)
412 const imageUri = getStarterPackImage(content)
413
414 return (
415 <Link
416 href={starterPackHref}
417 className="w-full rounded-xl overflow-hidden border dark:border-slate-600 flex flex-col items-stretch">
418 <img src={imageUri} className="aspect-[1.91/1] object-cover" />
419 <div className="py-3 px-4">
420 <div className="flex space-x-2 items-center">
421 <img src={starterPackIcon} className="w-10 h-10" />
422 <div>
423 <p className="font-semibold leading-[21px]">
424 {content.record.name}
425 </p>
426 <p className="text-sm text-textLight dark:text-textDimmed line-clamp-2 leading-[18px]">
427 Starter pack by{' '}
428 {content.creator.displayName || `@${content.creator.handle}`}
429 </p>
430 </div>
431 </div>
432 {content.record.description && (
433 <p className="text-sm mt-1">{content.record.description}</p>
434 )}
435 {!!content.joinedAllTimeCount && content.joinedAllTimeCount > 50 && (
436 <p className="text-sm font-semibold text-textLight dark:text-textDimmed mt-1">
437 {content.joinedAllTimeCount} users have joined!
438 </p>
439 )}
440 </div>
441 </Link>
442 )
443}
444
445// from #/lib/strings/starter-pack.ts
446function getStarterPackImage(starterPack: AppBskyGraphDefs.StarterPackView) {
447 const rkey = getRkey({uri: starterPack.uri})
448 return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}`
449}
450
451function getStarterPackHref(
452 starterPack: AppBskyGraphDefs.StarterPackViewBasic,
453) {
454 const rkey = getRkey({uri: starterPack.uri})
455 const handleOrDid = starterPack.creator.handle || starterPack.creator.did
456 return `/starter-pack/${handleOrDid}/${rkey}`
457}
458
459function clamp(num: number, min: number, max: number) {
460 return Math.max(min, Math.min(num, max))
461}