Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at a876aae44ea07494ebea9727350aa060b81f317b 510 lines 15 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_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}