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