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 <div className=" flex flex-col justify-start">
1561 <FollowButton targetdidorhandle={post.author.did} />
1562 </div>
1563 </div>
1564 </div>
1565 <div className="flex flex-col gap-3">
1566 <div>
1567 <div className="text-gray-900 dark:text-gray-100 font-medium text-md">
1568 {post.author.displayName || post.author.handle}{" "}
1569 </div>
1570 <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1">
1571 <Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "}
1572 </div>
1573 </div>
1574 {uprrrsauthor?.description && (
1575 <div className="text-gray-700 dark:text-gray-300 text-sm text-left break-words line-clamp-3">
1576 {uprrrsauthor.description}
1577 </div>
1578 )}
1579 {/* <div className="flex gap-4">
1580 <div className="flex gap-1">
1581 <div className="font-medium text-gray-900 dark:text-gray-100">
1582 0
1583 </div>
1584 <div className="text-gray-500 dark:text-gray-400">
1585 Following
1586 </div>
1587 </div>
1588 <div className="flex gap-1">
1589 <div className="font-medium text-gray-900 dark:text-gray-100">
1590 2,900
1591 </div>
1592 <div className="text-gray-500 dark:text-gray-400">
1593 Followers
1594 </div>
1595 </div>
1596 </div> */}
1597 </div>
1598 </div>
1599
1600 {/* <HoverCard.Arrow className="fill-gray-50 dark:fill-gray-900" /> */}
1601 </HoverCard.Content>
1602 </HoverCard.Portal>
1603 </HoverCard.Root>
1604
1605 <div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}>
1606 <div
1607 style={{
1608 display: "flex",
1609 flexDirection: "column",
1610 alignSelf: "stretch",
1611 alignItems: "center",
1612 overflow: "hidden",
1613 width: expanded || isQuote ? 0 : "auto",
1614 marginRight: expanded || isQuote ? 0 : 12,
1615 }}
1616 >
1617 {/* dummy for later use */}
1618 <div style={{ width: 42, height: 42 + 6, minHeight: 42 + 6 }} />
1619 {/* reply line !!!! bottomReplyLine */}
1620 {bottomReplyLine && (
1621 <div
1622 style={{
1623 width: 2,
1624 height: "100%",
1625 //background: theme.textSecondary,
1626 opacity: 0.5,
1627 // no flex here
1628 //color: "Red",
1629 //zIndex: 99
1630 }}
1631 className="bg-gray-500 dark:bg-gray-400"
1632 />
1633 )}
1634 {/* <div
1635 layout
1636 transition={{ duration: 0.2 }}
1637 animate={{ height: expanded ? 0 : '100%' }}
1638 style={{
1639 width: 2.4,
1640 background: theme.border,
1641 // no flex here
1642 }}
1643 /> */}
1644 </div>
1645 <div style={{ flex: 1, maxWidth: "100%" }}>
1646 <div
1647 style={{
1648 display: "flex",
1649 flexDirection: "row",
1650 alignItems: "center",
1651 flexWrap: "nowrap",
1652 maxWidth: `calc(100% - ${!expanded ? (isQuote ? 26 : 0) : 54}px)`,
1653 width: `calc(100% - ${!expanded ? (isQuote ? 26 : 0) : 54}px)`,
1654 marginLeft: !expanded ? (isQuote ? 26 : 0) : 54,
1655 marginBottom: !expanded ? 4 : 6,
1656 }}
1657 >
1658 <div
1659 style={{
1660 display: "flex",
1661 //overflow: "hidden", // hey why is overflow hidden unapplied
1662 overflow: "hidden",
1663 textOverflow: "ellipsis",
1664 flexShrink: 1,
1665 flexGrow: 1,
1666 flexBasis: 0,
1667 width: 0,
1668 gap: expanded ? 0 : 6,
1669 alignItems: expanded ? "flex-start" : "center",
1670 flexDirection: expanded ? "column" : "row",
1671 height: expanded ? 42 : "1rem",
1672 }}
1673 >
1674 <span
1675 style={{
1676 display: "flex",
1677 fontWeight: 700,
1678 fontSize: 16,
1679 overflow: "hidden",
1680 textOverflow: "ellipsis",
1681 whiteSpace: "nowrap",
1682 flexShrink: 1,
1683 minWidth: 0,
1684 gap: 4,
1685 alignItems: "center",
1686 //color: theme.text,
1687 }}
1688 className="text-gray-900 dark:text-gray-100"
1689 >
1690 {/* verified checkmark */}
1691 {post.author.displayName || post.author.handle}{" "}
1692 {post.author.verification?.verifiedStatus == "valid" && (
1693 <MdiVerified />
1694 )}
1695 </span>
1696
1697 <span
1698 style={{
1699 //color: theme.textSecondary,
1700 fontSize: 16,
1701 overflowX: "hidden",
1702 textOverflow: "ellipsis",
1703 whiteSpace: "nowrap",
1704 flexShrink: 1,
1705 flexGrow: 0,
1706 minWidth: 0,
1707 }}
1708 className="text-gray-500 dark:text-gray-400"
1709 >
1710 @{post.author.handle}
1711 </span>
1712 </div>
1713 <div
1714 style={{
1715 display: "flex",
1716 alignItems: "center",
1717 height: "1rem",
1718 }}
1719 >
1720 <span
1721 style={{
1722 //color: theme.textSecondary,
1723 fontSize: 16,
1724 marginLeft: 8,
1725 whiteSpace: "nowrap",
1726 flexShrink: 0,
1727 maxWidth: "100%",
1728 }}
1729 className="text-gray-500 dark:text-gray-400"
1730 >
1731 · {/* time placeholder */}
1732 {shortTimeAgo(post.indexedAt)}
1733 </span>
1734 </div>
1735 </div>
1736 {/* reply indicator */}
1737 {!!feedviewpostreplyhandle && (
1738 <div
1739 style={{
1740 display: "flex",
1741 borderRadius: 12,
1742 paddingBottom: 2,
1743 fontSize: 14,
1744 justifyContent: "flex-start",
1745 //color: theme.textSecondary,
1746 gap: 4,
1747 alignItems: "center",
1748 //marginLeft: 36,
1749 height:
1750 !(expanded || isQuote) && !!feedviewpostreplyhandle
1751 ? "1rem"
1752 : 0,
1753 opacity:
1754 !(expanded || isQuote) && !!feedviewpostreplyhandle ? 1 : 0,
1755 }}
1756 className="text-gray-500 dark:text-gray-400"
1757 >
1758 <MdiReply /> Reply to @{feedviewpostreplyhandle}
1759 </div>
1760 )}
1761 <div
1762 style={{
1763 fontSize: 16,
1764 marginBottom: !post.embed /*|| depth > 0*/ ? 0 : 8,
1765 whiteSpace: "pre-wrap",
1766 textAlign: "left",
1767 overflowWrap: "anywhere",
1768 wordBreak: "break-word",
1769 //color: theme.text,
1770 }}
1771 className="text-gray-900 dark:text-gray-100"
1772 >
1773 {fedi ? (
1774 <>
1775 <span
1776 className="dangerousFediContent"
1777 dangerouslySetInnerHTML={{
1778 __html: DOMPurify.sanitize(fedi),
1779 }}
1780 />
1781 </>
1782 ) : (
1783 <>
1784 {renderTextWithFacets({
1785 text: (post.record as { text?: string }).text ?? "",
1786 facets: (post.record.facets as Facet[]) ?? [],
1787 navigate: navigate,
1788 })}
1789 </>
1790 )}
1791 </div>
1792 {post.embed && depth < 1 ? (
1793 <PostEmbeds
1794 embed={post.embed}
1795 //moderation={moderation}
1796 viewContext={PostEmbedViewContext.Feed}
1797 salt={salt}
1798 navigate={navigate}
1799 postid={{ did: post.author.did, rkey: parsed.rkey }}
1800 nopics={nopics}
1801 lightboxCallback={lightboxCallback}
1802 />
1803 ) : null}
1804 {post.embed && depth > 0 && (
1805 /* pretty bad hack imo. its trying to sync up with how the embed shim doesnt
1806 hydrate embeds this deep but the connection here is implicit
1807 todo: idk make this a real part of the embed shim so its not implicit */
1808 <>
1809 <div className="border-gray-300 dark:border-gray-800 p-3 rounded-xl border italic text-gray-400 text-[14px]">
1810 (there is an embed here thats too deep to render)
1811 </div>
1812 </>
1813 )}
1814 <div style={{ paddingTop: post.embed && depth < 1 ? 4 : 0 }}>
1815 <>
1816 {expanded && (
1817 <div
1818 style={{
1819 overflow: "hidden",
1820 //color: theme.textSecondary,
1821 fontSize: 14,
1822 display: "flex",
1823 borderBottomStyle: "solid",
1824 //borderBottomColor: theme.border,
1825 //background: "#f00",
1826 // height: "1rem",
1827 paddingTop: 4,
1828 paddingBottom: 8,
1829 borderBottomWidth: 1,
1830 marginBottom: 8,
1831 }} // important for height animation
1832 className="text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-800 was7"
1833 >
1834 {fullDateTimeFormat(post.indexedAt)}
1835 </div>
1836 )}
1837 </>
1838 {!isQuote && (
1839 <div
1840 style={{
1841 display: "flex",
1842 gap: 32,
1843 paddingTop: 8,
1844 //color: theme.textSecondary,
1845 fontSize: 15,
1846 justifyContent: "space-between",
1847 //background: "#0f0",
1848 }}
1849 className="text-gray-500 dark:text-gray-400"
1850 >
1851 <HitSlopButton
1852 onClick={() => {
1853 setComposerPost({ kind: "reply", parent: post.uri });
1854 }}
1855 style={{
1856 ...btnstyle,
1857 }}
1858 >
1859 <MdiCommentOutline />
1860 {post.replyCount}
1861 </HitSlopButton>
1862 <DropdownMenu.Root modal={false}>
1863 <DropdownMenu.Trigger asChild>
1864 <div
1865 style={{
1866 ...btnstyle,
1867 ...(hasRetweeted ? { color: "#5CEFAA" } : {}),
1868 }}
1869 aria-label="Repost or quote post"
1870 >
1871 {hasRetweeted ? <MdiRepeatGreen /> : <MdiRepeat />}
1872 {post.repostCount ?? 0}
1873 </div>
1874 </DropdownMenu.Trigger>
1875
1876 <DropdownMenu.Portal>
1877 <DropdownMenu.Content
1878 align="start"
1879 sideOffset={5}
1880 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"
1881 >
1882 <DropdownMenu.Item
1883 onSelect={repostOrUnrepostPost}
1884 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"
1885 >
1886 <MdiRepeat
1887 className={hasRetweeted ? "text-green-400" : ""}
1888 />
1889 <span>{hasRetweeted ? "Undo Repost" : "Repost"}</span>
1890 </DropdownMenu.Item>
1891
1892 <DropdownMenu.Item
1893 onSelect={() => {
1894 setComposerPost({
1895 kind: "quote",
1896 subject: post.uri,
1897 });
1898 }}
1899 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"
1900 >
1901 {/* You might want a specific quote icon here */}
1902 <MdiCommentOutline />
1903 <span>Quote</span>
1904 </DropdownMenu.Item>
1905 </DropdownMenu.Content>
1906 </DropdownMenu.Portal>
1907 </DropdownMenu.Root>
1908 <HitSlopButton
1909 onClick={() => {
1910 likeOrUnlikePost();
1911 }}
1912 style={{
1913 ...btnstyle,
1914 ...(hasLiked ? { color: "#EC4899" } : {}),
1915 }}
1916 >
1917 {hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />}
1918 {(post.likeCount || 0) + (hasLiked ? 1 : 0)}
1919 </HitSlopButton>
1920 <div style={{ display: "flex", gap: 8 }}>
1921 <HitSlopButton
1922 onClick={async (e) => {
1923 e.stopPropagation();
1924 try {
1925 await navigator.clipboard.writeText(
1926 "https://bsky.app" +
1927 "/profile/" +
1928 post.author.handle +
1929 "/post/" +
1930 post.uri.split("/").pop()
1931 );
1932 } catch (_e) {
1933 // idk
1934 }
1935 }}
1936 style={{
1937 ...btnstyle,
1938 }}
1939 >
1940 <MdiShareVariant />
1941 </HitSlopButton>
1942 <span style={btnstyle}>
1943 <MdiMoreHoriz />
1944 </span>
1945 </div>
1946 </div>
1947 )}
1948 </div>
1949 <div
1950 style={{
1951 //height: bottomReplyLine ? 16 : 0
1952 height: isQuote ? 12 : 16,
1953 }}
1954 />
1955 </div>
1956 </div>
1957 </div>
1958 </div>
1959 );
1960}
1961
1962const fullDateTimeFormat = (iso: string) => {
1963 const date = new Date(iso);
1964 return date.toLocaleString("en-US", {
1965 month: "long",
1966 day: "numeric",
1967 year: "numeric",
1968 hour: "numeric",
1969 minute: "2-digit",
1970 hour12: true,
1971 });
1972};
1973const shortTimeAgo = (iso: string) => {
1974 const diff = Date.now() - new Date(iso).getTime();
1975 const mins = Math.floor(diff / 60000);
1976 if (mins < 1) return "now";
1977 if (mins < 60) return `${mins}m`;
1978 const hrs = Math.floor(mins / 60);
1979 if (hrs < 24) return `${hrs}h`;
1980 const days = Math.floor(hrs / 24);
1981 return `${days}d`;
1982};
1983
1984// const toAtUri = (url: string) =>
1985// url
1986// .replace("https://bsky.app/profile/", "at://")
1987// .replace("/feed/", "/app.bsky.feed.generator/");
1988
1989// function PostSizedElipsis() {
1990// return (
1991// <div
1992// style={{ display: "flex", flexDirection: "row", alignItems: "center" }}
1993// >
1994// <div
1995// style={{
1996// width: 2,
1997// height: 40,
1998// //background: theme.textSecondary,
1999// background: `repeating-linear-gradient(to bottom, var(--color-gray-400) 0px, var(--color-gray-400) 6px, transparent 6px, transparent 10px)`,
2000// backgroundSize: "100% 10px",
2001// opacity: 0.5,
2002// marginLeft: 36, // why 36 ???
2003// }}
2004// />
2005// <span
2006// style={{
2007// //color: theme.textSecondary,
2008// marginLeft: 34,
2009// }}
2010// className="text-gray-500 dark:text-gray-400"
2011// >
2012// more posts
2013// </span>
2014// </div>
2015// );
2016// }
2017
2018type Embed =
2019 | AppBskyEmbedRecord.View
2020 | AppBskyEmbedImages.View
2021 | AppBskyEmbedVideo.View
2022 | AppBskyEmbedExternal.View
2023 | AppBskyEmbedRecordWithMedia.View
2024 | { $type: string; [k: string]: unknown };
2025
2026enum PostEmbedViewContext {
2027 ThreadHighlighted = "ThreadHighlighted",
2028 Feed = "Feed",
2029 FeedEmbedRecordWithMedia = "FeedEmbedRecordWithMedia",
2030}
2031const stopgap = {
2032 display: "flex",
2033 justifyContent: "center",
2034 padding: "32px 12px",
2035 borderRadius: 12,
2036 border: "1px solid rgba(161, 170, 174, 0.38)",
2037};
2038
2039function PostEmbeds({
2040 embed,
2041 moderation,
2042 onOpen,
2043 allowNestedQuotes,
2044 viewContext,
2045 salt,
2046 navigate,
2047 postid,
2048 nopics,
2049 lightboxCallback,
2050}: {
2051 embed?: Embed;
2052 moderation?: ModerationDecision;
2053 onOpen?: () => void;
2054 allowNestedQuotes?: boolean;
2055 viewContext?: PostEmbedViewContext;
2056 salt: string;
2057 navigate: (_: any) => void;
2058 postid?: { did: string; rkey: string };
2059 nopics?: boolean;
2060 lightboxCallback?: (d: LightboxProps) => void;
2061}) {
2062 //const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
2063 function setLightboxIndex(number: number) {
2064 navigate({
2065 to: "/profile/$did/post/$rkey/image/$i",
2066 params: {
2067 did: postid?.did,
2068 rkey: postid?.rkey,
2069 i: number.toString(),
2070 },
2071 });
2072 }
2073 if (
2074 AppBskyEmbedRecordWithMedia.isView(embed) &&
2075 AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
2076 AppBskyFeedPost.isRecord(embed.record.record.value) //&&
2077 //AppBskyFeedPost.validateRecord(embed.record.record.value).success
2078 ) {
2079 const post: PostView = {
2080 $type: "app.bsky.feed.defs#postView", // lmao lies
2081 uri: embed.record.record.uri,
2082 cid: embed.record.record.cid,
2083 author: embed.record.record.author,
2084 record: embed.record.record.value as { [key: string]: unknown },
2085 embed: embed.record.record.embeds
2086 ? embed.record.record.embeds?.[0]
2087 : undefined, // quotes handles embeds differently, its an array for some reason
2088 replyCount: embed.record.record.replyCount,
2089 repostCount: embed.record.record.repostCount,
2090 likeCount: embed.record.record.likeCount,
2091 quoteCount: embed.record.record.quoteCount,
2092 indexedAt: embed.record.record.indexedAt,
2093 // we dont have a viewer, so this is a best effort conversion, still requires full query later on
2094 labels: embed.record.record.labels,
2095 // neither do we have threadgate. remember to please fetch the full post later
2096 };
2097 return (
2098 <div>
2099 <PostEmbeds
2100 embed={embed.media}
2101 moderation={moderation}
2102 onOpen={onOpen}
2103 viewContext={viewContext}
2104 salt={salt}
2105 navigate={navigate}
2106 postid={postid}
2107 nopics={nopics}
2108 lightboxCallback={lightboxCallback}
2109 />
2110 {/* padding empty div of 8px height */}
2111 <div style={{ height: 12 }} />
2112 {/* stopgap sorry*/}
2113 <div
2114 style={{
2115 display: "flex",
2116 flexDirection: "column",
2117 borderRadius: 12,
2118 //border: `1px solid ${theme.border}`,
2119 //boxShadow: theme.cardShadow,
2120 overflow: "hidden",
2121 }}
2122 className="shadow border border-gray-200 dark:border-gray-800 was7"
2123 >
2124 <UniversalPostRenderer
2125 post={post}
2126 isQuote
2127 salt={salt}
2128 onPostClick={(e) => {
2129 e.stopPropagation();
2130 const parsed = new AtUri(post.uri); //parseAtUri(post.uri);
2131 if (parsed) {
2132 navigate({
2133 to: "/profile/$did/post/$rkey",
2134 params: { did: parsed.host, rkey: parsed.rkey },
2135 });
2136 }
2137 }}
2138 depth={1}
2139 />
2140 </div>
2141 {/* <QuotePostRenderer
2142 record={embed.record.record}
2143 moderation={moderation}
2144 /> */}
2145 {/* stopgap sorry */}
2146 {/* <div style={stopgap}>quote post placeholder</div> */}
2147 {/* {<MaybeQuoteEmbed
2148 embed={embed.record}
2149 onOpen={onOpen}
2150 viewContext={
2151 viewContext === PostEmbedViewContext.Feed
2152 ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia
2153 : undefined
2154 }
2155 {/* <div style={stopgap}>quote post placeholder</div> */}
2156 {/* {<MaybeQuoteEmbed
2157 embed={embed.record}
2158 onOpen={onOpen}
2159 viewContext={
2160 viewContext === PostEmbedViewContext.Feed
2161 ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia
2162 : undefined
2163 }
2164 />} */}
2165 </div>
2166 );
2167 }
2168
2169 if (AppBskyEmbedRecord.isView(embed)) {
2170 // custom feed embed (i.e. generator view)
2171 if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
2172 // stopgap sorry
2173 return <div style={stopgap}>feedgen placeholder</div>;
2174 // return (
2175 // <div style={{ marginTop: '1rem' }}>
2176 // <MaybeFeedCard view={embed.record} />
2177 // </div>
2178 // )
2179 }
2180
2181 // list embed
2182 if (AppBskyGraphDefs.isListView(embed.record)) {
2183 // stopgap sorry
2184 return <div style={stopgap}>list placeholder</div>;
2185 // return (
2186 // <div style={{ marginTop: '1rem' }}>
2187 // <MaybeListCard view={embed.record} />
2188 // </div>
2189 // )
2190 }
2191
2192 // starter pack embed
2193 if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) {
2194 // stopgap sorry
2195 return <div style={stopgap}>starter pack card placeholder</div>;
2196 // return (
2197 // <div style={{ marginTop: '1rem' }}>
2198 // <StarterPackCard starterPack={embed.record} />
2199 // </div>
2200 // )
2201 }
2202
2203 // quote post
2204 // =
2205 // stopgap sorry
2206
2207 if (
2208 AppBskyEmbedRecord.isViewRecord(embed.record) &&
2209 AppBskyFeedPost.isRecord(embed.record.value) // &&
2210 //AppBskyFeedPost.validateRecord(embed.record.value).success
2211 ) {
2212 const post: PostView = {
2213 $type: "app.bsky.feed.defs#postView", // lmao lies
2214 uri: embed.record.uri,
2215 cid: embed.record.cid,
2216 author: embed.record.author,
2217 record: embed.record.value as { [key: string]: unknown },
2218 embed: embed.record.embeds ? embed.record.embeds?.[0] : undefined, // quotes handles embeds differently, its an array for some reason
2219 replyCount: embed.record.replyCount,
2220 repostCount: embed.record.repostCount,
2221 likeCount: embed.record.likeCount,
2222 quoteCount: embed.record.quoteCount,
2223 indexedAt: embed.record.indexedAt,
2224 // we dont have a viewer, so this is a best effort conversion, still requires full query later on
2225 labels: embed.record.labels,
2226 // neither do we have threadgate. remember to please fetch the full post later
2227 };
2228
2229 return (
2230 <div
2231 style={{
2232 display: "flex",
2233 flexDirection: "column",
2234 borderRadius: 12,
2235 //border: `1px solid ${theme.border}`,
2236 //boxShadow: theme.cardShadow,
2237 overflow: "hidden",
2238 }}
2239 className="shadow border border-gray-200 dark:border-gray-800 was7"
2240 >
2241 <UniversalPostRenderer
2242 post={post}
2243 isQuote
2244 salt={salt}
2245 onPostClick={(e) => {
2246 e.stopPropagation();
2247 const parsed = new AtUri(post.uri); //parseAtUri(post.uri);
2248 if (parsed) {
2249 navigate({
2250 to: "/profile/$did/post/$rkey",
2251 params: { did: parsed.host, rkey: parsed.rkey },
2252 });
2253 }
2254 }}
2255 depth={1}
2256 />
2257 </div>
2258 );
2259 } else {
2260 return <>sorry</>;
2261 }
2262 //return <QuotePostRenderer record={embed.record} moderation={moderation} />;
2263
2264 //return <div style={stopgap}>quote post placeholder</div>;
2265 // return (
2266 // <MaybeQuoteEmbed
2267 // embed={embed}
2268 // onOpen={onOpen}
2269 // allowNestedQuotes={allowNestedQuotes}
2270 // />
2271 // )
2272 }
2273
2274 // image embed
2275 // =
2276 if (AppBskyEmbedImages.isView(embed)) {
2277 const { images } = embed;
2278
2279 const lightboxImages = images.map((img) => ({
2280 src: img.fullsize,
2281 alt: img.alt,
2282 }));
2283 console.log("rendering images");
2284 if (lightboxCallback) {
2285 lightboxCallback({ images: lightboxImages });
2286 console.log("rendering images");
2287 }
2288
2289 if (nopics) return;
2290
2291 if (images.length > 0) {
2292 // const items = embed.images.map(img => ({
2293 // uri: img.fullsize,
2294 // thumbUri: img.thumb,
2295 // alt: img.alt,
2296 // dimensions: img.aspectRatio ?? null,
2297 // }))
2298
2299 if (images.length === 1) {
2300 const image = images[0];
2301 return (
2302 <div style={{ marginTop: 0 }}>
2303 <div
2304 style={{
2305 position: "relative",
2306 width: "100%",
2307 aspectRatio: image.aspectRatio
2308 ? (() => {
2309 const { width, height } = image.aspectRatio;
2310 const ratio = width / height;
2311 return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`;
2312 })()
2313 : "1 / 1", // fallback to square
2314 //backgroundColor: theme.background, // fallback letterboxing color
2315 borderRadius: 12,
2316 //border: `1px solid ${theme.border}`,
2317 overflow: "hidden",
2318 }}
2319 className="border border-gray-200 dark:border-gray-800 was7 bg-gray-200 dark:bg-gray-900"
2320 >
2321 {/* {lightboxIndex !== null && (
2322 <Lightbox
2323 images={lightboxImages}
2324 index={lightboxIndex}
2325 onClose={() => setLightboxIndex(null)}
2326 onNavigate={(newIndex) => setLightboxIndex(newIndex)}
2327 post={postid}
2328 />
2329 )} */}
2330 <img
2331 src={image.fullsize}
2332 alt={image.alt}
2333 style={{
2334 width: "100%",
2335 height: "100%",
2336 objectFit: "contain", // letterbox or scale to fit
2337 }}
2338 onClick={(e) => {
2339 e.stopPropagation();
2340 setLightboxIndex(0);
2341 }}
2342 />
2343 </div>
2344 </div>
2345 );
2346 }
2347 // 2 images: side by side, both 1:1, cropped
2348 if (images.length === 2) {
2349 return (
2350 <div
2351 style={{
2352 display: "flex",
2353 gap: 4,
2354 marginTop: 0,
2355 width: "100%",
2356 borderRadius: 12,
2357 overflow: "hidden",
2358 //border: `1px solid ${theme.border}`,
2359 }}
2360 className="border border-gray-200 dark:border-gray-800 was7"
2361 >
2362 {/* {lightboxIndex !== null && (
2363 <Lightbox
2364 images={lightboxImages}
2365 index={lightboxIndex}
2366 onClose={() => setLightboxIndex(null)}
2367 onNavigate={(newIndex) => setLightboxIndex(newIndex)}
2368 post={postid}
2369 />
2370 )} */}
2371 {images.map((img, i) => (
2372 <div
2373 key={i}
2374 style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }}
2375 >
2376 <img
2377 src={img.fullsize}
2378 alt={img.alt}
2379 style={{
2380 width: "100%",
2381 height: "100%",
2382 objectFit: "cover",
2383 borderRadius: i === 0 ? "12px 0 0 12px" : "0 12px 12px 0",
2384 }}
2385 onClick={(e) => {
2386 e.stopPropagation();
2387 setLightboxIndex(i);
2388 }}
2389 />
2390 </div>
2391 ))}
2392 </div>
2393 );
2394 }
2395
2396 // 3 images: left is 1:1, right is two stacked 2:1
2397 if (images.length === 3) {
2398 return (
2399 <div
2400 style={{
2401 display: "flex",
2402 gap: 4,
2403 marginTop: 0,
2404 width: "100%",
2405 borderRadius: 12,
2406 overflow: "hidden",
2407 //border: `1px solid ${theme.border}`,
2408 // height: 240, // fixed height for cropping
2409 }}
2410 className="border border-gray-200 dark:border-gray-800 was7"
2411 >
2412 {/* {lightboxIndex !== null && (
2413 <Lightbox
2414 images={lightboxImages}
2415 index={lightboxIndex}
2416 onClose={() => setLightboxIndex(null)}
2417 onNavigate={(newIndex) => setLightboxIndex(newIndex)}
2418 post={postid}
2419 />
2420 )} */}
2421 {/* Left: 1:1 */}
2422 <div
2423 style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }}
2424 >
2425 <img
2426 src={images[0].fullsize}
2427 alt={images[0].alt}
2428 style={{
2429 width: "100%",
2430 height: "100%",
2431 objectFit: "cover",
2432 borderRadius: "12px 0 0 12px",
2433 }}
2434 onClick={(e) => {
2435 e.stopPropagation();
2436 setLightboxIndex(0);
2437 }}
2438 />
2439 </div>
2440 {/* Right: two stacked 2:1 */}
2441 <div
2442 style={{
2443 flex: 1,
2444 display: "flex",
2445 flexDirection: "column",
2446 gap: 4,
2447 }}
2448 >
2449 {[1, 2].map((i) => (
2450 <div
2451 key={i}
2452 style={{
2453 flex: 1,
2454 aspectRatio: "2 / 1",
2455 position: "relative",
2456 }}
2457 >
2458 <img
2459 src={images[i].fullsize}
2460 alt={images[i].alt}
2461 style={{
2462 width: "100%",
2463 height: "100%",
2464 objectFit: "cover",
2465 borderRadius: i === 1 ? "0 12px 0 0" : "0 0 12px 0",
2466 }}
2467 onClick={(e) => {
2468 e.stopPropagation();
2469 setLightboxIndex(i + 1);
2470 }}
2471 />
2472 </div>
2473 ))}
2474 </div>
2475 </div>
2476 );
2477 }
2478
2479 // 4 images: 2x2 grid, all 3:2
2480 if (images.length === 4) {
2481 return (
2482 <div
2483 style={{
2484 display: "grid",
2485 gridTemplateColumns: "1fr 1fr",
2486 gridTemplateRows: "1fr 1fr",
2487 gap: 4,
2488 marginTop: 0,
2489 width: "100%",
2490 borderRadius: 12,
2491 overflow: "hidden",
2492 //border: `1px solid ${theme.border}`,
2493 //aspectRatio: "3 / 2", // overall grid aspect
2494 }}
2495 className="border border-gray-200 dark:border-gray-800 was7"
2496 >
2497 {/* {lightboxIndex !== null && (
2498 <Lightbox
2499 images={lightboxImages}
2500 index={lightboxIndex}
2501 onClose={() => setLightboxIndex(null)}
2502 onNavigate={(newIndex) => setLightboxIndex(newIndex)}
2503 post={postid}
2504 />
2505 )} */}
2506 {images.map((img, i) => (
2507 <div
2508 key={i}
2509 style={{
2510 width: "100%",
2511 height: "100%",
2512 aspectRatio: "3 / 2",
2513 position: "relative",
2514 }}
2515 >
2516 <img
2517 src={img.fullsize}
2518 alt={img.alt}
2519 style={{
2520 width: "100%",
2521 height: "100%",
2522 objectFit: "cover",
2523 borderRadius:
2524 i === 0
2525 ? "12px 0 0 0"
2526 : i === 1
2527 ? "0 12px 0 0"
2528 : i === 2
2529 ? "0 0 0 12px"
2530 : "0 0 12px 0",
2531 }}
2532 onClick={(e) => {
2533 e.stopPropagation();
2534 setLightboxIndex(i);
2535 }}
2536 />
2537 </div>
2538 ))}
2539 </div>
2540 );
2541 }
2542
2543 // stopgap sorry
2544 return <div style={stopgap}>image count more than one placeholder</div>;
2545 // return (
2546 // <div style={{ marginTop: '1rem' }}>
2547 // <ImageLayoutGrid
2548 // images={images}
2549 // viewContext={viewContext}
2550 // />
2551 // </div>
2552 // )
2553 }
2554 }
2555
2556 // external link embed
2557 // =
2558 if (AppBskyEmbedExternal.isView(embed)) {
2559 const link = embed.external;
2560 return (
2561 <ExternalLinkEmbed link={link} onOpen={onOpen} style={{ marginTop: 0 }} />
2562 );
2563 }
2564
2565 // video embed
2566 // =
2567 if (AppBskyEmbedVideo.isView(embed)) {
2568 // hls playlist
2569 const playlist = embed.playlist;
2570 return (
2571 <SmartHLSPlayer
2572 url={playlist}
2573 thumbnail={embed.thumbnail}
2574 aspect={embed.aspectRatio}
2575 />
2576 );
2577 // stopgap sorry
2578 //return (<div>video</div>)
2579 // return (
2580 // <VideoEmbed
2581 // embed={embed}
2582 // crop={
2583 // viewContext === PostEmbedViewContext.ThreadHighlighted
2584 // ? 'none'
2585 // : viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
2586 // ? 'square'
2587 // : 'constrained'
2588 // }
2589 // />
2590 // )
2591 }
2592
2593 return <div />;
2594}
2595
2596function getDomain(url: string) {
2597 try {
2598 const { hostname } = new URL(url);
2599 return hostname;
2600 } catch (e) {
2601 // In case it's a bare domain like "example.com"
2602 if (!url.startsWith("http")) {
2603 try {
2604 const { hostname } = new URL("http://" + url);
2605 return hostname;
2606 } catch {
2607 return null;
2608 }
2609 }
2610 return null;
2611 }
2612}
2613function getByteToCharMap(text: string): number[] {
2614 const encoder = new TextEncoder();
2615 //const utf8 = encoder.encode(text);
2616
2617 const map: number[] = [];
2618 let byteIndex = 0;
2619 let charIndex = 0;
2620
2621 for (const char of text) {
2622 const bytes = encoder.encode(char);
2623 for (let i = 0; i < bytes.length; i++) {
2624 map[byteIndex++] = charIndex;
2625 }
2626 charIndex += char.length;
2627 }
2628
2629 return map;
2630}
2631
2632function facetByteRangeToCharRange(
2633 byteStart: number,
2634 byteEnd: number,
2635 byteToCharMap: number[]
2636): [number, number] {
2637 return [
2638 byteToCharMap[byteStart] ?? 0,
2639 byteToCharMap[byteEnd - 1]! + 1, // inclusive end -> exclusive char end
2640 ];
2641}
2642
2643interface FacetRange {
2644 start: number;
2645 end: number;
2646 feature: Facet["features"][number];
2647}
2648
2649function extractFacetRanges(text: string, facets: Facet[]): FacetRange[] {
2650 const map = getByteToCharMap(text);
2651 return facets.map((f) => {
2652 const [start, end] = facetByteRangeToCharRange(
2653 f.index.byteStart,
2654 f.index.byteEnd,
2655 map
2656 );
2657 return { start, end, feature: f.features[0] };
2658 });
2659}
2660export function renderTextWithFacets({
2661 text,
2662 facets,
2663 navigate,
2664}: {
2665 text: string;
2666 facets: Facet[];
2667 navigate: (_: any) => void;
2668}) {
2669 const ranges = extractFacetRanges(text, facets).sort(
2670 (a: any, b: any) => a.start - b.start
2671 );
2672
2673 const result: React.ReactNode[] = [];
2674 let current = 0;
2675
2676 for (const { start, end, feature } of ranges) {
2677 if (current < start) {
2678 result.push(<span key={current}>{text.slice(current, start)}</span>);
2679 }
2680
2681 const fragment = text.slice(start, end);
2682 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed
2683 if (feature.$type === "app.bsky.richtext.facet#link" && feature.uri) {
2684 result.push(
2685 <a
2686 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed
2687 href={feature.uri}
2688 key={start}
2689 className="link"
2690 style={{
2691 textDecoration: "none",
2692 color: "rgb(29, 122, 242)",
2693 wordBreak: "break-all",
2694 }}
2695 target="_blank"
2696 rel="noreferrer"
2697 onClick={(e) => {
2698 e.stopPropagation();
2699 }}
2700 >
2701 {fragment}
2702 </a>
2703 );
2704 } else if (
2705 feature.$type === "app.bsky.richtext.facet#mention" &&
2706 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed
2707 feature.did
2708 ) {
2709 result.push(
2710 <span
2711 key={start}
2712 style={{ color: "rgb(29, 122, 242)" }}
2713 className=" cursor-pointer"
2714 onClick={(e) => {
2715 e.stopPropagation();
2716 navigate({
2717 to: "/profile/$did",
2718 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed
2719 params: { did: feature.did },
2720 });
2721 }}
2722 >
2723 {fragment}
2724 </span>
2725 );
2726 } else if (feature.$type === "app.bsky.richtext.facet#tag") {
2727 result.push(
2728 <span
2729 key={start}
2730 style={{ color: "rgb(29, 122, 242)" }}
2731 onClick={(e) => {
2732 e.stopPropagation();
2733 }}
2734 >
2735 {fragment}
2736 </span>
2737 );
2738 } else {
2739 result.push(<span key={start}>{fragment}</span>);
2740 }
2741
2742 current = end;
2743 }
2744
2745 if (current < text.length) {
2746 result.push(<span key={current}>{text.slice(current)}</span>);
2747 }
2748
2749 return result;
2750}
2751function ExternalLinkEmbed({
2752 link,
2753 onOpen,
2754 style,
2755}: {
2756 link: AppBskyEmbedExternal.ViewExternal;
2757 onOpen?: () => void;
2758 style?: React.CSSProperties;
2759}) {
2760 //const { theme } = useTheme();
2761 const { uri, title, description, thumb } = link;
2762 const thumbAspectRatio = 1.91;
2763 const titleStyle = {
2764 fontSize: 16,
2765 fontWeight: 700,
2766 marginBottom: 4,
2767 //color: theme.text,
2768 wordBreak: "break-word",
2769 textAlign: "left",
2770 maxHeight: "4em", // 2 lines * 1.5em line-height
2771 // stupid shit
2772 display: "-webkit-box",
2773 WebkitBoxOrient: "vertical",
2774 overflow: "hidden",
2775 WebkitLineClamp: 2,
2776 };
2777 const descriptionStyle = {
2778 fontSize: 14,
2779 //color: theme.textSecondary,
2780 marginBottom: 8,
2781 wordBreak: "break-word",
2782 textAlign: "left",
2783 maxHeight: "5em", // 3 lines * 1.5em line-height
2784 // stupid shit
2785 display: "-webkit-box",
2786 WebkitBoxOrient: "vertical",
2787 overflow: "hidden",
2788 WebkitLineClamp: 3,
2789 };
2790 const linkStyle = {
2791 textDecoration: "none",
2792 //color: theme.textSecondary,
2793 wordBreak: "break-all",
2794 textAlign: "left",
2795 };
2796 const containerStyle = {
2797 display: "flex",
2798 flexDirection: "column",
2799 //backgroundColor: theme.background,
2800 //background: '#eee',
2801 borderRadius: 12,
2802 //border: `1px solid ${theme.border}`,
2803 //boxShadow: theme.cardShadow,
2804 maxWidth: "100%",
2805 overflow: "hidden",
2806 ...style,
2807 };
2808 return (
2809 <a
2810 href={uri}
2811 target="_blank"
2812 rel="noopener noreferrer"
2813 onClick={(e) => {
2814 e.stopPropagation();
2815 if (onOpen) onOpen();
2816 }}
2817 /* @ts-expect-error css arent typed or something idk fuck you */
2818 style={linkStyle}
2819 className="text-gray-500 dark:text-gray-400"
2820 >
2821 <div
2822 style={containerStyle as React.CSSProperties}
2823 className="border border-gray-200 dark:border-gray-800 was7"
2824 >
2825 {thumb && (
2826 <div
2827 style={{
2828 position: "relative",
2829 width: "100%",
2830 aspectRatio: thumbAspectRatio,
2831 overflow: "hidden",
2832 borderTopLeftRadius: 12,
2833 borderTopRightRadius: 12,
2834 marginBottom: 8,
2835 //borderBottom: `1px solid ${theme.border}`,
2836 }}
2837 className="border-b border-gray-200 dark:border-gray-800 was7"
2838 >
2839 <img
2840 src={thumb}
2841 alt={description}
2842 style={{
2843 position: "absolute",
2844 top: 0,
2845 left: 0,
2846 width: "100%",
2847 height: "100%",
2848 objectFit: "cover",
2849 }}
2850 />
2851 </div>
2852 )}
2853 <div
2854 style={{
2855 paddingBottom: 12,
2856 paddingLeft: 12,
2857 paddingRight: 12,
2858 paddingTop: thumb ? 0 : 12,
2859 }}
2860 >
2861 {/* @ts-expect-error css */}
2862 <div style={titleStyle} className="text-gray-900 dark:text-gray-100">
2863 {title}
2864 </div>
2865 <div
2866 style={descriptionStyle as React.CSSProperties}
2867 className="text-gray-500 dark:text-gray-400"
2868 >
2869 {description}
2870 </div>
2871 {/* small 1px divider here */}
2872 <div
2873 style={{
2874 height: 1,
2875 //backgroundColor: theme.border,
2876 marginBottom: 8,
2877 }}
2878 className="bg-gray-200 dark:bg-gray-700"
2879 />
2880 <div
2881 style={{
2882 display: "flex",
2883 alignItems: "center",
2884 gap: 4,
2885 }}
2886 >
2887 <MdiGlobe />
2888 <span
2889 style={{
2890 fontSize: 12,
2891 //color: theme.textSecondary
2892 }}
2893 className="text-gray-500 dark:text-gray-400"
2894 >
2895 {getDomain(uri)}
2896 </span>
2897 </div>
2898 </div>
2899 </div>
2900 </a>
2901 );
2902}
2903
2904const SmartHLSPlayer = ({
2905 url,
2906 thumbnail,
2907 aspect,
2908}: {
2909 url: string;
2910 thumbnail?: string;
2911 aspect?: AppBskyEmbedDefs.AspectRatio;
2912}) => {
2913 const [playing, setPlaying] = useState(false);
2914 const containerRef = useRef(null);
2915
2916 // pause the player if it goes out of viewport
2917 useEffect(() => {
2918 const observer = new IntersectionObserver(
2919 ([entry]) => {
2920 if (!entry.isIntersecting && playing) {
2921 setPlaying(false);
2922 }
2923 },
2924 {
2925 root: null,
2926 threshold: 0.25,
2927 }
2928 );
2929
2930 if (containerRef.current) {
2931 observer.observe(containerRef.current);
2932 }
2933
2934 return () => {
2935 if (containerRef.current) {
2936 observer.unobserve(containerRef.current);
2937 }
2938 };
2939 }, [playing]);
2940
2941 return (
2942 <div
2943 ref={containerRef}
2944 style={{
2945 position: "relative",
2946 width: "100%",
2947 maxWidth: 640,
2948 cursor: "pointer",
2949 }}
2950 >
2951 {!playing && (
2952 <>
2953 <img
2954 src={thumbnail}
2955 alt="Video thumbnail"
2956 style={{
2957 width: "100%",
2958 display: "block",
2959 aspectRatio: aspect ? aspect?.width / aspect?.height : 16 / 9,
2960 borderRadius: 12,
2961 //border: `1px solid ${theme.border}`,
2962 }}
2963 className="border border-gray-200 dark:border-gray-800 was7"
2964 onClick={async (e) => {
2965 e.stopPropagation();
2966 setPlaying(true);
2967 }}
2968 />
2969 <div
2970 onClick={async (e) => {
2971 e.stopPropagation();
2972 setPlaying(true);
2973 }}
2974 style={{
2975 position: "absolute",
2976 top: "50%",
2977 left: "50%",
2978 transform: "translate(-50%, -50%)",
2979 //fontSize: 48,
2980 color: "white",
2981 //textShadow: theme.cardShadow,
2982 pointerEvents: "none",
2983 userSelect: "none",
2984 }}
2985 className="text-shadow-md"
2986 >
2987 {/*▶️*/}
2988 <MdiPlayCircle />
2989 </div>
2990 </>
2991 )}
2992 {playing && (
2993 <div
2994 style={{
2995 position: "relative",
2996 width: "100%",
2997 borderRadius: 12,
2998 overflow: "hidden",
2999 //border: `1px solid ${theme.border}`,
3000 paddingTop: `${
3001 100 / (aspect ? aspect.width / aspect.height : 16 / 9)
3002 }%`, // 16:9 = 56.25%, 4:3 = 75%
3003 }}
3004 className="border border-gray-200 dark:border-gray-800 was7"
3005 >
3006 <ReactPlayer
3007 src={url}
3008 playing={true}
3009 controls={true}
3010 width="100%"
3011 height="100%"
3012 style={{ position: "absolute", top: 0, left: 0 }}
3013 />
3014 {/* <ReactPlayer
3015 url={url}
3016 playing={true}
3017 controls={true}
3018 width="100%"
3019 style={{width: "100% !important", aspectRatio: aspect ? aspect?.width/aspect?.height : 16/9}}
3020 onPause={() => setPlaying(false)}
3021 onEnded={() => setPlaying(false)}
3022 /> */}
3023 </div>
3024 )}
3025 </div>
3026 );
3027};