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