an appview-less Bluesky client using Constellation and PDS Queries
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
1import { useNavigate } from "@tanstack/react-router";
2import DOMPurify from "dompurify";
3import { useAtom } from "jotai";
4import { DropdownMenu } from "radix-ui";
5import { HoverCard } from "radix-ui";
6import * as React from "react";
7import { type SVGProps } from "react";
8
9import {
10 composerAtom,
11 constellationURLAtom,
12 imgCDNAtom,
13 likedPostsAtom,
14} from "~/utils/atoms";
15import { useHydratedEmbed } from "~/utils/useHydrated";
16import {
17 useQueryConstellation,
18 useQueryIdentity,
19 useQueryPost,
20 useQueryProfile,
21 yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks,
22} from "~/utils/useQuery";
23
24function asTyped<T extends { $type: string }>(obj: T): $Typed<T> {
25 return obj as $Typed<T>;
26}
27
28export const CACHE_TIMEOUT = 5 * 60 * 1000;
29const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
30
31export interface UniversalPostRendererATURILoaderProps {
32 atUri: string;
33 onConstellation?: (data: any) => void;
34 detailed?: boolean;
35 bottomReplyLine?: boolean;
36 topReplyLine?: boolean;
37 bottomBorder?: boolean;
38 feedviewpost?: boolean;
39 repostedby?: string;
40 style?: React.CSSProperties;
41 ref?: React.Ref<HTMLDivElement>;
42 dataIndexPropPass?: number;
43 nopics?: boolean;
44 concise?: boolean;
45 lightboxCallback?: (d: LightboxProps) => void;
46 maxReplies?: number;
47 isQuote?: boolean;
48}
49
50// export async function cachedGetRecord({
51// atUri,
52// cacheTimeout = CACHE_TIMEOUT,
53// get,
54// set,
55// }: {
56// atUri: string;
57// //resolved: { pdsUrl: string; did: string } | null | undefined;
58// cacheTimeout?: number;
59// get: (key: string) => any;
60// set: (key: string, value: string) => void;
61// }): Promise<any> {
62// const cacheKey = `record:${atUri}`;
63// const cached = get(cacheKey);
64// const now = Date.now();
65// if (
66// cached &&
67// cached.value &&
68// cached.time &&
69// now - cached.time < cacheTimeout
70// ) {
71// try {
72// return JSON.parse(cached.value);
73// } catch {
74// // fall through to fetch
75// }
76// }
77// const parsed = parseAtUri(atUri);
78// if (!parsed) return null;
79// const resolved = await cachedResolveIdentity({
80// didOrHandle: parsed.did,
81// get,
82// set,
83// });
84// if (!resolved?.pdsUrl || !resolved?.did)
85// throw new Error("Missing resolved PDS info");
86
87// if (!parsed) throw new Error("Invalid atUri");
88// const { collection, rkey } = parsed;
89// const url = `${
90// resolved.pdsUrl
91// }/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(
92// resolved.did,
93// )}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(
94// rkey,
95// )}`;
96// const res = await fetch(url);
97// if (!res.ok) throw new Error("Failed to fetch base record");
98// const data = await res.json();
99// set(cacheKey, JSON.stringify(data));
100// return data;
101// }
102
103// export async function cachedResolveIdentity({
104// didOrHandle,
105// cacheTimeout = HANDLE_DID_CACHE_TIMEOUT,
106// get,
107// set,
108// }: {
109// didOrHandle: string;
110// cacheTimeout?: number;
111// get: (key: string) => any;
112// set: (key: string, value: string) => void;
113// }): Promise<any> {
114// const isDidInput = didOrHandle.startsWith("did:");
115// const cacheKey = `handleDid:${didOrHandle}`;
116// const now = Date.now();
117// const cached = get(cacheKey);
118// if (
119// cached &&
120// cached.value &&
121// cached.time &&
122// now - cached.time < cacheTimeout
123// ) {
124// try {
125// return JSON.parse(cached.value);
126// } catch {}
127// }
128// const url = `https://free-fly-24.deno.dev/?${
129// isDidInput
130// ? `did=${encodeURIComponent(didOrHandle)}`
131// : `handle=${encodeURIComponent(didOrHandle)}`
132// }`;
133// const res = await fetch(url);
134// if (!res.ok) throw new Error("Failed to resolve handle/did");
135// const data = await res.json();
136// set(cacheKey, JSON.stringify(data));
137// if (!isDidInput && data.did) {
138// set(`handleDid:${data.did}`, JSON.stringify(data));
139// }
140// return data;
141// }
142
143export function UniversalPostRendererATURILoader({
144 atUri,
145 onConstellation,
146 detailed = false,
147 bottomReplyLine,
148 topReplyLine,
149 bottomBorder = true,
150 feedviewpost = false,
151 repostedby,
152 style,
153 ref,
154 dataIndexPropPass,
155 nopics,
156 concise,
157 lightboxCallback,
158 maxReplies,
159 isQuote,
160}: UniversalPostRendererATURILoaderProps) {
161 // todo remove this once tree rendering is implemented, use a prop like isTree
162 const TEMPLINEAR = true;
163 // /*mass comment*/ console.log("atUri", atUri);
164 //const { get, set } = usePersistentStore();
165 //const [record, setRecord] = React.useState<any>(null);
166 //const [links, setLinks] = React.useState<any>(null);
167 //const [error, setError] = React.useState<string | null>(null);
168 //const [cacheTime, setCacheTime] = React.useState<number | null>(null);
169 //const [resolved, setResolved] = React.useState<any>(null); // { did, pdsUrl, bskyPds, handle }
170 //const [opProfile, setOpProfile] = React.useState<any>(null);
171 // const [opProfileCacheTime, setOpProfileCacheTime] = React.useState<
172 // number | null
173 // >(null);
174 //const router = useRouter();
175
176 //const parsed = React.useMemo(() => parseAtUri(atUri), [atUri]);
177 const parsed = new AtUri(atUri);
178 const did = parsed?.host;
179 const rkey = parsed?.rkey;
180 // /*mass comment*/ console.log("did", did);
181 // /*mass comment*/ console.log("rkey", rkey);
182
183 // React.useEffect(() => {
184 // const checkCache = async () => {
185 // const postUri = atUri;
186 // const cacheKey = `record:${postUri}`;
187 // const cached = await get(cacheKey);
188 // const now = Date.now();
189 // // /*mass comment*/ console.log(
190 // "UniversalPostRenderer checking cache for",
191 // cacheKey,
192 // "cached:",
193 // !!cached,
194 // );
195 // if (
196 // cached &&
197 // cached.value &&
198 // cached.time &&
199 // now - cached.time < CACHE_TIMEOUT
200 // ) {
201 // try {
202 // // /*mass comment*/ console.log("UniversalPostRenderer found cached data for", cacheKey);
203 // setRecord(JSON.parse(cached.value));
204 // } catch {
205 // setRecord(null);
206 // }
207 // }
208 // };
209 // checkCache();
210 // }, [atUri, get]);
211
212 const {
213 data: postQuery,
214 isLoading: isPostLoading,
215 isError: isPostError,
216 } = useQueryPost(atUri);
217 //const record = postQuery?.value;
218
219 // React.useEffect(() => {
220 // if (!did || record) return;
221 // (async () => {
222 // try {
223 // const resolvedData = await cachedResolveIdentity({
224 // didOrHandle: did,
225 // get,
226 // set,
227 // });
228 // setResolved(resolvedData);
229 // } catch (e: any) {
230 // //setError("Failed to resolve handle/did: " + e?.message);
231 // }
232 // })();
233 // }, [did, get, set, record]);
234
235 const { data: resolved } = useQueryIdentity(did || "");
236
237 // React.useEffect(() => {
238 // if (!resolved || !resolved.pdsUrl || !resolved.did || !rkey || record)
239 // return;
240 // let ignore = false;
241 // (async () => {
242 // try {
243 // const data = await cachedGetRecord({
244 // atUri,
245 // get,
246 // set,
247 // });
248 // if (!ignore) setRecord(data);
249 // } catch (e: any) {
250 // //if (!ignore) setError("Failed to fetch base record: " + e?.message);
251 // }
252 // })();
253 // return () => {
254 // ignore = true;
255 // };
256 // }, [resolved, rkey, atUri, record]);
257
258 // React.useEffect(() => {
259 // if (!resolved || !resolved.did || !rkey) return;
260 // const fetchLinks = async () => {
261 // const postUri = atUri;
262 // const cacheKey = `constellation:${postUri}`;
263 // const cached = await get(cacheKey);
264 // const now = Date.now();
265 // if (
266 // cached &&
267 // cached.value &&
268 // cached.time &&
269 // now - cached.time < CACHE_TIMEOUT
270 // ) {
271 // try {
272 // const data = JSON.parse(cached.value);
273 // setLinks(data);
274 // if (onConstellation) onConstellation(data);
275 // } catch {
276 // setLinks(null);
277 // }
278 // //setCacheTime(cached.time);
279 // return;
280 // }
281 // try {
282 // const url = `https://constellation.microcosm.blue/links/all?target=${encodeURIComponent(
283 // atUri,
284 // )}`;
285 // const res = await fetch(url);
286 // if (!res.ok) throw new Error("Failed to fetch constellation links");
287 // const data = await res.json();
288 // setLinks(data);
289 // //setCacheTime(now);
290 // set(cacheKey, JSON.stringify(data));
291 // if (onConstellation) onConstellation(data);
292 // } catch (e: any) {
293 // //setError("Failed to fetch constellation links: " + e?.message);
294 // }
295 // };
296 // fetchLinks();
297 // }, [resolved, rkey, get, set, atUri, onConstellation]);
298
299 const { data: links } = useQueryConstellation({
300 method: "/links/all",
301 target: atUri,
302 });
303
304 // React.useEffect(() => {
305 // if (!record || !resolved || !resolved.did) return;
306 // const fetchOpProfile = async () => {
307 // const opDid = resolved.did;
308 // const postUri = atUri;
309 // const cacheKey = `profile:${postUri}`;
310 // const cached = await get(cacheKey);
311 // const now = Date.now();
312 // if (
313 // cached &&
314 // cached.value &&
315 // cached.time &&
316 // now - cached.time < CACHE_TIMEOUT
317 // ) {
318 // try {
319 // setOpProfile(JSON.parse(cached.value));
320 // } catch {
321 // setOpProfile(null);
322 // }
323 // //setOpProfileCacheTime(cached.time);
324 // return;
325 // }
326 // try {
327 // let opResolvedRaw = await get(`handleDid:${opDid}`);
328 // let opResolved: any = null;
329 // if (
330 // opResolvedRaw &&
331 // opResolvedRaw.value &&
332 // opResolvedRaw.time &&
333 // now - opResolvedRaw.time < HANDLE_DID_CACHE_TIMEOUT
334 // ) {
335 // try {
336 // opResolved = JSON.parse(opResolvedRaw.value);
337 // } catch {
338 // opResolved = null;
339 // }
340 // } else {
341 // const url = `https://free-fly-24.deno.dev/?did=${encodeURIComponent(
342 // opDid,
343 // )}`;
344 // const res = await fetch(url);
345 // if (!res.ok) throw new Error("Failed to resolve OP did");
346 // opResolved = await res.json();
347 // set(`handleDid:${opDid}`, JSON.stringify(opResolved));
348 // }
349 // if (!opResolved || !opResolved.pdsUrl)
350 // throw new Error("OP did resolution failed or missing pdsUrl");
351 // const profileUrl = `${
352 // opResolved.pdsUrl
353 // }/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(
354 // opDid,
355 // )}&collection=app.bsky.actor.profile&rkey=self`;
356 // const profileRes = await fetch(profileUrl);
357 // if (!profileRes.ok) throw new Error("Failed to fetch OP profile");
358 // const profileData = await profileRes.json();
359 // setOpProfile(profileData);
360 // //setOpProfileCacheTime(now);
361 // set(cacheKey, JSON.stringify(profileData));
362 // } catch (e: any) {
363 // //setError("Failed to fetch OP profile: " + e?.message);
364 // }
365 // };
366 // fetchOpProfile();
367 // }, [record, get, set, rkey, resolved, atUri]);
368
369 const { data: opProfile } = useQueryProfile(
370 resolved ? `at://${resolved?.did}/app.bsky.actor.profile/self` : undefined
371 );
372
373 // const displayName =
374 // opProfile?.value?.displayName || resolved?.handle || resolved?.did;
375 // const handle = resolved?.handle ? `@${resolved.handle}` : resolved?.did;
376
377 // const postText = record?.value?.text || "";
378 // const createdAt = record?.value?.createdAt
379 // ? new Date(record.value.createdAt)
380 // : null;
381 // const langTags = record?.value?.langs || [];
382
383 const [likes, setLikes] = React.useState<number | null>(null);
384 const [reposts, setReposts] = React.useState<number | null>(null);
385 const [replies, setReplies] = React.useState<number | null>(null);
386
387 React.useEffect(() => {
388 // /*mass comment*/ console.log(JSON.stringify(links, null, 2));
389 setLikes(
390 links
391 ? links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0
392 : null
393 );
394 setReposts(
395 links
396 ? links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0
397 : null
398 );
399 setReplies(
400 links
401 ? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]
402 ?.records || 0
403 : null
404 );
405 }, [links]);
406
407 // const { data: repliesData } = useQueryConstellation({
408 // method: "/links",
409 // target: atUri,
410 // collection: "app.bsky.feed.post",
411 // path: ".reply.parent.uri",
412 // });
413
414 const [constellationurl] = useAtom(constellationURLAtom);
415
416 const infinitequeryresults = useInfiniteQuery({
417 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
418 {
419 constellation: constellationurl,
420 method: "/links",
421 target: atUri,
422 collection: "app.bsky.feed.post",
423 path: ".reply.parent.uri",
424 }
425 ),
426 enabled: !!atUri && !!maxReplies && !isQuote,
427 });
428
429 const {
430 data: repliesData,
431 // fetchNextPage,
432 // hasNextPage,
433 // isFetchingNextPage,
434 } = infinitequeryresults;
435
436 // auto-fetch all pages
437 useEffect(() => {
438 if (!maxReplies || isQuote || TEMPLINEAR) return;
439 if (
440 infinitequeryresults.hasNextPage &&
441 !infinitequeryresults.isFetchingNextPage
442 ) {
443 console.log("Fetching the next page...");
444 infinitequeryresults.fetchNextPage();
445 }
446 }, [TEMPLINEAR, infinitequeryresults, isQuote, maxReplies]);
447
448 const replyAturis = repliesData
449 ? repliesData.pages.flatMap((page) =>
450 page
451 ? page.linking_records.map((record) => {
452 const aturi = `at://${record.did}/${record.collection}/${record.rkey}`;
453 return aturi;
454 })
455 : []
456 )
457 : [];
458
459 //const [oldestOpsReply, setOldestOpsReply] = useState<string | undefined>(undefined);
460
461 const { oldestOpsReply, oldestOpsReplyElseNewestNonOpsReply } = (() => {
462 if (isQuote || !replyAturis || replyAturis.length === 0 || !maxReplies)
463 return {
464 oldestOpsReply: undefined,
465 oldestOpsReplyElseNewestNonOpsReply: undefined,
466 };
467
468 const opdid = new AtUri(
469 //postQuery?.value.reply?.root.uri ?? postQuery?.uri ?? atUri
470 atUri
471 ).host;
472
473 const opReplies = replyAturis.filter(
474 (aturi) => new AtUri(aturi).host === opdid
475 );
476
477 if (opReplies.length > 0) {
478 const opreply = opReplies[opReplies.length - 1];
479 //setOldestOpsReply(opreply);
480 return {
481 oldestOpsReply: opreply,
482 oldestOpsReplyElseNewestNonOpsReply: opreply,
483 };
484 } else {
485 return {
486 oldestOpsReply: undefined,
487 oldestOpsReplyElseNewestNonOpsReply: replyAturis[0],
488 };
489 }
490 })();
491
492 // const navigateToProfile = (e: React.MouseEvent) => {
493 // e.stopPropagation();
494 // if (resolved?.did) {
495 // router.navigate({
496 // to: "/profile/$did",
497 // params: { did: resolved.did },
498 // });
499 // }
500 // };
501 if (!postQuery?.value) {
502 // deleted post more often than a non-resolvable post
503 return <></>;
504 }
505
506 return (
507 <>
508 {/* <span>uprrs {maxReplies} {!!maxReplies&&!!oldestOpsReplyElseNewestNonOpsReply ? "true" : "false"}</span> */}
509 <UniversalPostRendererRawRecordShim
510 detailed={detailed}
511 postRecord={postQuery}
512 profileRecord={opProfile}
513 aturi={atUri}
514 resolved={resolved}
515 likesCount={likes}
516 repostsCount={reposts}
517 repliesCount={replies}
518 bottomReplyLine={
519 maxReplies && oldestOpsReplyElseNewestNonOpsReply
520 ? true
521 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply
522 ? false
523 : (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine
524 }
525 topReplyLine={topReplyLine}
526 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder}
527 bottomBorder={
528 maxReplies && oldestOpsReplyElseNewestNonOpsReply
529 ? false
530 : maxReplies === 0
531 ? false
532 : bottomBorder
533 }
534 feedviewpost={feedviewpost}
535 repostedby={repostedby}
536 //style={{...style, background: oldestOpsReply === atUri ? "Red" : undefined}}
537 style={style}
538 ref={ref}
539 dataIndexPropPass={dataIndexPropPass}
540 nopics={nopics}
541 concise={concise}
542 lightboxCallback={lightboxCallback}
543 maxReplies={maxReplies}
544 isQuote={isQuote}
545 />
546 <>
547 {(maxReplies && maxReplies === 0 && replies && replies > 0) ? (
548 <>
549 {/* <div>hello</div> */}
550 <MoreReplies atUri={atUri} />
551 </>
552 ) : (<></>)}
553 </>
554 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
555 <>
556 {/* <span>hello {maxReplies}</span> */}
557 <UniversalPostRendererATURILoader
558 //detailed={detailed}
559 atUri={oldestOpsReplyElseNewestNonOpsReply}
560 bottomReplyLine={(maxReplies ?? 0) > 0}
561 topReplyLine={
562 (!!(maxReplies && maxReplies - 1 === 0) &&
563 !!(replies && replies > 0)) ||
564 !!((maxReplies ?? 0) > 1)
565 }
566 bottomBorder={bottomBorder}
567 feedviewpost={feedviewpost}
568 repostedby={repostedby}
569 style={style}
570 ref={ref}
571 dataIndexPropPass={dataIndexPropPass}
572 nopics={nopics}
573 concise={concise}
574 lightboxCallback={lightboxCallback}
575 maxReplies={
576 maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined
577 }
578 />
579 </>
580 )}
581 </>
582 );
583}
584
585function MoreReplies({ atUri }: { atUri: string }) {
586 const navigate = useNavigate();
587 const aturio = new AtUri(atUri);
588 return (
589 <div
590 onClick={() =>
591 navigate({
592 to: "/profile/$did/post/$rkey",
593 params: { did: aturio.host, rkey: aturio.rkey },
594 })
595 }
596 className="border-b border-gray-300 dark:border-gray-800 flex flex-row px-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors"
597 >
598 <div className="w-[42px] h-12 flex flex-col items-center justify-center">
599 <div
600 style={{
601 width: 2,
602 height: "100%",
603 backgroundImage:
604 "repeating-linear-gradient(to bottom, var(--color-gray-500) 0, var(--color-gray-500) 4px, transparent 4px, transparent 8px)",
605 opacity: 0.5,
606 }}
607 className="dark:bg-[repeating-linear-gradient(to_bottom,var(--color-gray-500)_0,var(--color-gray-400)_4px,transparent_4px,transparent_8px)]"
608 //className="border-gray-400 dark:border-gray-500"
609 />
610 </div>
611
612 <div className="flex items-center pl-3 text-sm text-gray-500 dark:text-gray-400 select-none">
613 More Replies
614 </div>
615 </div>
616 );
617}
618
619function getAvatarUrl(opProfile: any, did: string, cdn: string) {
620 const link = opProfile?.value?.avatar?.ref?.["$link"];
621 if (!link) return null;
622 return `https://${cdn}/img/avatar/plain/${did}/${link}@jpeg`;
623}
624
625export function UniversalPostRendererRawRecordShim({
626 postRecord,
627 profileRecord,
628 aturi,
629 resolved,
630 likesCount,
631 repostsCount,
632 repliesCount,
633 detailed = false,
634 bottomReplyLine = false,
635 topReplyLine = false,
636 bottomBorder = true,
637 feedviewpost = false,
638 repostedby,
639 style,
640 ref,
641 dataIndexPropPass,
642 nopics,
643 concise,
644 lightboxCallback,
645 maxReplies,
646 isQuote,
647}: {
648 postRecord: any;
649 profileRecord: any;
650 aturi: string;
651 resolved: any;
652 likesCount?: number | null;
653 repostsCount?: number | null;
654 repliesCount?: number | null;
655 detailed?: boolean;
656 bottomReplyLine?: boolean;
657 topReplyLine?: boolean;
658 bottomBorder?: boolean;
659 feedviewpost?: boolean;
660 repostedby?: string;
661 style?: React.CSSProperties;
662 ref?: React.Ref<HTMLDivElement>;
663 dataIndexPropPass?: number;
664 nopics?: boolean;
665 concise?: boolean;
666 lightboxCallback?: (d: LightboxProps) => void;
667 maxReplies?: number;
668 isQuote?: boolean;
669}) {
670 // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`);
671 const navigate = useNavigate();
672
673 //const { get, set } = usePersistentStore();
674 // const [hydratedEmbed, setHydratedEmbed] = useState<any>(undefined);
675
676 // useEffect(() => {
677 // const run = async () => {
678 // if (!postRecord?.value?.embed) return;
679 // const embed = postRecord?.value?.embed;
680 // if (!embed || !embed.$type) {
681 // setHydratedEmbed(undefined);
682 // return;
683 // }
684
685 // try {
686 // let result: any;
687
688 // if (embed?.$type === "app.bsky.embed.recordWithMedia") {
689 // const mediaEmbed = embed.media;
690
691 // let hydratedMedia;
692 // if (mediaEmbed?.$type === "app.bsky.embed.images") {
693 // hydratedMedia = hydrateEmbedImages(mediaEmbed, resolved?.did);
694 // } else if (mediaEmbed?.$type === "app.bsky.embed.external") {
695 // hydratedMedia = hydrateEmbedExternal(mediaEmbed, resolved?.did);
696 // } else if (mediaEmbed?.$type === "app.bsky.embed.video") {
697 // hydratedMedia = hydrateEmbedVideo(mediaEmbed, resolved?.did);
698 // } else {
699 // throw new Error("idiot");
700 // }
701 // if (!hydratedMedia) throw new Error("idiot");
702
703 // // hydrate the outer recordWithMedia now using the hydrated media
704 // result = await hydrateEmbedRecordWithMedia(
705 // embed,
706 // resolved?.did,
707 // hydratedMedia,
708 // get,
709 // set,
710 // );
711 // } else {
712 // const hydrated =
713 // embed?.$type === "app.bsky.embed.images"
714 // ? hydrateEmbedImages(embed, resolved?.did)
715 // : embed?.$type === "app.bsky.embed.external"
716 // ? hydrateEmbedExternal(embed, resolved?.did)
717 // : embed?.$type === "app.bsky.embed.video"
718 // ? hydrateEmbedVideo(embed, resolved?.did)
719 // : embed?.$type === "app.bsky.embed.record"
720 // ? hydrateEmbedRecord(embed, resolved?.did, get, set)
721 // : undefined;
722
723 // result = hydrated instanceof Promise ? await hydrated : hydrated;
724 // }
725
726 // // /*mass comment*/ console.log(
727 // String(result) + " hydrateEmbedRecordWithMedia hey hyeh ye",
728 // );
729 // setHydratedEmbed(result);
730 // } catch (e) {
731 // console.error("Error hydrating embed", e);
732 // setHydratedEmbed(undefined);
733 // }
734 // };
735
736 // run();
737 // }, [postRecord, resolved?.did]);
738
739 const {
740 data: hydratedEmbed,
741 isLoading: isEmbedLoading,
742 error: embedError,
743 } = useHydratedEmbed(postRecord?.value?.embed, resolved?.did);
744
745 const [imgcdn] = useAtom(imgCDNAtom);
746
747 const parsedaturi = new AtUri(aturi); //parseAtUri(aturi);
748
749 const fakeprofileviewbasic = React.useMemo<AppBskyActorDefs.ProfileViewBasic>(
750 () => ({
751 did: resolved?.did || "",
752 handle: resolved?.handle || "",
753 displayName: profileRecord?.value?.displayName || "",
754 avatar: getAvatarUrl(profileRecord, resolved?.did, imgcdn) || "",
755 viewer: undefined,
756 labels: profileRecord?.labels || undefined,
757 verification: undefined,
758 }),
759 [imgcdn, profileRecord, resolved?.did, resolved?.handle]
760 );
761
762 const fakeprofileviewdetailed =
763 React.useMemo<AppBskyActorDefs.ProfileViewDetailed>(
764 () => ({
765 ...fakeprofileviewbasic,
766 $type: "app.bsky.actor.defs#profileViewDetailed",
767 description: profileRecord?.value?.description || undefined,
768 }),
769 [fakeprofileviewbasic, profileRecord?.value?.description]
770 );
771
772 const fakepost = React.useMemo<AppBskyFeedDefs.PostView>(
773 () => ({
774 $type: "app.bsky.feed.defs#postView",
775 uri: aturi,
776 cid: postRecord?.cid || "",
777 author: fakeprofileviewbasic,
778 record: postRecord?.value || {},
779 embed: hydratedEmbed ?? undefined,
780 replyCount: repliesCount ?? 0,
781 repostCount: repostsCount ?? 0,
782 likeCount: likesCount ?? 0,
783 quoteCount: 0,
784 indexedAt: postRecord?.value?.createdAt || "",
785 viewer: undefined,
786 labels: postRecord?.labels || undefined,
787 threadgate: undefined,
788 }),
789 [
790 aturi,
791 postRecord?.cid,
792 postRecord?.value,
793 postRecord?.labels,
794 fakeprofileviewbasic,
795 hydratedEmbed,
796 repliesCount,
797 repostsCount,
798 likesCount,
799 ]
800 );
801
802 //const [feedviewpostreplyhandle, setFeedviewpostreplyhandle] = useState<string | undefined>(undefined);
803
804 // useEffect(() => {
805 // if(!feedviewpost) return;
806 // let cancelled = false;
807
808 // const run = async () => {
809 // const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent?.uri;
810 // const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined;
811
812 // if (feedviewpostreplydid) {
813 // const opi = await cachedResolveIdentity({
814 // didOrHandle: feedviewpostreplydid,
815 // get,
816 // set,
817 // });
818
819 // if (!cancelled) {
820 // setFeedviewpostreplyhandle(opi?.handle);
821 // }
822 // }
823 // };
824
825 // run();
826
827 // return () => {
828 // cancelled = true;
829 // };
830 // }, [fakepost, get, set]);
831 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent
832 ?.uri;
833 const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined;
834 const replyhookvalue = useQueryIdentity(
835 feedviewpost ? feedviewpostreplydid : undefined
836 );
837 const feedviewpostreplyhandle = replyhookvalue?.data?.handle;
838
839 const aturirepostbydid = repostedby ? new AtUri(repostedby).host : undefined;
840 const repostedbyhookvalue = useQueryIdentity(
841 repostedby ? aturirepostbydid : undefined
842 );
843 const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle;
844 return (
845 <>
846 {/* <p>
847 {postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)}
848 </p> */}
849 <UniversalPostRenderer
850 expanded={detailed}
851 onPostClick={() =>
852 parsedaturi &&
853 navigate({
854 to: "/profile/$did/post/$rkey",
855 params: { did: parsedaturi.host, rkey: parsedaturi.rkey },
856 })
857 }
858 // onProfileClick={() => parsedaturi && navigate({to: "/profile/$did",
859 // params: {did: parsedaturi.did}
860 // })}
861 onProfileClick={(e) => {
862 e.stopPropagation();
863 if (parsedaturi) {
864 navigate({
865 to: "/profile/$did",
866 params: { did: parsedaturi.host },
867 });
868 }
869 }}
870 post={fakepost}
871 uprrrsauthor={fakeprofileviewdetailed}
872 salt={aturi}
873 bottomReplyLine={bottomReplyLine}
874 topReplyLine={topReplyLine}
875 bottomBorder={bottomBorder}
876 //extraOptionalItemInfo={{reply: postRecord?.value?.reply as AppBskyFeedDefs.ReplyRef, post: fakepost}}
877 feedviewpostreplyhandle={feedviewpostreplyhandle}
878 repostedby={feedviewpostrepostedbyhandle}
879 style={style}
880 ref={ref}
881 dataIndexPropPass={dataIndexPropPass}
882 nopics={nopics}
883 concise={concise}
884 lightboxCallback={lightboxCallback}
885 maxReplies={maxReplies}
886 isQuote={isQuote}
887 />
888 </>
889 );
890}
891
892// export function parseAtUri(
893// atUri: string
894// ): { did: string; collection: string; rkey: string } | null {
895// const PREFIX = "at://";
896// if (!atUri.startsWith(PREFIX)) {
897// return null;
898// }
899
900// const parts = atUri.slice(PREFIX.length).split("/");
901
902// if (parts.length !== 3) {
903// return null;
904// }
905
906// const [did, collection, rkey] = parts;
907
908// if (!did || !collection || !rkey) {
909// return null;
910// }
911
912// return { did, collection, rkey };
913// }
914
915export function MdiCommentOutline(props: SVGProps<SVGSVGElement>) {
916 return (
917 <svg
918 xmlns="http://www.w3.org/2000/svg"
919 width={16}
920 height={16}
921 viewBox="0 0 24 24"
922 {...props}
923 >
924 <path
925 fill="var(--color-gray-400)"
926 d="M9 22a1 1 0 0 1-1-1v-3H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6.1l-3.7 3.71c-.2.19-.45.29-.7.29zm1-6v3.08L13.08 16H20V4H4v12z"
927 ></path>
928 </svg>
929 );
930}
931
932export function MdiRepeat(props: SVGProps<SVGSVGElement>) {
933 return (
934 <svg
935 xmlns="http://www.w3.org/2000/svg"
936 width={16}
937 height={16}
938 viewBox="0 0 24 24"
939 {...props}
940 >
941 <path
942 fill="var(--color-gray-400)"
943 d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
944 ></path>
945 </svg>
946 );
947}
948
949export function MdiRepeatGreen(props: SVGProps<SVGSVGElement>) {
950 return (
951 <svg
952 xmlns="http://www.w3.org/2000/svg"
953 width={16}
954 height={16}
955 viewBox="0 0 24 24"
956 {...props}
957 >
958 <path
959 fill="#5CEFAA"
960 d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
961 ></path>
962 </svg>
963 );
964}
965
966export function MdiCardsHeart(props: SVGProps<SVGSVGElement>) {
967 return (
968 <svg
969 xmlns="http://www.w3.org/2000/svg"
970 width={16}
971 height={16}
972 viewBox="0 0 24 24"
973 {...props}
974 >
975 <path
976 fill="#EC4899"
977 d="m12 21.35l-1.45-1.32C5.4 15.36 2 12.27 2 8.5C2 5.41 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08C13.09 3.81 14.76 3 16.5 3C19.58 3 22 5.41 22 8.5c0 3.77-3.4 6.86-8.55 11.53z"
978 ></path>
979 </svg>
980 );
981}
982
983export function MdiCardsHeartOutline(props: SVGProps<SVGSVGElement>) {
984 return (
985 <svg
986 xmlns="http://www.w3.org/2000/svg"
987 width={16}
988 height={16}
989 viewBox="0 0 24 24"
990 {...props}
991 >
992 <path
993 fill="var(--color-gray-400)"
994 d="m12.1 18.55l-.1.1l-.11-.1C7.14 14.24 4 11.39 4 8.5C4 6.5 5.5 5 7.5 5c1.54 0 3.04 1 3.57 2.36h1.86C13.46 6 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5c0 2.89-3.14 5.74-7.9 10.05M16.5 3c-1.74 0-3.41.81-4.5 2.08C10.91 3.81 9.24 3 7.5 3C4.42 3 2 5.41 2 8.5c0 3.77 3.4 6.86 8.55 11.53L12 21.35l1.45-1.32C18.6 15.36 22 12.27 22 8.5C22 5.41 19.58 3 16.5 3"
995 ></path>
996 </svg>
997 );
998}
999
1000export function MdiShareVariant(props: SVGProps<SVGSVGElement>) {
1001 return (
1002 <svg
1003 xmlns="http://www.w3.org/2000/svg"
1004 width={16}
1005 height={16}
1006 viewBox="0 0 24 24"
1007 {...props}
1008 >
1009 <path
1010 fill="var(--color-gray-400)"
1011 d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 0 0 3-3a3 3 0 0 0-3-3a3 3 0 0 0-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66c0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91A2.92 2.92 0 0 0 18 16.08"
1012 ></path>
1013 </svg>
1014 );
1015}
1016
1017export function MdiMoreHoriz(props: SVGProps<SVGSVGElement>) {
1018 return (
1019 <svg
1020 xmlns="http://www.w3.org/2000/svg"
1021 width={16}
1022 height={16}
1023 viewBox="0 0 24 24"
1024 {...props}
1025 >
1026 <path
1027 fill="var(--color-gray-400)"
1028 d="M16 12a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2m-6 0a2 2 0 0 1 2-2a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2"
1029 ></path>
1030 </svg>
1031 );
1032}
1033
1034export function MdiGlobe(props: SVGProps<SVGSVGElement>) {
1035 return (
1036 <svg
1037 xmlns="http://www.w3.org/2000/svg"
1038 width={12}
1039 height={12}
1040 viewBox="0 0 24 24"
1041 {...props}
1042 >
1043 <path
1044 fill="var(--color-gray-400)"
1045 d="M17.9 17.39c-.26-.8-1.01-1.39-1.9-1.39h-1v-3a1 1 0 0 0-1-1H8v-2h2a1 1 0 0 0 1-1V7h2a2 2 0 0 0 2-2v-.41a7.984 7.984 0 0 1 2.9 12.8M11 19.93c-3.95-.49-7-3.85-7-7.93c0-.62.08-1.22.21-1.79L9 15v1a2 2 0 0 0 2 2m1-16A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2"
1046 ></path>
1047 </svg>
1048 );
1049}
1050
1051export function MdiVerified(props: SVGProps<SVGSVGElement>) {
1052 return (
1053 <svg
1054 xmlns="http://www.w3.org/2000/svg"
1055 width={16}
1056 height={16}
1057 viewBox="0 0 24 24"
1058 {...props}
1059 >
1060 <path
1061 fill="#1297ff"
1062 d="m23 12l-2.44-2.78l.34-3.68l-3.61-.82l-1.89-3.18L12 3L8.6 1.54L6.71 4.72l-3.61.81l.34 3.68L1 12l2.44 2.78l-.34 3.69l3.61.82l1.89 3.18L12 21l3.4 1.46l1.89-3.18l3.61-.82l-.34-3.68zm-13 5l-4-4l1.41-1.41L10 14.17l6.59-6.59L18 9z"
1063 ></path>
1064 </svg>
1065 );
1066}
1067
1068export function MdiReply(props: SVGProps<SVGSVGElement>) {
1069 return (
1070 <svg
1071 xmlns="http://www.w3.org/2000/svg"
1072 width={14}
1073 height={14}
1074 viewBox="0 0 24 24"
1075 {...props}
1076 >
1077 <path
1078 fill="var(--color-gray-400)"
1079 d="M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11"
1080 ></path>
1081 </svg>
1082 );
1083}
1084
1085export function LineMdLoadingLoop(props: SVGProps<SVGSVGElement>) {
1086 return (
1087 <svg
1088 xmlns="http://www.w3.org/2000/svg"
1089 width={24}
1090 height={24}
1091 viewBox="0 0 24 24"
1092 {...props}
1093 >
1094 <path
1095 fill="none"
1096 stroke="#1297ff"
1097 strokeDasharray={16}
1098 strokeDashoffset={16}
1099 strokeLinecap="round"
1100 strokeLinejoin="round"
1101 strokeWidth={2}
1102 d="M12 3c4.97 0 9 4.03 9 9"
1103 >
1104 <animate
1105 fill="freeze"
1106 attributeName="stroke-dashoffset"
1107 dur="0.2s"
1108 values="16;0"
1109 ></animate>
1110 <animateTransform
1111 attributeName="transform"
1112 dur="1.5s"
1113 repeatCount="indefinite"
1114 type="rotate"
1115 values="0 12 12;360 12 12"
1116 ></animateTransform>
1117 </path>
1118 </svg>
1119 );
1120}
1121
1122export function MdiRepost(props: SVGProps<SVGSVGElement>) {
1123 return (
1124 <svg
1125 xmlns="http://www.w3.org/2000/svg"
1126 width={14}
1127 height={14}
1128 viewBox="0 0 24 24"
1129 {...props}
1130 >
1131 <path
1132 fill="var(--color-gray-400)"
1133 d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z"
1134 ></path>
1135 </svg>
1136 );
1137}
1138
1139export function MdiRepeatVariant(props: SVGProps<SVGSVGElement>) {
1140 return (
1141 <svg
1142 xmlns="http://www.w3.org/2000/svg"
1143 width={14}
1144 height={14}
1145 viewBox="0 0 24 24"
1146 {...props}
1147 >
1148 <path
1149 fill="var(--color-gray-400)"
1150 d="M6 5.75L10.25 10H7v6h6.5l2 2H7a2 2 0 0 1-2-2v-6H1.75zm12 12.5L13.75 14H17V8h-6.5l-2-2H17a2 2 0 0 1 2 2v6h3.25z"
1151 ></path>
1152 </svg>
1153 );
1154}
1155
1156export function MdiPlayCircle(props: SVGProps<SVGSVGElement>) {
1157 return (
1158 <svg
1159 xmlns="http://www.w3.org/2000/svg"
1160 width={64}
1161 height={64}
1162 viewBox="0 0 24 24"
1163 {...props}
1164 >
1165 <path
1166 fill="#edf2f5"
1167 d="M10 16.5v-9l6 4.5M12 2A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2"
1168 ></path>
1169 </svg>
1170 );
1171}
1172
1173/* what imported from testfront */
1174//import Masonry from "@mui/lab/Masonry";
1175import {
1176 type $Typed,
1177 AppBskyActorDefs,
1178 AppBskyEmbedDefs,
1179 AppBskyEmbedExternal,
1180 AppBskyEmbedImages,
1181 AppBskyEmbedRecord,
1182 AppBskyEmbedRecordWithMedia,
1183 AppBskyEmbedVideo,
1184 AppBskyFeedDefs,
1185 AppBskyFeedPost,
1186 AppBskyGraphDefs,
1187 AtUri,
1188 type Facet,
1189 //AppBskyLabelerDefs,
1190 //AtUri,
1191 //ComAtprotoRepoStrongRef,
1192 ModerationDecision,
1193} from "@atproto/api";
1194import type {
1195 //BlockedPost,
1196 FeedViewPost,
1197 //NotFoundPost,
1198 PostView,
1199 //ThreadViewPost,
1200} from "@atproto/api/dist/client/types/app/bsky/feed/defs";
1201import { useInfiniteQuery } from "@tanstack/react-query";
1202import { useEffect, useRef, useState } from "react";
1203import ReactPlayer from "react-player";
1204
1205import defaultpfp from "~/../public/favicon.png";
1206import { useAuth } from "~/providers/UnifiedAuthProvider";
1207import { FeedItemRenderAturiLoader, FollowButton, Mutual } from "~/routes/profile.$did";
1208import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
1209// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed";
1210// import type {
1211// ViewRecord,
1212// ViewNotFound,
1213// ViewBlocked,
1214// ViewDetached,
1215// } from "@atproto/api/dist/client/types/app/bsky/embed/record";
1216//import type { MasonryItemData } from "./onemason/masonry.types";
1217//import { MasonryLayout } from "./onemason/MasonryLayout";
1218// const agent = new AtpAgent({
1219// service: 'https://public.api.bsky.app'
1220// })
1221type HitSlopButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
1222 hitSlop?: number;
1223};
1224
1225const HitSlopButtonCustom: React.FC<HitSlopButtonProps> = ({
1226 children,
1227 hitSlop = 8,
1228 style,
1229 ...rest
1230}) => (
1231 <button
1232 {...rest}
1233 style={{
1234 position: "relative",
1235 background: "none",
1236 border: "none",
1237 padding: 0,
1238 cursor: "pointer",
1239 ...style,
1240 }}
1241 >
1242 {/* Invisible hit slop area */}
1243 <span
1244 style={{
1245 position: "absolute",
1246 top: -hitSlop,
1247 left: -hitSlop,
1248 right: -hitSlop,
1249 bottom: -hitSlop,
1250 }}
1251 />
1252 {/* Actual button content stays positioned normally */}
1253 <span style={{ position: "relative", zIndex: 1 }}>{children}</span>
1254 </button>
1255);
1256
1257const HitSlopButton = ({
1258 onClick,
1259 children,
1260 style = {},
1261 ...rest
1262}: React.HTMLAttributes<HTMLSpanElement> & {
1263 onClick?: (e: React.MouseEvent) => void;
1264 children: React.ReactNode;
1265 style?: React.CSSProperties;
1266}) => (
1267 <span
1268 style={{ position: "relative", display: "inline-block", cursor: "pointer" }}
1269 >
1270 <span
1271 style={{
1272 position: "absolute",
1273 top: -8,
1274 left: -8,
1275 right: -8,
1276 bottom: -8,
1277 zIndex: 0,
1278 }}
1279 onClick={(e) => {
1280 e.stopPropagation();
1281 onClick?.(e);
1282 }}
1283 />
1284 <span
1285 style={{
1286 ...style,
1287 position: "relative",
1288 zIndex: 1,
1289 pointerEvents: "none",
1290 }}
1291 {...rest}
1292 >
1293 {children}
1294 </span>
1295 </span>
1296);
1297
1298const btnstyle = {
1299 display: "flex",
1300 gap: 4,
1301 cursor: "pointer",
1302 alignItems: "center",
1303 fontSize: 14,
1304};
1305function randomString(length = 8) {
1306 const chars =
1307 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
1308 return Array.from(
1309 { length },
1310 () => chars[Math.floor(Math.random() * chars.length)]
1311 ).join("");
1312}
1313
1314function UniversalPostRenderer({
1315 post,
1316 uprrrsauthor,
1317 //setMainItem,
1318 //isMainItem,
1319 onPostClick,
1320 onProfileClick,
1321 expanded,
1322 //expanded,
1323 isQuote,
1324 //isQuote,
1325 extraOptionalItemInfo,
1326 bottomReplyLine,
1327 topReplyLine,
1328 salt,
1329 bottomBorder = true,
1330 feedviewpostreplyhandle,
1331 depth = 0,
1332 repostedby,
1333 style,
1334 ref,
1335 dataIndexPropPass,
1336 nopics,
1337 concise,
1338 lightboxCallback,
1339 maxReplies,
1340}: {
1341 post: PostView;
1342 uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed;
1343 // optional for now because i havent ported every use to this yet
1344 // setMainItem?: React.Dispatch<
1345 // React.SetStateAction<AppBskyFeedDefs.FeedViewPost>
1346 // >;
1347 //isMainItem?: boolean;
1348 onPostClick?: (e: React.MouseEvent) => void;
1349 onProfileClick?: (e: React.MouseEvent) => void;
1350 expanded?: boolean;
1351 isQuote?: boolean;
1352 extraOptionalItemInfo?: FeedViewPost;
1353 bottomReplyLine?: boolean;
1354 topReplyLine?: boolean;
1355 salt: string;
1356 bottomBorder?: boolean;
1357 feedviewpostreplyhandle?: string;
1358 depth?: number;
1359 repostedby?: string;
1360 style?: React.CSSProperties;
1361 ref?: React.Ref<HTMLDivElement>;
1362 dataIndexPropPass?: number;
1363 nopics?: boolean;
1364 concise?: boolean;
1365 lightboxCallback?: (d: LightboxProps) => void;
1366 maxReplies?: number;
1367}) {
1368 const parsed = new AtUri(post.uri);
1369 const navigate = useNavigate();
1370 const [likedPosts, setLikedPosts] = useAtom(likedPostsAtom);
1371 const [hasRetweeted, setHasRetweeted] = useState<boolean>(
1372 post.viewer?.repost ? true : false
1373 );
1374 const [hasLiked, setHasLiked] = useState<boolean>(
1375 post.uri in likedPosts || post.viewer?.like ? true : false
1376 );
1377 const [, setComposerPost] = useAtom(composerAtom);
1378 const { agent } = useAuth();
1379 const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like);
1380 const [retweetUri, setRetweetUri] = useState<string | undefined>(
1381 post.viewer?.repost
1382 );
1383
1384 const likeOrUnlikePost = async () => {
1385 const newLikedPosts = { ...likedPosts };
1386 if (!agent) {
1387 console.error("Agent is null or undefined");
1388 return;
1389 }
1390 if (hasLiked) {
1391 if (post.uri in likedPosts) {
1392 const likeUri = likedPosts[post.uri];
1393 setLikeUri(likeUri);
1394 }
1395 if (likeUri) {
1396 await agent.deleteLike(likeUri);
1397 setHasLiked(false);
1398 delete newLikedPosts[post.uri];
1399 }
1400 } else {
1401 const { uri } = await agent.like(post.uri, post.cid);
1402 setLikeUri(uri);
1403 setHasLiked(true);
1404 newLikedPosts[post.uri] = uri;
1405 }
1406 setLikedPosts(newLikedPosts);
1407 };
1408
1409 const repostOrUnrepostPost = async () => {
1410 if (!agent) {
1411 console.error("Agent is null or undefined");
1412 return;
1413 }
1414 if (hasRetweeted) {
1415 if (retweetUri) {
1416 await agent.deleteRepost(retweetUri);
1417 setHasRetweeted(false);
1418 }
1419 } else {
1420 const { uri } = await agent.repost(post.uri, post.cid);
1421 setRetweetUri(uri);
1422 setHasRetweeted(true);
1423 }
1424 };
1425
1426 const isRepost = repostedby
1427 ? repostedby
1428 : extraOptionalItemInfo
1429 ? AppBskyFeedDefs.isReasonRepost(extraOptionalItemInfo.reason)
1430 ? extraOptionalItemInfo.reason?.by.displayName
1431 : undefined
1432 : undefined;
1433 const isReply = extraOptionalItemInfo
1434 ? extraOptionalItemInfo.reply
1435 : undefined;
1436
1437 const emergencySalt = randomString();
1438 const fedi = (post.record as { bridgyOriginalText?: string })
1439 .bridgyOriginalText;
1440
1441 /* fuck you */
1442 const isMainItem = false;
1443 const setMainItem = (any: any) => {};
1444 // eslint-disable-next-line react-hooks/refs
1445 console.log("Received ref in UniversalPostRenderer:", ref);
1446 return (
1447 <div ref={ref} style={style} data-index={dataIndexPropPass}>
1448 <div
1449 //ref={ref}
1450 key={salt + "-" + (post.uri || emergencySalt)}
1451 onClick={
1452 isMainItem
1453 ? onPostClick
1454 : setMainItem
1455 ? onPostClick
1456 ? (e) => {
1457 setMainItem({ post: post });
1458 onPostClick(e);
1459 }
1460 : () => {
1461 setMainItem({ post: post });
1462 }
1463 : undefined
1464 }
1465 style={{
1466 //...style,
1467 //border: "1px solid #e1e8ed",
1468 //borderRadius: 12,
1469 opacity: "1 !important",
1470 background: "transparent",
1471 paddingLeft: isQuote ? 12 : 16,
1472 paddingRight: isQuote ? 12 : 16,
1473 //paddingTop: 16,
1474 paddingTop: isRepost ? 10 : isQuote ? 12 : topReplyLine ? 8 : 16,
1475 //paddingBottom: bottomReplyLine ? 0 : 16,
1476 paddingBottom: 0,
1477 fontFamily: "system-ui, sans-serif",
1478 //boxShadow: "0 2px 8px rgba(0,0,0,0.04)",
1479 position: "relative",
1480 // dont cursor: "pointer",
1481 borderBottomWidth: bottomBorder ? (isQuote ? 0 : 1) : 0,
1482 }}
1483 className="border-gray-300 dark:border-gray-800"
1484 >
1485 {isRepost && (
1486 <div
1487 style={{
1488 marginLeft: 36,
1489 display: "flex",
1490 borderRadius: 12,
1491 paddingBottom: "calc(22px - 1rem)",
1492 fontSize: 14,
1493 maxHeight: "1rem",
1494 justifyContent: "flex-start",
1495 //color: theme.textSecondary,
1496 gap: 4,
1497 alignItems: "center",
1498 }}
1499 className="text-gray-500 dark:text-gray-400"
1500 >
1501 <MdiRepost /> Reposted by @{isRepost}{" "}
1502 </div>
1503 )}
1504 {!isQuote && (
1505 <div
1506 style={{
1507 opacity:
1508 topReplyLine || isReply /*&& (true || expanded)*/ ? 0.5 : 0,
1509 position: "absolute",
1510 top: 0,
1511 left: 36, // why 36 ???
1512 //left: 16 + (42 / 2),
1513 width: 2,
1514 //height: "100%",
1515 height: isRepost
1516 ? "calc(16px + 1rem - 6px)"
1517 : topReplyLine
1518 ? 8 - 6
1519 : 16 - 6,
1520 // background: theme.textSecondary,
1521 //opacity: 0.5,
1522 // no flex here
1523 }}
1524 className="bg-gray-500 dark:bg-gray-400"
1525 />
1526 )}
1527 <HoverCard.Root>
1528 <HoverCard.Trigger asChild>
1529 <div
1530 className={`absolute`}
1531 style={{
1532 top: isRepost
1533 ? "calc(16px + 1rem)"
1534 : isQuote
1535 ? 12
1536 : topReplyLine
1537 ? 8
1538 : 16,
1539 left: isQuote ? 12 : 16,
1540 }}
1541 onClick={onProfileClick}
1542 >
1543 <img
1544 src={post.author.avatar || defaultpfp}
1545 alt="avatar"
1546 className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
1547 style={{
1548 width: isQuote ? 16 : 42,
1549 height: isQuote ? 16 : 42,
1550 }}
1551 />
1552 </div>
1553 </HoverCard.Trigger>
1554 <HoverCard.Portal>
1555 <HoverCard.Content
1556 className="rounded-md p-4 w-72 bg-gray-50 dark:bg-gray-900 shadow-lg border border-gray-300 dark:border-gray-800 animate-slide-fade z-50"
1557 side={"bottom"}
1558 sideOffset={5}
1559 onClick={onProfileClick}
1560 >
1561 <div className="flex flex-col gap-2">
1562 <div className="flex flex-row">
1563 <img
1564 src={post.author.avatar || defaultpfp}
1565 alt="avatar"
1566 className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600"
1567 />
1568 <div className=" flex-1 flex flex-row align-middle justify-end">
1569 <FollowButton targetdidorhandle={post.author.did} />
1570 </div>
1571 </div>
1572 <div className="flex flex-col gap-3">
1573 <div>
1574 <div className="text-gray-900 dark:text-gray-100 font-medium text-md">
1575 {post.author.displayName || post.author.handle}{" "}
1576 </div>
1577 <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1">
1578 <Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "}
1579 </div>
1580 </div>
1581 {uprrrsauthor?.description && (
1582 <div className="text-gray-700 dark:text-gray-300 text-sm text-left break-words line-clamp-3">
1583 {uprrrsauthor.description}
1584 </div>
1585 )}
1586 {/* <div className="flex gap-4">
1587 <div className="flex gap-1">
1588 <div className="font-medium text-gray-900 dark:text-gray-100">
1589 0
1590 </div>
1591 <div className="text-gray-500 dark:text-gray-400">
1592 Following
1593 </div>
1594 </div>
1595 <div className="flex gap-1">
1596 <div className="font-medium text-gray-900 dark:text-gray-100">
1597 2,900
1598 </div>
1599 <div className="text-gray-500 dark:text-gray-400">
1600 Followers
1601 </div>
1602 </div>
1603 </div> */}
1604 </div>
1605 </div>
1606
1607 {/* <HoverCard.Arrow className="fill-gray-50 dark:fill-gray-900" /> */}
1608 </HoverCard.Content>
1609 </HoverCard.Portal>
1610 </HoverCard.Root>
1611
1612 <div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}>
1613 <div
1614 style={{
1615 display: "flex",
1616 flexDirection: "column",
1617 alignSelf: "stretch",
1618 alignItems: "center",
1619 overflow: "hidden",
1620 width: expanded || isQuote ? 0 : "auto",
1621 marginRight: expanded || isQuote ? 0 : 12,
1622 }}
1623 >
1624 {/* dummy for later use */}
1625 <div style={{ width: 42, height: 42 + 6, minHeight: 42 + 6 }} />
1626 {/* reply line !!!! bottomReplyLine */}
1627 {bottomReplyLine && (
1628 <div
1629 style={{
1630 width: 2,
1631 height: "100%",
1632 //background: theme.textSecondary,
1633 opacity: 0.5,
1634 // no flex here
1635 //color: "Red",
1636 //zIndex: 99
1637 }}
1638 className="bg-gray-500 dark:bg-gray-400"
1639 />
1640 )}
1641 {/* <div
1642 layout
1643 transition={{ duration: 0.2 }}
1644 animate={{ height: expanded ? 0 : '100%' }}
1645 style={{
1646 width: 2.4,
1647 background: theme.border,
1648 // no flex here
1649 }}
1650 /> */}
1651 </div>
1652 <div style={{ flex: 1, maxWidth: "100%" }}>
1653 <div
1654 style={{
1655 display: "flex",
1656 flexDirection: "row",
1657 alignItems: "center",
1658 flexWrap: "nowrap",
1659 maxWidth: `calc(100% - ${!expanded ? (isQuote ? 26 : 0) : 54}px)`,
1660 width: `calc(100% - ${!expanded ? (isQuote ? 26 : 0) : 54}px)`,
1661 marginLeft: !expanded ? (isQuote ? 26 : 0) : 54,
1662 marginBottom: !expanded ? 4 : 6,
1663 }}
1664 >
1665 <div
1666 style={{
1667 display: "flex",
1668 //overflow: "hidden", // hey why is overflow hidden unapplied
1669 overflow: "hidden",
1670 textOverflow: "ellipsis",
1671 flexShrink: 1,
1672 flexGrow: 1,
1673 flexBasis: 0,
1674 width: 0,
1675 gap: expanded ? 0 : 6,
1676 alignItems: expanded ? "flex-start" : "center",
1677 flexDirection: expanded ? "column" : "row",
1678 height: expanded ? 42 : "1rem",
1679 }}
1680 >
1681 <span
1682 style={{
1683 display: "flex",
1684 fontWeight: 700,
1685 fontSize: 16,
1686 overflow: "hidden",
1687 textOverflow: "ellipsis",
1688 whiteSpace: "nowrap",
1689 flexShrink: 1,
1690 minWidth: 0,
1691 gap: 4,
1692 alignItems: "center",
1693 //color: theme.text,
1694 }}
1695 className="text-gray-900 dark:text-gray-100"
1696 >
1697 {/* verified checkmark */}
1698 {post.author.displayName || post.author.handle}{" "}
1699 {post.author.verification?.verifiedStatus == "valid" && (
1700 <MdiVerified />
1701 )}
1702 </span>
1703
1704 <span
1705 style={{
1706 //color: theme.textSecondary,
1707 fontSize: 16,
1708 overflowX: "hidden",
1709 textOverflow: "ellipsis",
1710 whiteSpace: "nowrap",
1711 flexShrink: 1,
1712 flexGrow: 0,
1713 minWidth: 0,
1714 }}
1715 className="text-gray-500 dark:text-gray-400"
1716 >
1717 @{post.author.handle}
1718 </span>
1719 </div>
1720 <div
1721 style={{
1722 display: "flex",
1723 alignItems: "center",
1724 height: "1rem",
1725 }}
1726 >
1727 <span
1728 style={{
1729 //color: theme.textSecondary,
1730 fontSize: 16,
1731 marginLeft: 8,
1732 whiteSpace: "nowrap",
1733 flexShrink: 0,
1734 maxWidth: "100%",
1735 }}
1736 className="text-gray-500 dark:text-gray-400"
1737 >
1738 · {/* time placeholder */}
1739 {shortTimeAgo(post.indexedAt)}
1740 </span>
1741 </div>
1742 </div>
1743 {/* reply indicator */}
1744 {!!feedviewpostreplyhandle && (
1745 <div
1746 style={{
1747 display: "flex",
1748 borderRadius: 12,
1749 paddingBottom: 2,
1750 fontSize: 14,
1751 justifyContent: "flex-start",
1752 //color: theme.textSecondary,
1753 gap: 4,
1754 alignItems: "center",
1755 //marginLeft: 36,
1756 height:
1757 !(expanded || isQuote) && !!feedviewpostreplyhandle
1758 ? "1rem"
1759 : 0,
1760 opacity:
1761 !(expanded || isQuote) && !!feedviewpostreplyhandle ? 1 : 0,
1762 }}
1763 className="text-gray-500 dark:text-gray-400"
1764 >
1765 <MdiReply /> Reply to @{feedviewpostreplyhandle}
1766 </div>
1767 )}
1768 <div
1769 style={{
1770 fontSize: 16,
1771 marginBottom: !post.embed || concise ? 0 : 8,
1772 whiteSpace: "pre-wrap",
1773 textAlign: "left",
1774 overflowWrap: "anywhere",
1775 wordBreak: "break-word",
1776 ...(concise && {
1777 display: "-webkit-box",
1778 WebkitBoxOrient: "vertical",
1779 WebkitLineClamp: 2,
1780 overflow: "hidden",
1781 }),
1782 }}
1783 className="text-gray-900 dark:text-gray-100"
1784 >
1785 {fedi ? (
1786 <>
1787 <span
1788 className="dangerousFediContent"
1789 dangerouslySetInnerHTML={{
1790 __html: DOMPurify.sanitize(fedi),
1791 }}
1792 />
1793 </>
1794 ) : (
1795 <>
1796 {renderTextWithFacets({
1797 text: (post.record as { text?: string }).text ?? "",
1798 facets: (post.record.facets as Facet[]) ?? [],
1799 navigate: navigate,
1800 })}
1801 </>
1802 )}
1803 </div>
1804 {post.embed && depth < 1 && !concise ? (
1805 <PostEmbeds
1806 embed={post.embed}
1807 //moderation={moderation}
1808 viewContext={PostEmbedViewContext.Feed}
1809 salt={salt}
1810 navigate={navigate}
1811 postid={{ did: post.author.did, rkey: parsed.rkey }}
1812 nopics={nopics}
1813 lightboxCallback={lightboxCallback}
1814 />
1815 ) : null}
1816 {post.embed && depth > 0 && (
1817 /* pretty bad hack imo. its trying to sync up with how the embed shim doesnt
1818 hydrate embeds this deep but the connection here is implicit
1819 todo: idk make this a real part of the embed shim so its not implicit */
1820 <>
1821 <div className="border-gray-300 dark:border-gray-800 p-3 rounded-xl border italic text-gray-400 text-[14px]">
1822 (there is an embed here thats too deep to render)
1823 </div>
1824 </>
1825 )}
1826 <div style={{ paddingTop: post.embed && !concise && depth < 1 ? 4 : 0 }}>
1827 <>
1828 {expanded && (
1829 <div
1830 style={{
1831 overflow: "hidden",
1832 //color: theme.textSecondary,
1833 fontSize: 14,
1834 display: "flex",
1835 borderBottomStyle: "solid",
1836 //borderBottomColor: theme.border,
1837 //background: "#f00",
1838 // height: "1rem",
1839 paddingTop: 4,
1840 paddingBottom: 8,
1841 borderBottomWidth: 1,
1842 marginBottom: 8,
1843 }} // important for height animation
1844 className="text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-800 was7"
1845 >
1846 {fullDateTimeFormat(post.indexedAt)}
1847 </div>
1848 )}
1849 </>
1850 {!isQuote && (
1851 <div
1852 style={{
1853 display: "flex",
1854 gap: 32,
1855 paddingTop: 8,
1856 //color: theme.textSecondary,
1857 fontSize: 15,
1858 justifyContent: "space-between",
1859 //background: "#0f0",
1860 }}
1861 className="text-gray-500 dark:text-gray-400"
1862 >
1863 <HitSlopButton
1864 onClick={() => {
1865 setComposerPost({ kind: "reply", parent: post.uri });
1866 }}
1867 style={{
1868 ...btnstyle,
1869 }}
1870 >
1871 <MdiCommentOutline />
1872 {post.replyCount}
1873 </HitSlopButton>
1874 <DropdownMenu.Root modal={false}>
1875 <DropdownMenu.Trigger asChild>
1876 <div
1877 style={{
1878 ...btnstyle,
1879 ...(hasRetweeted ? { color: "#5CEFAA" } : {}),
1880 }}
1881 aria-label="Repost or quote post"
1882 >
1883 {hasRetweeted ? <MdiRepeatGreen /> : <MdiRepeat />}
1884 {post.repostCount ?? 0}
1885 </div>
1886 </DropdownMenu.Trigger>
1887
1888 <DropdownMenu.Portal>
1889 <DropdownMenu.Content
1890 align="start"
1891 sideOffset={5}
1892 className="bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-32 z-50 overflow-hidden"
1893 >
1894 <DropdownMenu.Item
1895 onSelect={repostOrUnrepostPost}
1896 className="px-3 py-2 text-sm flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-700"
1897 >
1898 <MdiRepeat
1899 className={hasRetweeted ? "text-green-400" : ""}
1900 />
1901 <span>{hasRetweeted ? "Undo Repost" : "Repost"}</span>
1902 </DropdownMenu.Item>
1903
1904 <DropdownMenu.Item
1905 onSelect={() => {
1906 setComposerPost({
1907 kind: "quote",
1908 subject: post.uri,
1909 });
1910 }}
1911 className="px-3 py-2 text-sm flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-700"
1912 >
1913 {/* You might want a specific quote icon here */}
1914 <MdiCommentOutline />
1915 <span>Quote</span>
1916 </DropdownMenu.Item>
1917 </DropdownMenu.Content>
1918 </DropdownMenu.Portal>
1919 </DropdownMenu.Root>
1920 <HitSlopButton
1921 onClick={() => {
1922 likeOrUnlikePost();
1923 }}
1924 style={{
1925 ...btnstyle,
1926 ...(hasLiked ? { color: "#EC4899" } : {}),
1927 }}
1928 >
1929 {hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
1930 {(post.likeCount || 0) + (hasLiked ? 1 : 0)}
1931 </HitSlopButton>
1932 <div style={{ display: "flex", gap: 8 }}>
1933 <HitSlopButton
1934 onClick={async (e) => {
1935 e.stopPropagation();
1936 try {
1937 await navigator.clipboard.writeText(
1938 "https://bsky.app" +
1939 "/profile/" +
1940 post.author.handle +
1941 "/post/" +
1942 post.uri.split("/").pop()
1943 );
1944 } catch (_e) {
1945 // idk
1946 }
1947 }}
1948 style={{
1949 ...btnstyle,
1950 }}
1951 >
1952 <MdiShareVariant />
1953 </HitSlopButton>
1954 <span style={btnstyle}>
1955 <MdiMoreHoriz />
1956 </span>
1957 </div>
1958 </div>
1959 )}
1960 </div>
1961 <div
1962 style={{
1963 //height: bottomReplyLine ? 16 : 0
1964 height: isQuote ? 12 : 16,
1965 }}
1966 />
1967 </div>
1968 </div>
1969 </div>
1970 </div>
1971 );
1972}
1973
1974const fullDateTimeFormat = (iso: string) => {
1975 const date = new Date(iso);
1976 return date.toLocaleString("en-US", {
1977 month: "long",
1978 day: "numeric",
1979 year: "numeric",
1980 hour: "numeric",
1981 minute: "2-digit",
1982 hour12: true,
1983 });
1984};
1985const shortTimeAgo = (iso: string) => {
1986 const diff = Date.now() - new Date(iso).getTime();
1987 const mins = Math.floor(diff / 60000);
1988 if (mins < 1) return "now";
1989 if (mins < 60) return `${mins}m`;
1990 const hrs = Math.floor(mins / 60);
1991 if (hrs < 24) return `${hrs}h`;
1992 const days = Math.floor(hrs / 24);
1993 return `${days}d`;
1994};
1995
1996// const toAtUri = (url: string) =>
1997// url
1998// .replace("https://bsky.app/profile/", "at://")
1999// .replace("/feed/", "/app.bsky.feed.generator/");
2000
2001// function PostSizedElipsis() {
2002// return (
2003// <div
2004// style={{ display: "flex", flexDirection: "row", alignItems: "center" }}
2005// >
2006// <div
2007// style={{
2008// width: 2,
2009// height: 40,
2010// //background: theme.textSecondary,
2011// background: `repeating-linear-gradient(to bottom, var(--color-gray-400) 0px, var(--color-gray-400) 6px, transparent 6px, transparent 10px)`,
2012// backgroundSize: "100% 10px",
2013// opacity: 0.5,
2014// marginLeft: 36, // why 36 ???
2015// }}
2016// />
2017// <span
2018// style={{
2019// //color: theme.textSecondary,
2020// marginLeft: 34,
2021// }}
2022// className="text-gray-500 dark:text-gray-400"
2023// >
2024// more posts
2025// </span>
2026// </div>
2027// );
2028// }
2029
2030type Embed =
2031 | AppBskyEmbedRecord.View
2032 | AppBskyEmbedImages.View
2033 | AppBskyEmbedVideo.View
2034 | AppBskyEmbedExternal.View
2035 | AppBskyEmbedRecordWithMedia.View
2036 | { $type: string; [k: string]: unknown };
2037
2038enum PostEmbedViewContext {
2039 ThreadHighlighted = "ThreadHighlighted",
2040 Feed = "Feed",
2041 FeedEmbedRecordWithMedia = "FeedEmbedRecordWithMedia",
2042}
2043const stopgap = {
2044 display: "flex",
2045 justifyContent: "center",
2046 padding: "32px 12px",
2047 borderRadius: 12,
2048 border: "1px solid rgba(161, 170, 174, 0.38)",
2049};
2050
2051function PostEmbeds({
2052 embed,
2053 moderation,
2054 onOpen,
2055 allowNestedQuotes,
2056 viewContext,
2057 salt,
2058 navigate,
2059 postid,
2060 nopics,
2061 lightboxCallback,
2062}: {
2063 embed?: Embed;
2064 moderation?: ModerationDecision;
2065 onOpen?: () => void;
2066 allowNestedQuotes?: boolean;
2067 viewContext?: PostEmbedViewContext;
2068 salt: string;
2069 navigate: (_: any) => void;
2070 postid?: { did: string; rkey: string };
2071 nopics?: boolean;
2072 lightboxCallback?: (d: LightboxProps) => void;
2073}) {
2074 //const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
2075 function setLightboxIndex(number: number) {
2076 navigate({
2077 to: "/profile/$did/post/$rkey/image/$i",
2078 params: {
2079 did: postid?.did,
2080 rkey: postid?.rkey,
2081 i: number.toString(),
2082 },
2083 });
2084 }
2085 if (
2086 AppBskyEmbedRecordWithMedia.isView(embed) &&
2087 AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
2088 AppBskyFeedPost.isRecord(embed.record.record.value) //&&
2089 //AppBskyFeedPost.validateRecord(embed.record.record.value).success
2090 ) {
2091 const post: PostView = {
2092 $type: "app.bsky.feed.defs#postView", // lmao lies
2093 uri: embed.record.record.uri,
2094 cid: embed.record.record.cid,
2095 author: embed.record.record.author,
2096 record: embed.record.record.value as { [key: string]: unknown },
2097 embed: embed.record.record.embeds
2098 ? embed.record.record.embeds?.[0]
2099 : undefined, // quotes handles embeds differently, its an array for some reason
2100 replyCount: embed.record.record.replyCount,
2101 repostCount: embed.record.record.repostCount,
2102 likeCount: embed.record.record.likeCount,
2103 quoteCount: embed.record.record.quoteCount,
2104 indexedAt: embed.record.record.indexedAt,
2105 // we dont have a viewer, so this is a best effort conversion, still requires full query later on
2106 labels: embed.record.record.labels,
2107 // neither do we have threadgate. remember to please fetch the full post later
2108 };
2109 return (
2110 <div>
2111 <PostEmbeds
2112 embed={embed.media}
2113 moderation={moderation}
2114 onOpen={onOpen}
2115 viewContext={viewContext}
2116 salt={salt}
2117 navigate={navigate}
2118 postid={postid}
2119 nopics={nopics}
2120 lightboxCallback={lightboxCallback}
2121 />
2122 {/* padding empty div of 8px height */}
2123 <div style={{ height: 12 }} />
2124 {/* stopgap sorry*/}
2125 <div
2126 style={{
2127 display: "flex",
2128 flexDirection: "column",
2129 borderRadius: 12,
2130 //border: `1px solid ${theme.border}`,
2131 //boxShadow: theme.cardShadow,
2132 overflow: "hidden",
2133 }}
2134 className="shadow border border-gray-200 dark:border-gray-800 was7"
2135 >
2136 <UniversalPostRenderer
2137 post={post}
2138 isQuote
2139 salt={salt}
2140 onPostClick={(e) => {
2141 e.stopPropagation();
2142 const parsed = new AtUri(post.uri); //parseAtUri(post.uri);
2143 if (parsed) {
2144 navigate({
2145 to: "/profile/$did/post/$rkey",
2146 params: { did: parsed.host, rkey: parsed.rkey },
2147 });
2148 }
2149 }}
2150 depth={1}
2151 />
2152 </div>
2153 {/* <QuotePostRenderer
2154 record={embed.record.record}
2155 moderation={moderation}
2156 /> */}
2157 {/* stopgap sorry */}
2158 {/* <div style={stopgap}>quote post placeholder</div> */}
2159 {/* {<MaybeQuoteEmbed
2160 embed={embed.record}
2161 onOpen={onOpen}
2162 viewContext={
2163 viewContext === PostEmbedViewContext.Feed
2164 ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia
2165 : undefined
2166 }
2167 {/* <div style={stopgap}>quote post placeholder</div> */}
2168 {/* {<MaybeQuoteEmbed
2169 embed={embed.record}
2170 onOpen={onOpen}
2171 viewContext={
2172 viewContext === PostEmbedViewContext.Feed
2173 ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia
2174 : undefined
2175 }
2176 />} */}
2177 </div>
2178 );
2179 }
2180
2181 if (AppBskyEmbedRecord.isView(embed)) {
2182 // hey im really lazy and im gonna do it the bad way
2183 const reallybaduri = (embed?.record as any)?.uri as string | undefined;
2184 const reallybadaturi = reallybaduri ? new AtUri(reallybaduri) : undefined;
2185
2186 // custom feed embed (i.e. generator view)
2187 if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
2188 // stopgap sorry
2189 return <div style={stopgap}>feedgen placeholder</div>;
2190 // return (
2191 // <div style={{ marginTop: '1rem' }}>
2192 // <MaybeFeedCard view={embed.record} />
2193 // </div>
2194 // )
2195 } else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.feed.generator") {
2196 return <div className="rounded-xl border"><FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder/></div>
2197 }
2198
2199 // list embed
2200 if (AppBskyGraphDefs.isListView(embed.record)) {
2201 // stopgap sorry
2202 return <div style={stopgap}>list placeholder</div>;
2203 // return (
2204 // <div style={{ marginTop: '1rem' }}>
2205 // <MaybeListCard view={embed.record} />
2206 // </div>
2207 // )
2208 } else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.graph.list") {
2209 return <div className="rounded-xl border"><FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder listmode /></div>
2210 }
2211
2212 // starter pack embed
2213 if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) {
2214 // stopgap sorry
2215 return <div style={stopgap}>starter pack card placeholder</div>;
2216 // return (
2217 // <div style={{ marginTop: '1rem' }}>
2218 // <StarterPackCard starterPack={embed.record} />
2219 // </div>
2220 // )
2221 } else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.graph.starterpack") {
2222 return <div className="rounded-xl border"><FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder listmode /></div>
2223 }
2224
2225 // quote post
2226 // =
2227 // stopgap sorry
2228
2229 if (
2230 AppBskyEmbedRecord.isViewRecord(embed.record) &&
2231 AppBskyFeedPost.isRecord(embed.record.value) // &&
2232 //AppBskyFeedPost.validateRecord(embed.record.value).success
2233 ) {
2234 const post: PostView = {
2235 $type: "app.bsky.feed.defs#postView", // lmao lies
2236 uri: embed.record.uri,
2237 cid: embed.record.cid,
2238 author: embed.record.author,
2239 record: embed.record.value as { [key: string]: unknown },
2240 embed: embed.record.embeds ? embed.record.embeds?.[0] : undefined, // quotes handles embeds differently, its an array for some reason
2241 replyCount: embed.record.replyCount,
2242 repostCount: embed.record.repostCount,
2243 likeCount: embed.record.likeCount,
2244 quoteCount: embed.record.quoteCount,
2245 indexedAt: embed.record.indexedAt,
2246 // we dont have a viewer, so this is a best effort conversion, still requires full query later on
2247 labels: embed.record.labels,
2248 // neither do we have threadgate. remember to please fetch the full post later
2249 };
2250
2251 return (
2252 <div
2253 style={{
2254 display: "flex",
2255 flexDirection: "column",
2256 borderRadius: 12,
2257 //border: `1px solid ${theme.border}`,
2258 //boxShadow: theme.cardShadow,
2259 overflow: "hidden",
2260 }}
2261 className="shadow border border-gray-200 dark:border-gray-800 was7"
2262 >
2263 <UniversalPostRenderer
2264 post={post}
2265 isQuote
2266 salt={salt}
2267 onPostClick={(e) => {
2268 e.stopPropagation();
2269 const parsed = new AtUri(post.uri); //parseAtUri(post.uri);
2270 if (parsed) {
2271 navigate({
2272 to: "/profile/$did/post/$rkey",
2273 params: { did: parsed.host, rkey: parsed.rkey },
2274 });
2275 }
2276 }}
2277 depth={1}
2278 />
2279 </div>
2280 );
2281 } else {
2282 console.log("what the hell is a ", embed);
2283 return <>sorry</>;
2284 }
2285 //return <QuotePostRenderer record={embed.record} moderation={moderation} />;
2286
2287 //return <div style={stopgap}>quote post placeholder</div>;
2288 // return (
2289 // <MaybeQuoteEmbed
2290 // embed={embed}
2291 // onOpen={onOpen}
2292 // allowNestedQuotes={allowNestedQuotes}
2293 // />
2294 // )
2295 }
2296
2297 // image embed
2298 // =
2299 if (AppBskyEmbedImages.isView(embed)) {
2300 const { images } = embed;
2301
2302 const lightboxImages = images.map((img) => ({
2303 src: img.fullsize,
2304 alt: img.alt,
2305 }));
2306 console.log("rendering images");
2307 if (lightboxCallback) {
2308 lightboxCallback({ images: lightboxImages });
2309 console.log("rendering images");
2310 }
2311
2312 if (nopics) return;
2313
2314 if (images.length > 0) {
2315 // const items = embed.images.map(img => ({
2316 // uri: img.fullsize,
2317 // thumbUri: img.thumb,
2318 // alt: img.alt,
2319 // dimensions: img.aspectRatio ?? null,
2320 // }))
2321
2322 if (images.length === 1) {
2323 const image = images[0];
2324 return (
2325 <div style={{ marginTop: 0 }}>
2326 <div
2327 style={{
2328 position: "relative",
2329 width: "100%",
2330 aspectRatio: image.aspectRatio
2331 ? (() => {
2332 const { width, height } = image.aspectRatio;
2333 const ratio = width / height;
2334 return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`;
2335 })()
2336 : "1 / 1", // fallback to square
2337 //backgroundColor: theme.background, // fallback letterboxing color
2338 borderRadius: 12,
2339 //border: `1px solid ${theme.border}`,
2340 overflow: "hidden",
2341 }}
2342 className="border border-gray-200 dark:border-gray-800 was7 bg-gray-200 dark:bg-gray-900"
2343 >
2344 {/* {lightboxIndex !== null && (
2345 <Lightbox
2346 images={lightboxImages}
2347 index={lightboxIndex}
2348 onClose={() => setLightboxIndex(null)}
2349 onNavigate={(newIndex) => setLightboxIndex(newIndex)}
2350 post={postid}
2351 />
2352 )} */}
2353 <img
2354 src={image.fullsize}
2355 alt={image.alt}
2356 style={{
2357 width: "100%",
2358 height: "100%",
2359 objectFit: "contain", // letterbox or scale to fit
2360 }}
2361 onClick={(e) => {
2362 e.stopPropagation();
2363 setLightboxIndex(0);
2364 }}
2365 />
2366 </div>
2367 </div>
2368 );
2369 }
2370 // 2 images: side by side, both 1:1, cropped
2371 if (images.length === 2) {
2372 return (
2373 <div
2374 style={{
2375 display: "flex",
2376 gap: 4,
2377 marginTop: 0,
2378 width: "100%",
2379 borderRadius: 12,
2380 overflow: "hidden",
2381 //border: `1px solid ${theme.border}`,
2382 }}
2383 className="border border-gray-200 dark:border-gray-800 was7"
2384 >
2385 {/* {lightboxIndex !== null && (
2386 <Lightbox
2387 images={lightboxImages}
2388 index={lightboxIndex}
2389 onClose={() => setLightboxIndex(null)}
2390 onNavigate={(newIndex) => setLightboxIndex(newIndex)}
2391 post={postid}
2392 />
2393 )} */}
2394 {images.map((img, i) => (
2395 <div
2396 key={i}
2397 style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }}
2398 >
2399 <img
2400 src={img.fullsize}
2401 alt={img.alt}
2402 style={{
2403 width: "100%",
2404 height: "100%",
2405 objectFit: "cover",
2406 borderRadius: i === 0 ? "12px 0 0 12px" : "0 12px 12px 0",
2407 }}
2408 onClick={(e) => {
2409 e.stopPropagation();
2410 setLightboxIndex(i);
2411 }}
2412 />
2413 </div>
2414 ))}
2415 </div>
2416 );
2417 }
2418
2419 // 3 images: left is 1:1, right is two stacked 2:1
2420 if (images.length === 3) {
2421 return (
2422 <div
2423 style={{
2424 display: "flex",
2425 gap: 4,
2426 marginTop: 0,
2427 width: "100%",
2428 borderRadius: 12,
2429 overflow: "hidden",
2430 //border: `1px solid ${theme.border}`,
2431 // height: 240, // fixed height for cropping
2432 }}
2433 className="border border-gray-200 dark:border-gray-800 was7"
2434 >
2435 {/* {lightboxIndex !== null && (
2436 <Lightbox
2437 images={lightboxImages}
2438 index={lightboxIndex}
2439 onClose={() => setLightboxIndex(null)}
2440 onNavigate={(newIndex) => setLightboxIndex(newIndex)}
2441 post={postid}
2442 />
2443 )} */}
2444 {/* Left: 1:1 */}
2445 <div
2446 style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }}
2447 >
2448 <img
2449 src={images[0].fullsize}
2450 alt={images[0].alt}
2451 style={{
2452 width: "100%",
2453 height: "100%",
2454 objectFit: "cover",
2455 borderRadius: "12px 0 0 12px",
2456 }}
2457 onClick={(e) => {
2458 e.stopPropagation();
2459 setLightboxIndex(0);
2460 }}
2461 />
2462 </div>
2463 {/* Right: two stacked 2:1 */}
2464 <div
2465 style={{
2466 flex: 1,
2467 display: "flex",
2468 flexDirection: "column",
2469 gap: 4,
2470 }}
2471 >
2472 {[1, 2].map((i) => (
2473 <div
2474 key={i}
2475 style={{
2476 flex: 1,
2477 aspectRatio: "2 / 1",
2478 position: "relative",
2479 }}
2480 >
2481 <img
2482 src={images[i].fullsize}
2483 alt={images[i].alt}
2484 style={{
2485 width: "100%",
2486 height: "100%",
2487 objectFit: "cover",
2488 borderRadius: i === 1 ? "0 12px 0 0" : "0 0 12px 0",
2489 }}
2490 onClick={(e) => {
2491 e.stopPropagation();
2492 setLightboxIndex(i + 1);
2493 }}
2494 />
2495 </div>
2496 ))}
2497 </div>
2498 </div>
2499 );
2500 }
2501
2502 // 4 images: 2x2 grid, all 3:2
2503 if (images.length === 4) {
2504 return (
2505 <div
2506 style={{
2507 display: "grid",
2508 gridTemplateColumns: "1fr 1fr",
2509 gridTemplateRows: "1fr 1fr",
2510 gap: 4,
2511 marginTop: 0,
2512 width: "100%",
2513 borderRadius: 12,
2514 overflow: "hidden",
2515 //border: `1px solid ${theme.border}`,
2516 //aspectRatio: "3 / 2", // overall grid aspect
2517 }}
2518 className="border border-gray-200 dark:border-gray-800 was7"
2519 >
2520 {/* {lightboxIndex !== null && (
2521 <Lightbox
2522 images={lightboxImages}
2523 index={lightboxIndex}
2524 onClose={() => setLightboxIndex(null)}
2525 onNavigate={(newIndex) => setLightboxIndex(newIndex)}
2526 post={postid}
2527 />
2528 )} */}
2529 {images.map((img, i) => (
2530 <div
2531 key={i}
2532 style={{
2533 width: "100%",
2534 height: "100%",
2535 aspectRatio: "3 / 2",
2536 position: "relative",
2537 }}
2538 >
2539 <img
2540 src={img.fullsize}
2541 alt={img.alt}
2542 style={{
2543 width: "100%",
2544 height: "100%",
2545 objectFit: "cover",
2546 borderRadius:
2547 i === 0
2548 ? "12px 0 0 0"
2549 : i === 1
2550 ? "0 12px 0 0"
2551 : i === 2
2552 ? "0 0 0 12px"
2553 : "0 0 12px 0",
2554 }}
2555 onClick={(e) => {
2556 e.stopPropagation();
2557 setLightboxIndex(i);
2558 }}
2559 />
2560 </div>
2561 ))}
2562 </div>
2563 );
2564 }
2565
2566 // stopgap sorry
2567 return <div style={stopgap}>image count more than one placeholder</div>;
2568 // return (
2569 // <div style={{ marginTop: '1rem' }}>
2570 // <ImageLayoutGrid
2571 // images={images}
2572 // viewContext={viewContext}
2573 // />
2574 // </div>
2575 // )
2576 }
2577 }
2578
2579 // external link embed
2580 // =
2581 if (AppBskyEmbedExternal.isView(embed)) {
2582 const link = embed.external;
2583 return (
2584 <ExternalLinkEmbed link={link} onOpen={onOpen} style={{ marginTop: 0 }} />
2585 );
2586 }
2587
2588 // video embed
2589 // =
2590 if (AppBskyEmbedVideo.isView(embed)) {
2591 // hls playlist
2592 if (nopics) return;
2593 const playlist = embed.playlist;
2594 return (
2595 <SmartHLSPlayer
2596 url={playlist}
2597 thumbnail={embed.thumbnail}
2598 aspect={embed.aspectRatio}
2599 />
2600 );
2601 // stopgap sorry
2602 //return (<div>video</div>)
2603 // return (
2604 // <VideoEmbed
2605 // embed={embed}
2606 // crop={
2607 // viewContext === PostEmbedViewContext.ThreadHighlighted
2608 // ? 'none'
2609 // : viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
2610 // ? 'square'
2611 // : 'constrained'
2612 // }
2613 // />
2614 // )
2615 }
2616
2617 return <div />;
2618}
2619
2620function getDomain(url: string) {
2621 try {
2622 const { hostname } = new URL(url);
2623 return hostname;
2624 } catch (e) {
2625 // In case it's a bare domain like "example.com"
2626 if (!url.startsWith("http")) {
2627 try {
2628 const { hostname } = new URL("http://" + url);
2629 return hostname;
2630 } catch {
2631 return null;
2632 }
2633 }
2634 return null;
2635 }
2636}
2637function getByteToCharMap(text: string): number[] {
2638 const encoder = new TextEncoder();
2639 //const utf8 = encoder.encode(text);
2640
2641 const map: number[] = [];
2642 let byteIndex = 0;
2643 let charIndex = 0;
2644
2645 for (const char of text) {
2646 const bytes = encoder.encode(char);
2647 for (let i = 0; i < bytes.length; i++) {
2648 map[byteIndex++] = charIndex;
2649 }
2650 charIndex += char.length;
2651 }
2652
2653 return map;
2654}
2655
2656function facetByteRangeToCharRange(
2657 byteStart: number,
2658 byteEnd: number,
2659 byteToCharMap: number[]
2660): [number, number] {
2661 return [
2662 byteToCharMap[byteStart] ?? 0,
2663 byteToCharMap[byteEnd - 1]! + 1, // inclusive end -> exclusive char end
2664 ];
2665}
2666
2667interface FacetRange {
2668 start: number;
2669 end: number;
2670 feature: Facet["features"][number];
2671}
2672
2673function extractFacetRanges(text: string, facets: Facet[]): FacetRange[] {
2674 const map = getByteToCharMap(text);
2675 return facets.map((f) => {
2676 const [start, end] = facetByteRangeToCharRange(
2677 f.index.byteStart,
2678 f.index.byteEnd,
2679 map
2680 );
2681 return { start, end, feature: f.features[0] };
2682 });
2683}
2684export function renderTextWithFacets({
2685 text,
2686 facets,
2687 navigate,
2688}: {
2689 text: string;
2690 facets: Facet[];
2691 navigate: (_: any) => void;
2692}) {
2693 const ranges = extractFacetRanges(text, facets).sort(
2694 (a: any, b: any) => a.start - b.start
2695 );
2696
2697 const result: React.ReactNode[] = [];
2698 let current = 0;
2699
2700 for (const { start, end, feature } of ranges) {
2701 if (current < start) {
2702 result.push(<span key={current}>{text.slice(current, start)}</span>);
2703 }
2704
2705 const fragment = text.slice(start, end);
2706 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed
2707 if (feature.$type === "app.bsky.richtext.facet#link" && feature.uri) {
2708 result.push(
2709 <a
2710 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed
2711 href={feature.uri}
2712 key={start}
2713 className="link"
2714 style={{
2715 textDecoration: "none",
2716 color: "rgb(29, 122, 242)",
2717 wordBreak: "break-all",
2718 }}
2719 target="_blank"
2720 rel="noreferrer"
2721 onClick={(e) => {
2722 e.stopPropagation();
2723 }}
2724 >
2725 {fragment}
2726 </a>
2727 );
2728 } else if (
2729 feature.$type === "app.bsky.richtext.facet#mention" &&
2730 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed
2731 feature.did
2732 ) {
2733 result.push(
2734 <span
2735 key={start}
2736 style={{ color: "rgb(29, 122, 242)" }}
2737 className=" cursor-pointer"
2738 onClick={(e) => {
2739 e.stopPropagation();
2740 navigate({
2741 to: "/profile/$did",
2742 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed
2743 params: { did: feature.did },
2744 });
2745 }}
2746 >
2747 {fragment}
2748 </span>
2749 );
2750 } else if (feature.$type === "app.bsky.richtext.facet#tag") {
2751 result.push(
2752 <span
2753 key={start}
2754 style={{ color: "rgb(29, 122, 242)" }}
2755 onClick={(e) => {
2756 e.stopPropagation();
2757 }}
2758 >
2759 {fragment}
2760 </span>
2761 );
2762 } else {
2763 result.push(<span key={start}>{fragment}</span>);
2764 }
2765
2766 current = end;
2767 }
2768
2769 if (current < text.length) {
2770 result.push(<span key={current}>{text.slice(current)}</span>);
2771 }
2772
2773 return result;
2774}
2775function ExternalLinkEmbed({
2776 link,
2777 onOpen,
2778 style,
2779}: {
2780 link: AppBskyEmbedExternal.ViewExternal;
2781 onOpen?: () => void;
2782 style?: React.CSSProperties;
2783}) {
2784 //const { theme } = useTheme();
2785 const { uri, title, description, thumb } = link;
2786 const thumbAspectRatio = 1.91;
2787 const titleStyle = {
2788 fontSize: 16,
2789 fontWeight: 700,
2790 marginBottom: 4,
2791 //color: theme.text,
2792 wordBreak: "break-word",
2793 textAlign: "left",
2794 maxHeight: "4em", // 2 lines * 1.5em line-height
2795 // stupid shit
2796 display: "-webkit-box",
2797 WebkitBoxOrient: "vertical",
2798 overflow: "hidden",
2799 WebkitLineClamp: 2,
2800 };
2801 const descriptionStyle = {
2802 fontSize: 14,
2803 //color: theme.textSecondary,
2804 marginBottom: 8,
2805 wordBreak: "break-word",
2806 textAlign: "left",
2807 maxHeight: "5em", // 3 lines * 1.5em line-height
2808 // stupid shit
2809 display: "-webkit-box",
2810 WebkitBoxOrient: "vertical",
2811 overflow: "hidden",
2812 WebkitLineClamp: 3,
2813 };
2814 const linkStyle = {
2815 textDecoration: "none",
2816 //color: theme.textSecondary,
2817 wordBreak: "break-all",
2818 textAlign: "left",
2819 };
2820 const containerStyle = {
2821 display: "flex",
2822 flexDirection: "column",
2823 //backgroundColor: theme.background,
2824 //background: '#eee',
2825 borderRadius: 12,
2826 //border: `1px solid ${theme.border}`,
2827 //boxShadow: theme.cardShadow,
2828 maxWidth: "100%",
2829 overflow: "hidden",
2830 ...style,
2831 };
2832 return (
2833 <a
2834 href={uri}
2835 target="_blank"
2836 rel="noopener noreferrer"
2837 onClick={(e) => {
2838 e.stopPropagation();
2839 if (onOpen) onOpen();
2840 }}
2841 /* @ts-expect-error css arent typed or something idk fuck you */
2842 style={linkStyle}
2843 className="text-gray-500 dark:text-gray-400"
2844 >
2845 <div
2846 style={containerStyle as React.CSSProperties}
2847 className="border border-gray-200 dark:border-gray-800 was7"
2848 >
2849 {thumb && (
2850 <div
2851 style={{
2852 position: "relative",
2853 width: "100%",
2854 aspectRatio: thumbAspectRatio,
2855 overflow: "hidden",
2856 borderTopLeftRadius: 12,
2857 borderTopRightRadius: 12,
2858 marginBottom: 8,
2859 //borderBottom: `1px solid ${theme.border}`,
2860 }}
2861 className="border-b border-gray-200 dark:border-gray-800 was7"
2862 >
2863 <img
2864 src={thumb}
2865 alt={description}
2866 style={{
2867 position: "absolute",
2868 top: 0,
2869 left: 0,
2870 width: "100%",
2871 height: "100%",
2872 objectFit: "cover",
2873 }}
2874 />
2875 </div>
2876 )}
2877 <div
2878 style={{
2879 paddingBottom: 12,
2880 paddingLeft: 12,
2881 paddingRight: 12,
2882 paddingTop: thumb ? 0 : 12,
2883 }}
2884 >
2885 {/* @ts-expect-error css */}
2886 <div style={titleStyle} className="text-gray-900 dark:text-gray-100">
2887 {title}
2888 </div>
2889 <div
2890 style={descriptionStyle as React.CSSProperties}
2891 className="text-gray-500 dark:text-gray-400"
2892 >
2893 {description}
2894 </div>
2895 {/* small 1px divider here */}
2896 <div
2897 style={{
2898 height: 1,
2899 //backgroundColor: theme.border,
2900 marginBottom: 8,
2901 }}
2902 className="bg-gray-200 dark:bg-gray-700"
2903 />
2904 <div
2905 style={{
2906 display: "flex",
2907 alignItems: "center",
2908 gap: 4,
2909 }}
2910 >
2911 <MdiGlobe />
2912 <span
2913 style={{
2914 fontSize: 12,
2915 //color: theme.textSecondary
2916 }}
2917 className="text-gray-500 dark:text-gray-400"
2918 >
2919 {getDomain(uri)}
2920 </span>
2921 </div>
2922 </div>
2923 </div>
2924 </a>
2925 );
2926}
2927
2928const SmartHLSPlayer = ({
2929 url,
2930 thumbnail,
2931 aspect,
2932}: {
2933 url: string;
2934 thumbnail?: string;
2935 aspect?: AppBskyEmbedDefs.AspectRatio;
2936}) => {
2937 const [playing, setPlaying] = useState(false);
2938 const containerRef = useRef(null);
2939
2940 // pause the player if it goes out of viewport
2941 useEffect(() => {
2942 const observer = new IntersectionObserver(
2943 ([entry]) => {
2944 if (!entry.isIntersecting && playing) {
2945 setPlaying(false);
2946 }
2947 },
2948 {
2949 root: null,
2950 threshold: 0.25,
2951 }
2952 );
2953
2954 if (containerRef.current) {
2955 observer.observe(containerRef.current);
2956 }
2957
2958 return () => {
2959 if (containerRef.current) {
2960 observer.unobserve(containerRef.current);
2961 }
2962 };
2963 }, [playing]);
2964
2965 return (
2966 <div
2967 ref={containerRef}
2968 style={{
2969 position: "relative",
2970 width: "100%",
2971 maxWidth: 640,
2972 cursor: "pointer",
2973 }}
2974 >
2975 {!playing && (
2976 <>
2977 <img
2978 src={thumbnail}
2979 alt="Video thumbnail"
2980 style={{
2981 width: "100%",
2982 display: "block",
2983 aspectRatio: aspect ? aspect?.width / aspect?.height : 16 / 9,
2984 borderRadius: 12,
2985 //border: `1px solid ${theme.border}`,
2986 }}
2987 className="border border-gray-200 dark:border-gray-800 was7"
2988 onClick={async (e) => {
2989 e.stopPropagation();
2990 setPlaying(true);
2991 }}
2992 />
2993 <div
2994 onClick={async (e) => {
2995 e.stopPropagation();
2996 setPlaying(true);
2997 }}
2998 style={{
2999 position: "absolute",
3000 top: "50%",
3001 left: "50%",
3002 transform: "translate(-50%, -50%)",
3003 //fontSize: 48,
3004 color: "white",
3005 //textShadow: theme.cardShadow,
3006 pointerEvents: "none",
3007 userSelect: "none",
3008 }}
3009 className="text-shadow-md"
3010 >
3011 {/*▶️*/}
3012 <MdiPlayCircle />
3013 </div>
3014 </>
3015 )}
3016 {playing && (
3017 <div
3018 style={{
3019 position: "relative",
3020 width: "100%",
3021 borderRadius: 12,
3022 overflow: "hidden",
3023 //border: `1px solid ${theme.border}`,
3024 paddingTop: `${
3025 100 / (aspect ? aspect.width / aspect.height : 16 / 9)
3026 }%`, // 16:9 = 56.25%, 4:3 = 75%
3027 }}
3028 className="border border-gray-200 dark:border-gray-800 was7"
3029 >
3030 <ReactPlayer
3031 src={url}
3032 playing={true}
3033 controls={true}
3034 width="100%"
3035 height="100%"
3036 style={{ position: "absolute", top: 0, left: 0 }}
3037 />
3038 {/* <ReactPlayer
3039 url={url}
3040 playing={true}
3041 controls={true}
3042 width="100%"
3043 style={{width: "100% !important", aspectRatio: aspect ? aspect?.width/aspect?.height : 16/9}}
3044 onPause={() => setPlaying(false)}
3045 onEnded={() => setPlaying(false)}
3046 /> */}
3047 </div>
3048 )}
3049 </div>
3050 );
3051};