mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at rm-proxy 352 lines 9.8 kB view raw
1import { 2 AppBskyEmbedExternal, 3 AppBskyEmbedImages, 4 AppBskyEmbedRecord, 5 AppBskyEmbedRecordWithMedia, 6 AppBskyFeedDefs, 7 AppBskyFeedPost, 8 AppBskyGraphDefs, 9 AppBskyLabelerDefs, 10} from '@atproto/api' 11import {ComponentChildren, h} from 'preact' 12import {useMemo} from 'preact/hooks' 13 14import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg' 15import {CONTENT_LABELS, labelsToInfo} from '../labels' 16import {getRkey} from '../utils' 17import {Link} from './link' 18 19export function Embed({ 20 content, 21 labels, 22 hideRecord, 23}: { 24 content: AppBskyFeedDefs.PostView['embed'] 25 labels: AppBskyFeedDefs.PostView['labels'] 26 hideRecord?: boolean 27}) { 28 const labelInfo = useMemo(() => labelsToInfo(labels), [labels]) 29 30 if (!content) return null 31 32 try { 33 // Case 1: Image 34 if (AppBskyEmbedImages.isView(content)) { 35 return <ImageEmbed content={content} labelInfo={labelInfo} /> 36 } 37 38 // Case 2: External link 39 if (AppBskyEmbedExternal.isView(content)) { 40 return <ExternalEmbed content={content} labelInfo={labelInfo} /> 41 } 42 43 // Case 3: Record (quote or linked post) 44 if (AppBskyEmbedRecord.isView(content)) { 45 if (hideRecord) { 46 return null 47 } 48 49 const record = content.record 50 51 // Case 3.1: Post 52 if (AppBskyEmbedRecord.isViewRecord(record)) { 53 const pwiOptOut = !!record.author.labels?.find( 54 label => label.val === '!no-unauthenticated', 55 ) 56 if (pwiOptOut) { 57 return ( 58 <Info> 59 The author of the quoted post has requested their posts not be 60 displayed on external sites. 61 </Info> 62 ) 63 } 64 65 let text 66 if (AppBskyFeedPost.isRecord(record.value)) { 67 text = record.value.text 68 } 69 70 const isAuthorLabeled = record.author.labels?.some(label => 71 CONTENT_LABELS.includes(label.val), 72 ) 73 74 return ( 75 <Link 76 href={`/profile/${record.author.did}/post/${getRkey(record)}`} 77 className="transition-colors hover:bg-neutral-100 border rounded-lg p-2 gap-1.5 w-full flex flex-col"> 78 <div className="flex gap-1.5 items-center"> 79 <div className="w-4 h-4 overflow-hidden rounded-full bg-neutral-300 shrink-0"> 80 <img 81 src={record.author.avatar} 82 style={isAuthorLabeled ? {filter: 'blur(1.5px)'} : undefined} 83 /> 84 </div> 85 <p className="line-clamp-1 text-sm"> 86 <span className="font-bold">{record.author.displayName}</span> 87 <span className="text-textLight ml-1"> 88 @{record.author.handle} 89 </span> 90 </p> 91 </div> 92 {text && <p className="text-sm">{text}</p>} 93 {record.embeds?.map(embed => ( 94 <Embed 95 key={embed.$type} 96 content={embed} 97 labels={record.labels} 98 hideRecord 99 /> 100 ))} 101 </Link> 102 ) 103 } 104 105 // Case 3.2: List 106 if (AppBskyGraphDefs.isListView(record)) { 107 return ( 108 <GenericWithImage 109 image={record.avatar} 110 title={record.name} 111 href={`/profile/${record.creator.did}/lists/${getRkey(record)}`} 112 subtitle={ 113 record.purpose === AppBskyGraphDefs.MODLIST 114 ? `Moderation list by @${record.creator.handle}` 115 : `User list by @${record.creator.handle}` 116 } 117 description={record.description} 118 /> 119 ) 120 } 121 122 // Case 3.3: Feed 123 if (AppBskyFeedDefs.isGeneratorView(record)) { 124 return ( 125 <GenericWithImage 126 image={record.avatar} 127 title={record.displayName} 128 href={`/profile/${record.creator.did}/feed/${getRkey(record)}`} 129 subtitle={`Feed by @${record.creator.handle}`} 130 description={`Liked by ${record.likeCount ?? 0} users`} 131 /> 132 ) 133 } 134 135 // Case 3.4: Labeler 136 if (AppBskyLabelerDefs.isLabelerView(record)) { 137 return ( 138 <GenericWithImage 139 image={record.creator.avatar} 140 title={record.creator.displayName || record.creator.handle} 141 href={`/profile/${record.creator.did}`} 142 subtitle="Labeler" 143 description={`Liked by ${record.likeCount ?? 0} users`} 144 /> 145 ) 146 } 147 148 // Case 3.5: Post not found 149 if (AppBskyEmbedRecord.isViewNotFound(record)) { 150 return <Info>Quoted post not found, it may have been deleted.</Info> 151 } 152 153 // Case 3.6: Post blocked 154 if (AppBskyEmbedRecord.isViewBlocked(record)) { 155 return <Info>The quoted post is blocked.</Info> 156 } 157 158 throw new Error('Unknown embed type') 159 } 160 161 // Case 4: Record with media 162 if ( 163 AppBskyEmbedRecordWithMedia.isView(content) && 164 AppBskyEmbedRecord.isViewRecord(content.record.record) 165 ) { 166 return ( 167 <div className="flex flex-col gap-2"> 168 <Embed 169 content={content.media} 170 labels={labels} 171 hideRecord={hideRecord} 172 /> 173 <Embed 174 content={{ 175 $type: 'app.bsky.embed.record#view', 176 record: content.record.record, 177 }} 178 labels={content.record.record.labels} 179 hideRecord={hideRecord} 180 /> 181 </div> 182 ) 183 } 184 185 throw new Error('Unsupported embed type') 186 } catch (err) { 187 return ( 188 <Info>{err instanceof Error ? err.message : 'An error occurred'}</Info> 189 ) 190 } 191} 192 193function Info({children}: {children: ComponentChildren}) { 194 return ( 195 <div className="w-full rounded-lg border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50"> 196 <img src={infoIcon} className="w-4 h-4 shrink-0 mt-0.5" /> 197 <p className="text-sm text-textLight">{children}</p> 198 </div> 199 ) 200} 201 202function ImageEmbed({ 203 content, 204 labelInfo, 205}: { 206 content: AppBskyEmbedImages.View 207 labelInfo?: string 208}) { 209 if (labelInfo) { 210 return <Info>{labelInfo}</Info> 211 } 212 213 switch (content.images.length) { 214 case 1: 215 return ( 216 <img 217 src={content.images[0].thumb} 218 alt={content.images[0].alt} 219 className="w-full rounded-lg overflow-hidden object-cover h-auto max-h-[1000px]" 220 /> 221 ) 222 case 2: 223 return ( 224 <div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]"> 225 {content.images.map((image, i) => ( 226 <img 227 key={i} 228 src={image.thumb} 229 alt={image.alt} 230 className="w-1/2 h-full object-cover rounded-sm" 231 /> 232 ))} 233 </div> 234 ) 235 case 3: 236 return ( 237 <div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]"> 238 <img 239 src={content.images[0].thumb} 240 alt={content.images[0].alt} 241 className="flex-[3] object-cover rounded-sm" 242 /> 243 <div className="flex flex-col gap-1 flex-[2]"> 244 {content.images.slice(1).map((image, i) => ( 245 <img 246 key={i} 247 src={image.thumb} 248 alt={image.alt} 249 className="w-full h-full object-cover rounded-sm" 250 /> 251 ))} 252 </div> 253 </div> 254 ) 255 case 4: 256 return ( 257 <div className="grid grid-cols-2 gap-1 rounded-lg overflow-hidden"> 258 {content.images.map((image, i) => ( 259 <img 260 key={i} 261 src={image.thumb} 262 alt={image.alt} 263 className="aspect-square w-full object-cover rounded-sm" 264 /> 265 ))} 266 </div> 267 ) 268 default: 269 return null 270 } 271} 272 273function ExternalEmbed({ 274 content, 275 labelInfo, 276}: { 277 content: AppBskyEmbedExternal.View 278 labelInfo?: string 279}) { 280 function toNiceDomain(url: string): string { 281 try { 282 const urlp = new URL(url) 283 return urlp.host ? urlp.host : url 284 } catch (e) { 285 return url 286 } 287 } 288 289 if (labelInfo) { 290 return <Info>{labelInfo}</Info> 291 } 292 293 return ( 294 <Link 295 href={content.external.uri} 296 className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch" 297 disableTracking> 298 {content.external.thumb && ( 299 <img 300 src={content.external.thumb} 301 className="aspect-[1.91/1] object-cover" 302 /> 303 )} 304 <div className="py-3 px-4"> 305 <p className="text-sm text-textLight line-clamp-1"> 306 {toNiceDomain(content.external.uri)} 307 </p> 308 <p className="font-semibold line-clamp-3">{content.external.title}</p> 309 <p className="text-sm text-textLight line-clamp-2 mt-0.5"> 310 {content.external.description} 311 </p> 312 </div> 313 </Link> 314 ) 315} 316 317function GenericWithImage({ 318 title, 319 subtitle, 320 href, 321 image, 322 description, 323}: { 324 title: string 325 subtitle: string 326 href: string 327 image?: string 328 description?: string 329}) { 330 return ( 331 <Link 332 href={href} 333 className="w-full rounded-lg border py-2 px-3 flex flex-col gap-2"> 334 <div className="flex gap-2.5 items-center"> 335 {image ? ( 336 <img 337 src={image} 338 alt={title} 339 className="w-8 h-8 rounded-md bg-neutral-300 shrink-0" 340 /> 341 ) : ( 342 <div className="w-8 h-8 rounded-md bg-brand shrink-0" /> 343 )} 344 <div className="flex-1"> 345 <p className="font-bold text-sm">{title}</p> 346 <p className="text-textLight text-sm">{subtitle}</p> 347 </div> 348 </div> 349 {description && <p className="text-textLight text-sm">{description}</p>} 350 </Link> 351 ) 352}