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