mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
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}