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