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