mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
fork

Configure Feed

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

at utm-source 453 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 border rounded-lg 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 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 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-lg 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">{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-lg overflow-hidden object-cover h-auto max-h-[1000px]" 235 /> 236 ) 237 case 2: 238 return ( 239 <div className="flex gap-1 rounded-lg 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-lg overflow-hidden w-full aspect-[2/1]"> 253 <img 254 src={content.images[0].thumb} 255 alt={content.images[0].alt} 256 className="flex-[3] object-cover rounded-sm" 257 /> 258 <div className="flex flex-col gap-1 flex-[2]"> 259 {content.images.slice(1).map((image, i) => ( 260 <img 261 key={i} 262 src={image.thumb} 263 alt={image.alt} 264 className="w-full h-full object-cover rounded-sm" 265 /> 266 ))} 267 </div> 268 </div> 269 ) 270 case 4: 271 return ( 272 <div className="grid grid-cols-2 gap-1 rounded-lg overflow-hidden"> 273 {content.images.map((image, i) => ( 274 <img 275 key={i} 276 src={image.thumb} 277 alt={image.alt} 278 className="aspect-square w-full object-cover rounded-sm" 279 /> 280 ))} 281 </div> 282 ) 283 default: 284 return null 285 } 286} 287 288function ExternalEmbed({ 289 content, 290 labelInfo, 291}: { 292 content: AppBskyEmbedExternal.View 293 labelInfo?: string 294}) { 295 function toNiceDomain(url: string): string { 296 try { 297 const urlp = new URL(url) 298 return urlp.host ? urlp.host : url 299 } catch (e) { 300 return url 301 } 302 } 303 304 if (labelInfo) { 305 return <Info>{labelInfo}</Info> 306 } 307 308 return ( 309 <Link 310 href={content.external.uri} 311 className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch" 312 disableTracking> 313 {content.external.thumb && ( 314 <img 315 src={content.external.thumb} 316 className="aspect-[1.91/1] object-cover" 317 /> 318 )} 319 <div className="py-3 px-4"> 320 <p className="text-sm text-textLight line-clamp-1"> 321 {toNiceDomain(content.external.uri)} 322 </p> 323 <p className="font-semibold line-clamp-3">{content.external.title}</p> 324 <p className="text-sm text-textLight line-clamp-2 mt-0.5"> 325 {content.external.description} 326 </p> 327 </div> 328 </Link> 329 ) 330} 331 332function GenericWithImageEmbed({ 333 title, 334 subtitle, 335 href, 336 image, 337 description, 338}: { 339 title: string 340 subtitle: string 341 href: string 342 image?: string 343 description?: string 344}) { 345 return ( 346 <Link 347 href={href} 348 className="w-full rounded-lg border py-2 px-3 flex flex-col gap-2"> 349 <div className="flex gap-2.5 items-center"> 350 {image ? ( 351 <img 352 src={image} 353 alt={title} 354 className="w-8 h-8 rounded-md bg-neutral-300 shrink-0" 355 /> 356 ) : ( 357 <div className="w-8 h-8 rounded-md bg-brand shrink-0" /> 358 )} 359 <div className="flex-1"> 360 <p className="font-bold text-sm">{title}</p> 361 <p className="text-textLight text-sm">{subtitle}</p> 362 </div> 363 </div> 364 {description && <p className="text-textLight text-sm">{description}</p>} 365 </Link> 366 ) 367} 368 369// just the thumbnail and a play button 370function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) { 371 let aspectRatio = 1 372 373 if (content.aspectRatio) { 374 const {width, height} = content.aspectRatio 375 aspectRatio = clamp(width / height, 1 / 1, 3 / 1) 376 } 377 378 return ( 379 <div 380 className="w-full overflow-hidden rounded-lg aspect-square relative" 381 style={{aspectRatio: `${aspectRatio} / 1`}}> 382 <img 383 src={content.thumbnail} 384 alt={content.alt} 385 className="object-cover size-full" 386 /> 387 <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"> 388 <img src={playIcon} className="object-cover size-3/5" /> 389 </div> 390 </div> 391 ) 392} 393 394function StarterPackEmbed({ 395 content, 396}: { 397 content: AppBskyGraphDefs.StarterPackViewBasic 398}) { 399 if (!AppBskyGraphStarterpack.isRecord(content.record)) { 400 return null 401 } 402 403 const starterPackHref = getStarterPackHref(content) 404 const imageUri = getStarterPackImage(content) 405 406 return ( 407 <Link 408 href={starterPackHref} 409 className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch"> 410 <img src={imageUri} className="aspect-[1.91/1] object-cover" /> 411 <div className="py-3 px-4"> 412 <div className="flex space-x-2 items-center"> 413 <img src={starterPackIcon} className="w-10 h-10" /> 414 <div> 415 <p className="font-semibold leading-[21px]"> 416 {content.record.name} 417 </p> 418 <p className="text-sm text-textLight line-clamp-2 leading-[18px]"> 419 Starter pack by{' '} 420 {content.creator.displayName || `@${content.creator.handle}`} 421 </p> 422 </div> 423 </div> 424 {content.record.description && ( 425 <p className="text-sm mt-1">{content.record.description}</p> 426 )} 427 {!!content.joinedAllTimeCount && content.joinedAllTimeCount > 50 && ( 428 <p className="text-sm font-semibold text-textLight mt-1"> 429 {content.joinedAllTimeCount} users have joined! 430 </p> 431 )} 432 </div> 433 </Link> 434 ) 435} 436 437// from #/lib/strings/starter-pack.ts 438function getStarterPackImage(starterPack: AppBskyGraphDefs.StarterPackView) { 439 const rkey = getRkey({uri: starterPack.uri}) 440 return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}` 441} 442 443function getStarterPackHref( 444 starterPack: AppBskyGraphDefs.StarterPackViewBasic, 445) { 446 const rkey = getRkey({uri: starterPack.uri}) 447 const handleOrDid = starterPack.creator.handle || starterPack.creator.did 448 return `/starter-pack/${handleOrDid}/${rkey}` 449} 450 451function clamp(num: number, min: number, max: number) { 452 return Math.max(min, Math.min(num, max)) 453}