Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 483 lines 14 kB view raw
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}