mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at sonner 461 lines 13 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 {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}