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