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