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