an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
1import * as ATPAPI from "@atproto/api";
2import {
3 AppBskyActorDefs,
4 AppBskyFeedDefs,
5 AppBskyFeedPost,
6 AtUri,
7 type Facet,
8} from "@atproto/api";
9import { useInfiniteQuery } from "@tanstack/react-query";
10import { useNavigate } from "@tanstack/react-router";
11import DOMPurify from "dompurify";
12import { useAtom } from "jotai";
13import { DropdownMenu } from "radix-ui";
14import { HoverCard } from "radix-ui";
15import * as React from "react";
16import { useEffect, useState } from "react";
17
18import { FORCE_HIDE_LABELS, FORCE_HIDE_LABELS_WHITELISTED_SOURCE, UNAUTHED_PREVENT_OPENING_WARNS } from "~/../policy";
19import defaultpfp from "~/../public/defaultpfp.png";
20import { getGetHydratedLabelDefs, useAutoLabels } from "~/hooks/useAutoLabels";
21import { useLabelInfo } from "~/hooks/useLabelInfo";
22//import { useModeration } from "~/hooks/useModeration";
23import { useAuth } from "~/providers/UnifiedAuthProvider";
24import { renderSnack } from "~/routes/__root";
25//import { ModerationInner } from "~/routes/moderation";
26import { FollowButton, getLocaleLabel, type LabelWithHydratedLocaleName, Mutual } from "~/routes/profile.$did";
27import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
28//import type { ContentLabel } from "~/types/moderation";
29import {
30 appviewUrlAtom,
31 composerAtom,
32 constellationURLAtom,
33 enableAppViewAtom,
34 enableBridgyTextAtom,
35 enableWafrnTextAtom,
36 imgCDNAtom,
37} from "~/utils/atoms";
38import { useGetOneToOneState } from "~/utils/followState";
39import { useFastLike } from "~/utils/likeMutationQueue";
40import { useHydratedEmbed } from "~/utils/useHydrated";
41import {
42 useQueryConstellation,
43 useQueryIdentity,
44 useQueryPost,
45 useQueryProfile,
46 useQuerySingularAVPostQuery,
47 yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks,
48} from "~/utils/useQuery";
49
50import { PostEmbeds } from "./PostEmbeds";
51import {
52 btnstyle,
53 fullDateTimeFormat,
54 HitSlopButton,
55 randomString,
56 renderTextWithFacets,
57 shortTimeAgo,
58} from "./UtilityFunctions";
59
60export interface UniversalPostRendererATURILoaderProps {
61 atUri: string;
62 onConstellation?: (data: any) => void;
63 detailed?: boolean;
64 bottomReplyLine?: boolean;
65 topReplyLine?: boolean;
66 bottomBorder?: boolean;
67 feedviewpost?: boolean;
68 repostedby?: string;
69 style?: React.CSSProperties;
70 ref?: React.RefObject<HTMLDivElement>;
71 dataIndexPropPass?: number;
72 nopics?: boolean;
73 concise?: boolean;
74 lightboxCallback?: (d: LightboxProps) => void;
75 maxReplies?: number;
76 isQuote?: boolean;
77 filterNoReplies?: boolean;
78 filterMustHaveMedia?: boolean;
79 filterMustBeReply?: boolean;
80}
81
82export function UniversalPostRendererATURILoader({
83 atUri,
84 onConstellation,
85 detailed = false,
86 bottomReplyLine,
87 topReplyLine,
88 bottomBorder = true,
89 feedviewpost = false,
90 repostedby,
91 style,
92 ref,
93 dataIndexPropPass,
94 nopics,
95 concise,
96 lightboxCallback,
97 maxReplies,
98 isQuote,
99 filterNoReplies,
100 filterMustHaveMedia,
101 filterMustBeReply,
102}: UniversalPostRendererATURILoaderProps) {
103 const [usesAV] = useAtom(enableAppViewAtom);
104 if (usesAV) {
105 return (
106 <UniversalPostRendererATURILoader_AppView
107 atUri={atUri}
108 onConstellation={onConstellation}
109 detailed={detailed}
110 bottomReplyLine={bottomReplyLine}
111 topReplyLine={topReplyLine}
112 bottomBorder={bottomBorder}
113 feedviewpost={feedviewpost}
114 repostedby={repostedby}
115 style={style}
116 ref={ref}
117 dataIndexPropPass={dataIndexPropPass}
118 nopics={nopics}
119 concise={concise}
120 lightboxCallback={lightboxCallback}
121 maxReplies={maxReplies}
122 isQuote={isQuote}
123 filterNoReplies={filterNoReplies}
124 filterMustHaveMedia={filterMustHaveMedia}
125 filterMustBeReply={filterMustBeReply}
126 />
127 )
128 }
129 return (
130 <UniversalPostRendererATURILoader_Microcosm
131 atUri={atUri}
132 onConstellation={onConstellation}
133 detailed={detailed}
134 bottomReplyLine={bottomReplyLine}
135 topReplyLine={topReplyLine}
136 bottomBorder={bottomBorder}
137 feedviewpost={feedviewpost}
138 repostedby={repostedby}
139 style={style}
140 ref={ref}
141 dataIndexPropPass={dataIndexPropPass}
142 nopics={nopics}
143 concise={concise}
144 lightboxCallback={lightboxCallback}
145 maxReplies={maxReplies}
146 isQuote={isQuote}
147 filterNoReplies={filterNoReplies}
148 filterMustHaveMedia={filterMustHaveMedia}
149 filterMustBeReply={filterMustBeReply}
150 />
151 )
152}
153/*
154 todo:
155 - either
156 - put constellation based reply threading or
157 - use a getPostThreadV2 once for quick reply threadings (the post thread page always
158 fetches replies via constellation for complteness)
159 - do the profile pages too
160 */
161export function UniversalPostRendererATURILoader_AppView({
162 atUri,
163 onConstellation,
164 detailed = false,
165 bottomReplyLine,
166 topReplyLine,
167 bottomBorder = true,
168 feedviewpost = false,
169 repostedby,
170 style,
171 ref,
172 dataIndexPropPass,
173 nopics,
174 concise,
175 lightboxCallback,
176 maxReplies,
177 isQuote,
178 filterNoReplies,
179 filterMustHaveMedia,
180 filterMustBeReply,
181}: UniversalPostRendererATURILoaderProps) {
182 const [avurl] = useAtom(appviewUrlAtom);
183 const navigate = useNavigate();
184 const parsedaturi = new AtUri(atUri);
185
186 const { data, isLoading, isEnabled, isError, error } = useQuerySingularAVPostQuery({ aturi: atUri, avurl: avurl });
187
188
189 const thereply = (data?.record as AppBskyFeedPost.Record)?.reply?.parent
190 ?.uri;
191 const feedviewpostreplydid =
192 thereply && !filterNoReplies ? new AtUri(thereply).host : undefined;
193 const replyhookvalue = useQueryIdentity(
194 feedviewpost ? feedviewpostreplydid : undefined,
195 );
196 const feedviewpostreplyhandle = replyhookvalue?.data?.handle;
197
198 const aturirepostbydid = repostedby ? new AtUri(repostedby).host : undefined;
199 const repostedbyhookvalue = useQueryIdentity(
200 repostedby ? aturirepostbydid : undefined,
201 );
202 const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle;
203 if (!isLoading && data === undefined) {
204 return (
205 <UniversalPostRendererATURILoader_Microcosm
206 atUri={atUri}
207 onConstellation={onConstellation}
208 detailed={detailed}
209 bottomReplyLine={bottomReplyLine}
210 topReplyLine={topReplyLine}
211 bottomBorder={bottomBorder}
212 feedviewpost={feedviewpost}
213 repostedby={repostedby}
214 style={style}
215 ref={ref}
216 dataIndexPropPass={dataIndexPropPass}
217 nopics={nopics}
218 concise={concise}
219 lightboxCallback={lightboxCallback}
220 maxReplies={maxReplies}
221 isQuote={isQuote}
222 filterNoReplies={filterNoReplies}
223 filterMustHaveMedia={filterMustHaveMedia}
224 filterMustBeReply={filterMustBeReply}
225 />
226 )
227 }
228 return (
229 <UniversalPostRenderer
230 referral={["appview"]}
231 expanded={detailed}
232 onPostClick={() =>
233 parsedaturi &&
234 navigate({
235 to: "/profile/$did/post/$rkey",
236 params: { did: parsedaturi.host, rkey: parsedaturi.rkey },
237 })
238 }
239 onProfileClick={(e) => {
240 e.stopPropagation();
241 if (parsedaturi) {
242 navigate({
243 to: "/profile/$did",
244 params: { did: parsedaturi.host },
245 });
246 }
247 }}
248 post={data || {
249 uri: atUri,
250 cid: atUri,
251 author: {
252 did: parsedaturi.host,
253 handle: parsedaturi.host,
254 },
255 record: {},
256 indexedAt: "",
257 }} // todo: this is bad. just make it so that UPR allows missing data
258 uprrrsauthor={{
259 ...(data?.author ||
260 {
261 did: parsedaturi.host,
262 handle: parsedaturi.host,
263 }),
264 "$type": "app.bsky.actor.defs#profileViewDetailed",
265 }}
266 salt={atUri}
267 bottomReplyLine={bottomReplyLine}
268 topReplyLine={topReplyLine}
269 bottomBorder={bottomBorder}
270 feedviewpost={feedviewpost}
271 feedviewpostreplyhandle={feedviewpostreplyhandle}
272 repostedby={feedviewpostrepostedbyhandle}
273 style={style}
274 ref={ref}
275 dataIndexPropPass={dataIndexPropPass}
276 nopics={nopics}
277 concise={concise}
278 lightboxCallback={lightboxCallback}
279 maxReplies={maxReplies}
280 isQuote={isQuote}
281 constellationLinks={{}}
282 />
283 )
284}
285export function UniversalPostRendererATURILoader_Microcosm({
286 atUri,
287 onConstellation,
288 detailed = false,
289 bottomReplyLine,
290 topReplyLine,
291 bottomBorder = true,
292 feedviewpost = false,
293 repostedby,
294 style,
295 ref,
296 dataIndexPropPass,
297 nopics,
298 concise,
299 lightboxCallback,
300 maxReplies,
301 isQuote,
302 filterNoReplies,
303 filterMustHaveMedia,
304 filterMustBeReply,
305}: UniversalPostRendererATURILoaderProps) {
306 const TEMPLINEAR = true;
307 const parsed = new AtUri(atUri);
308 const did = parsed?.host;
309 const rkey = parsed?.rkey;
310
311 const {
312 data: postQuery,
313 isLoading: isPostLoading,
314 isError: isPostError,
315 } = useQueryPost(atUri);
316
317 const { data: resolved } = useQueryIdentity(did || "");
318
319 const { data: links } = useQueryConstellation({
320 method: "/links/all",
321 target: atUri,
322 });
323
324 const { data: opProfile } = useQueryProfile(
325 resolved ? `at://${resolved?.did}/app.bsky.actor.profile/self` : undefined,
326 );
327
328 const [likes, setLikes] = React.useState<number | null>(null);
329 const [reposts, setReposts] = React.useState<number | null>(null);
330 const [replies, setReplies] = React.useState<number | null>(null);
331
332 React.useEffect(() => {
333 setLikes(
334 links
335 ? links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0
336 : null,
337 );
338 setReposts(
339 links
340 // add the two quote forms as well
341 ? links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records
342 // .embed.record.uri
343 + links?.links?.["app.bsky.feed.post"]?.[".embed.record.uri"]?.records
344 // .embed.record.record.uri
345 + links?.links?.["app.bsky.feed.post"]?.[".embed.record.record.uri"]?.records
346 || 0
347 : null,
348 );
349 setReplies(
350 links
351 ? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]
352 ?.records || 0
353 : null,
354 );
355 }, [links]);
356
357 const [constellationurl] = useAtom(constellationURLAtom);
358
359 const infinitequeryresults = useInfiniteQuery({
360 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(
361 {
362 constellation: constellationurl,
363 method: "/links",
364 target: atUri,
365 collection: "app.bsky.feed.post",
366 path: ".reply.parent.uri",
367 },
368 ),
369 enabled: !!atUri && !!maxReplies && !isQuote,
370 });
371
372 const { data: repliesData } = infinitequeryresults;
373
374 useEffect(() => {
375 if (!maxReplies || isQuote || TEMPLINEAR) return;
376 if (
377 infinitequeryresults.hasNextPage &&
378 !infinitequeryresults.isFetchingNextPage
379 ) {
380 console.log("Fetching the next page...");
381 infinitequeryresults.fetchNextPage();
382 }
383 }, [TEMPLINEAR, infinitequeryresults, isQuote, maxReplies]);
384
385 const replyAturis = repliesData
386 ? repliesData.pages.flatMap((page) =>
387 page
388 ? page.linking_records.map((record) => {
389 const aturi = `at://${record.did}/${record.collection}/${record.rkey}`;
390 return aturi;
391 })
392 : [],
393 )
394 : [];
395
396 const { oldestOpsReply, oldestOpsReplyElseNewestNonOpsReply } = (() => {
397 if (isQuote || !replyAturis || replyAturis.length === 0 || !maxReplies)
398 return {
399 oldestOpsReply: undefined,
400 oldestOpsReplyElseNewestNonOpsReply: undefined,
401 };
402
403 const opdid = new AtUri(atUri).host;
404
405 const opReplies = replyAturis.filter(
406 (aturi) => new AtUri(aturi).host === opdid,
407 );
408
409 if (opReplies.length > 0) {
410 const opreply = opReplies[opReplies.length - 1];
411 return {
412 oldestOpsReply: opreply,
413 oldestOpsReplyElseNewestNonOpsReply: opreply,
414 };
415 } else {
416 return {
417 oldestOpsReply: undefined,
418 oldestOpsReplyElseNewestNonOpsReply: replyAturis[0],
419 };
420 }
421 })();
422
423 // placeholder for when a post is missing
424 if (!isPostLoading && !postQuery?.value || isPostError) {
425 if (feedviewpost) {
426 return null // if feed view post then missing post isnt important and just remove it from view
427 }
428 return (
429 <>
430 {/* todo add reply lines here. */}
431 {/* todo dont let the UPR render the shitty placeholder uri we received */}
432 {/* <div className={`flex flex-row p-4 ${isQuote ? "border-gray-200 dark:border-gray-800 border-1 rounded-lg" : "border-gray-200 dark:border-gray-800 border-b"}`}> */}
433
434 <div className={`flex flex-col gap-0 border-gray-200 dark:border-gray-800 ${bottomReplyLine ? "" : "border-b"}`}>
435 <div style={{ width: 42, height: 16, minHeight: 16 }} className="flex items-center flex-col mx-4">
436 <div
437 style={{
438 width: 2,
439 height: 16,
440 opacity: 0.5,
441 }}
442 className={`${topReplyLine ? "bg-gray-500 dark:bg-gray-400" : "bg-transparent"}`}
443 />
444 </div>
445 <div className="flex flex-row px-4">
446 <div className="flex flex-col gap-1 flex-1 rounded-lg py-3 px-4 bg-gray-200 dark:bg-gray-800">
447 <div className="flex flex-row flex-1 gap-2 rounded-lg bg-gray-200 dark:bg-gray-800 items-center">
448 <IconMaterialSymbolsScanDeleteOutline />
449 <span>Missing {isQuote ? "Quoted" : ""} Post</span>
450 </div>
451 </div>
452 </div>
453
454 <div style={{ width: 42, height: 16, minHeight: 16 }} className="flex items-center flex-col mx-4">
455 <div
456 style={{
457 width: 2,
458 height: 16,
459 opacity: 0.5,
460 }}
461 // maxReplies === undefined to specifically prevent missing apost from threading down with more missings posts
462 // shouldnt affect thread up (parent) or feed view. im pretty sure missinga post would cut off the thread
463 className={`${bottomReplyLine && maxReplies === undefined ? "bg-gray-500 dark:bg-gray-400" : "bg-transparent"}`}
464 />
465 </div>
466 </div>
467 </>
468 );
469 }
470
471 return (
472 <>
473 <UniversalPostRendererRawRecordShim
474 detailed={detailed}
475 postRecord={postQuery}
476 profileRecord={opProfile}
477 aturi={atUri}
478 resolved={resolved}
479 likesCount={likes}
480 repostsCount={reposts}
481 repliesCount={replies}
482 links={links}
483 bottomReplyLine={
484 maxReplies && oldestOpsReplyElseNewestNonOpsReply
485 ? true
486 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply
487 ? false
488 : maxReplies === 0 && (!replies || (!!replies && replies === 0))
489 ? false
490 : bottomReplyLine
491 }
492 topReplyLine={topReplyLine}
493 bottomBorder={
494 maxReplies && oldestOpsReplyElseNewestNonOpsReply
495 ? false
496 : maxReplies === 0
497 ? false
498 : bottomBorder
499 }
500 feedviewpost={feedviewpost}
501 repostedby={repostedby}
502 style={style}
503 ref={ref}
504 dataIndexPropPass={dataIndexPropPass}
505 nopics={nopics}
506 concise={concise}
507 lightboxCallback={lightboxCallback}
508 maxReplies={maxReplies}
509 isQuote={isQuote}
510 filterNoReplies={filterNoReplies}
511 filterMustHaveMedia={filterMustHaveMedia}
512 filterMustBeReply={filterMustBeReply}
513 />
514 <>
515 {maxReplies !== undefined && maxReplies === 0 && replies && replies > 0 ? (
516 <>
517 <MoreReplies atUri={atUri} />
518 </>
519 ) : (
520 <>
521 </>
522 )}
523 </>
524 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && (
525 <>
526 <UniversalPostRendererATURILoader
527 atUri={oldestOpsReplyElseNewestNonOpsReply}
528 bottomReplyLine={(maxReplies ?? 0) > 0}
529 topReplyLine={
530 (!!(maxReplies && maxReplies - 1 === 0) &&
531 !!(replies && replies > 0)) ||
532 !!((maxReplies ?? 0) > 1)
533 }
534 bottomBorder={bottomBorder}
535 feedviewpost={feedviewpost}
536 repostedby={repostedby}
537 style={style}
538 ref={ref}
539 dataIndexPropPass={dataIndexPropPass}
540 nopics={nopics}
541 concise={concise}
542 lightboxCallback={lightboxCallback}
543 maxReplies={
544 maxReplies && maxReplies > 0 ? maxReplies - 1 : undefined
545 }
546 />
547 </>
548 )}
549 </>
550 );
551}
552
553function MoreReplies({ atUri }: { atUri: string }) {
554 const navigate = useNavigate();
555 const aturio = new AtUri(atUri);
556 return (
557 <div
558 onClick={() =>
559 navigate({
560 to: "/profile/$did/post/$rkey",
561 params: { did: aturio.host, rkey: aturio.rkey },
562 })
563 }
564 className="border-b border-gray-200 dark:border-gray-800 flex flex-row px-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors"
565 >
566 <div className="w-[42px] h-12 flex flex-col items-center justify-center">
567 <div
568 style={{
569 width: 2,
570 height: "100%",
571 backgroundImage:
572 "repeating-linear-gradient(to bottom, var(--color-gray-500) 0, var(--color-gray-500) 4px, transparent 4px, transparent 8px)",
573 opacity: 0.5,
574 }}
575 className="dark:bg-[repeating-linear-gradient(to_bottom,var(--color-gray-500)_0,var(--color-gray-400)_4px,transparent_4px,transparent_8px)]"
576 />
577 </div>
578
579 <div className="flex items-center pl-3 text-sm text-gray-500 dark:text-gray-400 select-none">
580 More Replies
581 </div>
582 </div>
583 );
584}
585
586function getAvatarUrl(opProfile: any, did: string, cdn: string) {
587 const link = opProfile?.value?.avatar?.ref?.["$link"];
588 if (!link) return null;
589 return `https://${cdn}/img/avatar/plain/${did}/${link}@jpeg`;
590}
591
592export function UniversalPostRendererRawRecordShim({
593 postRecord,
594 profileRecord,
595 aturi,
596 resolved,
597 likesCount,
598 repostsCount,
599 repliesCount,
600 links,
601 detailed = false,
602 bottomReplyLine = false,
603 topReplyLine = false,
604 bottomBorder = true,
605 feedviewpost = false,
606 repostedby,
607 style,
608 ref,
609 dataIndexPropPass,
610 nopics,
611 concise,
612 lightboxCallback,
613 maxReplies,
614 isQuote,
615 filterNoReplies,
616 filterMustHaveMedia,
617 filterMustBeReply,
618}: {
619 postRecord: any;
620 profileRecord: any;
621 aturi: string;
622 resolved: any;
623 likesCount?: number | null;
624 repostsCount?: number | null;
625 repliesCount?: number | null;
626 links?: any;
627 detailed?: boolean;
628 bottomReplyLine?: boolean;
629 topReplyLine?: boolean;
630 bottomBorder?: boolean;
631 feedviewpost?: boolean;
632 repostedby?: string;
633 style?: React.CSSProperties;
634 ref?: React.RefObject<HTMLDivElement>;
635 dataIndexPropPass?: number;
636 nopics?: boolean;
637 concise?: boolean;
638 lightboxCallback?: (d: LightboxProps) => void;
639 maxReplies?: number;
640 isQuote?: boolean;
641 filterNoReplies?: boolean;
642 filterMustHaveMedia?: boolean;
643 filterMustBeReply?: boolean;
644}) {
645 const navigate = useNavigate();
646
647 const hasEmbed = (postRecord?.value as ATPAPI.AppBskyFeedPost.Record)?.embed;
648 const hasImages = hasEmbed?.$type === "app.bsky.embed.images";
649 const hasVideo = hasEmbed?.$type === "app.bsky.embed.video";
650 const isquotewithmedia = hasEmbed?.$type === "app.bsky.embed.recordWithMedia";
651 const isQuotewithImages =
652 isquotewithmedia &&
653 (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
654 "app.bsky.embed.images";
655 const isQuotewithVideo =
656 isquotewithmedia &&
657 (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
658 "app.bsky.embed.video";
659
660 const hasMedia =
661 hasEmbed &&
662 (hasImages || hasVideo || isQuotewithImages || isQuotewithVideo);
663
664 const {
665 data: hydratedEmbed,
666 isLoading: isEmbedLoading,
667 error: embedError,
668 } = useHydratedEmbed(postRecord?.value?.embed, resolved?.did);
669
670 const [imgcdn] = useAtom(imgCDNAtom);
671
672 const parsedaturi = new AtUri(aturi);
673
674 const fakeprofileviewbasic = React.useMemo<AppBskyActorDefs.ProfileViewBasic>(
675 () => ({
676 did: resolved?.did || "",
677 handle: resolved?.handle || "",
678 displayName: profileRecord?.value?.displayName || "",
679 avatar: getAvatarUrl(profileRecord, resolved?.did, imgcdn) || "",
680 viewer: undefined,
681 labels: profileRecord?.labels || undefined,
682 verification: undefined,
683 pronouns: profileRecord?.value?.pronouns || undefined,
684 }),
685 [imgcdn, profileRecord, resolved?.did, resolved?.handle],
686 );
687
688 const fakeprofileviewdetailed =
689 React.useMemo<AppBskyActorDefs.ProfileViewDetailed>(
690 () => ({
691 ...fakeprofileviewbasic,
692 $type: "app.bsky.actor.defs#profileViewDetailed",
693 description: profileRecord?.value?.description || undefined,
694 }),
695 [fakeprofileviewbasic, profileRecord?.value?.description],
696 );
697
698 const fakepost = React.useMemo<AppBskyFeedDefs.PostView>(
699 () => ({
700 $type: "app.bsky.feed.defs#postView",
701 uri: aturi,
702 cid: postRecord?.cid || "",
703 author: fakeprofileviewbasic,
704 record: postRecord?.value || {},
705 embed: hydratedEmbed ?? undefined,
706 replyCount: repliesCount ?? 0,
707 repostCount: repostsCount ?? 0,
708 likeCount: likesCount ?? 0,
709 quoteCount: 0,
710 indexedAt: postRecord?.value?.createdAt || "",
711 viewer: undefined,
712 labels: postRecord?.labels || undefined,
713 threadgate: undefined,
714 }),
715 [
716 aturi,
717 postRecord?.cid,
718 postRecord?.value,
719 postRecord?.labels,
720 fakeprofileviewbasic,
721 hydratedEmbed,
722 repliesCount,
723 repostsCount,
724 likesCount,
725 ],
726 );
727
728 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent
729 ?.uri;
730 const feedviewpostreplydid =
731 thereply && !filterNoReplies ? new AtUri(thereply).host : undefined;
732 const replyhookvalue = useQueryIdentity(
733 feedviewpost ? feedviewpostreplydid : undefined,
734 );
735 const feedviewpostreplyhandle = replyhookvalue?.data?.handle;
736
737 const aturirepostbydid = repostedby ? new AtUri(repostedby).host : undefined;
738 const repostedbyhookvalue = useQueryIdentity(
739 repostedby ? aturirepostbydid : undefined,
740 );
741 const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle;
742
743 if (filterNoReplies && thereply) return null;
744
745 if (filterMustHaveMedia && !hasMedia) return null;
746
747 if (filterMustBeReply && !thereply) return null;
748
749 return (
750 <>
751 <UniversalPostRenderer
752 expanded={detailed}
753 onPostClick={() =>
754 parsedaturi &&
755 navigate({
756 to: "/profile/$did/post/$rkey",
757 params: { did: parsedaturi.host, rkey: parsedaturi.rkey },
758 })
759 }
760 onProfileClick={(e) => {
761 e.stopPropagation();
762 if (parsedaturi) {
763 navigate({
764 to: "/profile/$did",
765 params: { did: parsedaturi.host },
766 });
767 }
768 }}
769 post={fakepost}
770 uprrrsauthor={fakeprofileviewdetailed}
771 salt={aturi}
772 bottomReplyLine={bottomReplyLine}
773 topReplyLine={topReplyLine}
774 bottomBorder={bottomBorder}
775 feedviewpost={feedviewpost}
776 feedviewpostreplyhandle={feedviewpostreplyhandle}
777 repostedby={feedviewpostrepostedbyhandle}
778 style={style}
779 ref={ref}
780 dataIndexPropPass={dataIndexPropPass}
781 nopics={nopics}
782 concise={concise}
783 lightboxCallback={lightboxCallback}
784 maxReplies={maxReplies}
785 isQuote={isQuote}
786 constellationLinks={links}
787 />
788 </>
789 );
790}
791
792export function UniversalPostRenderer({
793 post,
794 uprrrsauthor,
795 onPostClick,
796 onProfileClick,
797 expanded,
798 isQuote,
799 extraOptionalItemInfo,
800 bottomReplyLine,
801 topReplyLine,
802 salt,
803 bottomBorder = true,
804 feedviewpost,
805 feedviewpostreplyhandle,
806 depth = 0,
807 repostedby,
808 style,
809 ref,
810 dataIndexPropPass,
811 nopics,
812 concise,
813 lightboxCallback,
814 maxReplies,
815 constellationLinks,
816 referral,
817}: {
818 post: AppBskyFeedDefs.PostView;
819 uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed;
820 onPostClick?: (e: React.MouseEvent) => void;
821 onProfileClick?: (e: React.MouseEvent) => void;
822 expanded?: boolean;
823 isQuote?: boolean;
824 extraOptionalItemInfo?: AppBskyFeedDefs.FeedViewPost;
825 bottomReplyLine?: boolean;
826 topReplyLine?: boolean;
827 salt: string;
828 bottomBorder?: boolean;
829 feedviewpost?: boolean;
830 feedviewpostreplyhandle?: string;
831 depth?: number;
832 repostedby?: string;
833 style?: React.CSSProperties;
834 ref?: React.RefObject<HTMLDivElement>;
835 dataIndexPropPass?: number;
836 nopics?: boolean;
837 concise?: boolean;
838 lightboxCallback?: (d: LightboxProps) => void;
839 maxReplies?: number;
840 constellationLinks?: any;
841 referral?: string[];
842}) {
843
844 // todo move moderation to one of the UniversalPostRenderer wrapper components, and not the pure renderer component. please. thanks
845 // todo please move all moderation including labeling and blocks into a wrapper component please i beg you
846
847 const subjects = [
848 post.author.did,
849 `at://${post.author.did}/app.bsky.actor.profile/self`,
850 post.uri,
851 ]
852
853 const {
854 results: labelResults,
855 hydratedLabelDefs,
856 } = useAutoLabels({
857 subjects,
858 type: "post", // or whatever you’re keying on for now
859 })
860
861 const ghld = getGetHydratedLabelDefs(hydratedLabelDefs)
862 const accountResult = labelResults.get(post.author.did)
863 const profileResult = labelResults.get(
864 `at://${post.author.did}/app.bsky.actor.profile/self`,
865 )
866 const postResult = labelResults.get(post.uri)
867
868 const accountLabelVerdict = accountResult?.labelVerdict ?? "unknown"
869 const authorLabels = accountResult?.labels ?? []
870
871 const profileLabelVerdict = profileResult?.labelVerdict ?? "unknown"
872 const profileLabels = profileResult?.labels ?? []
873
874 const postLabelVerdict = postResult?.labelVerdict ?? "unknown"
875 const contentLabels = postResult?.labels ?? []
876
877 const combinedLabels = [...authorLabels, ...profileLabels, ...contentLabels]
878
879 const authorModUnknown = accountLabelVerdict === "unknown";
880 const profileModUnknown = profileLabelVerdict === "unknown";
881 const contentModUnknown = postLabelVerdict === "unknown";
882
883 const authorModLoading = accountLabelVerdict === "loading";
884 const profileModLoading = profileLabelVerdict === "loading";
885 const contentModLoading = postLabelVerdict === "loading";
886
887 const authorModError = accountLabelVerdict === "error";
888 const profileModError = profileLabelVerdict === "error";
889 const contentModError = postLabelVerdict === "error";
890
891 const verdictDebugString = `accountLabelVerdict: ${accountLabelVerdict}, profileLabelVerdict: ${profileLabelVerdict}, postLabelVerdict: ${postLabelVerdict}`
892 //const verdictDebugStringCauses =
893
894 const strictModerationUnknown = authorModUnknown || profileModUnknown || contentModUnknown
895 const strictModerationLoading = authorModLoading || profileModLoading || contentModLoading
896 const strictModerationError = authorModError || profileModError || contentModError
897
898 const strictModerationDontShow = strictModerationUnknown || strictModerationLoading || strictModerationError
899
900 const hideAuthorLabels = authorLabels.filter(
901 (label) => ghld(label.src, label.val)?.pref === "hide",
902 );
903 const warnAuthorLabels = authorLabels.filter(
904 (label) => ghld(label.src, label.val)?.pref === "warn",
905 );
906 // const errorAuthorLabels = authorLabels.filter(
907 // //(label) => ghld(label.src,label.val)?.severity === "hide",
908 // );
909 const hideProfileLabels = profileLabels.filter(
910 (label) => ghld(label.src, label.val)?.pref === "hide",
911 );
912 const warnProfileLabels = profileLabels.filter(
913 (label) => ghld(label.src, label.val)?.pref === "warn",
914 );
915 const hideContentLabels = contentLabels.filter(
916 (label) => ghld(label.src, label.val)?.pref === "hide",
917 );
918 const warnContentLabels = contentLabels.filter(
919 (label) => ghld(label.src, label.val)?.pref === "warn",
920 );
921
922 // add user pronouns
923 const pronoun = post.author.pronouns || undefined
924 const informCombinedLabels: LabelWithHydratedLocaleName[] = combinedLabels.flatMap(
925 (label) => {
926 if (ghld(label.src, label.val)?.severity === "inform" && ghld(label.src, label.val)?.pref === "warn") {
927 return [{
928 ...label,
929 name: getLocaleLabel(ghld(label.src, label.val))?.name || label.val
930 }]
931 }
932 return []
933 },
934 );
935
936 const parsed = new AtUri(post.uri);
937 const navigate = useNavigate();
938 const [hasRetweeted, setHasRetweeted] = useState<boolean>(
939 post.viewer?.repost ? true : false,
940 );
941 const [, setComposerPost] = useAtom(composerAtom);
942 const { agent, status } = useAuth();
943 const [retweetUri, setRetweetUri] = useState<string | undefined>(
944 post.viewer?.repost,
945 );
946 const { liked, toggle, backfill } = useFastLike(post.uri, post.cid);
947
948 const agentDid = agent?.did;
949 const authorDid = post.author.did;
950
951 const userBlocksAuthor = useGetOneToOneState(
952 agentDid && authorDid
953 ? {
954 target: authorDid,
955 user: agentDid,
956 collection: "app.bsky.graph.block",
957 path: ".subject",
958 }
959 : undefined,
960 );
961 const authorBlocksUser = useGetOneToOneState(
962 agentDid && authorDid
963 ? {
964 target: agentDid,
965 user: authorDid,
966 collection: "app.bsky.graph.block",
967 path: ".subject",
968 }
969 : undefined,
970 );
971
972 const repostOrUnrepostPost = async () => {
973 if (!agent) {
974 console.error("Agent is null or undefined");
975 return;
976 }
977 if (hasRetweeted) {
978 if (retweetUri) {
979 await agent.deleteRepost(retweetUri);
980 setHasRetweeted(false);
981 }
982 } else {
983 const { uri } = await agent.repost(post.uri, post.cid);
984 setRetweetUri(uri);
985 setHasRetweeted(true);
986 }
987 };
988
989 const isRepost = repostedby
990 ? repostedby
991 : extraOptionalItemInfo
992 ? AppBskyFeedDefs.isReasonRepost(extraOptionalItemInfo.reason)
993 ? extraOptionalItemInfo.reason?.by.displayName
994 : undefined
995 : undefined;
996 const isReply = extraOptionalItemInfo
997 ? extraOptionalItemInfo.reply
998 : undefined;
999
1000 const emergencySalt = randomString();
1001
1002 const [showBridgyText] = useAtom(enableBridgyTextAtom);
1003 const [showWafrnText] = useAtom(enableWafrnTextAtom);
1004
1005 const unfedibridgy = (post.record as { bridgyOriginalText?: string })
1006 .bridgyOriginalText;
1007 const unfediwafrnPartial = (post.record as { fullText?: string }).fullText;
1008 const unfediwafrnTags = (post.record as { fullTags?: string }).fullTags;
1009 const unfediwafrnUnHost = (post.record as { fediverseId?: string })
1010 .fediverseId;
1011
1012 const undfediwafrnHost = unfediwafrnUnHost
1013 ? new URL(unfediwafrnUnHost).hostname
1014 : undefined;
1015
1016 const tags = unfediwafrnTags
1017 ? unfediwafrnTags
1018 .split("\n")
1019 .map((t) => t.trim())
1020 .filter(Boolean)
1021 : undefined;
1022
1023 const links = tags
1024 ? tags
1025 .map((tag) => {
1026 const encoded = encodeURIComponent(tag);
1027 return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`;
1028 })
1029 .join("<br>")
1030 : "";
1031
1032 const unfediwafrn = unfediwafrnPartial
1033 ? unfediwafrnPartial + (links ? `<br>${links}` : "")
1034 : undefined;
1035
1036 const fedi =
1037 (showBridgyText ? unfedibridgy : undefined) ??
1038 (showWafrnText ? unfediwafrn : undefined);
1039
1040 const isMainItem = false;
1041 const setMainItem = (any: any) => { };
1042
1043 const hideWarnsWhenUnauthed = UNAUTHED_PREVENT_OPENING_WARNS && status === "signedOut";
1044
1045 const showContentWarning = warnContentLabels.length > 0;
1046
1047 const [isOpen, setIsOpen] = useState(!showContentWarning);
1048
1049 const [hasUserTouchedToggleYet, setHasUserTouchedToggleYet] = useState(false);
1050
1051 // Force Hiddens from host policy
1052 const isForceHiddenAuthor = authorLabels.some((label) => {
1053 return (
1054 FORCE_HIDE_LABELS.has(label.val) &&
1055 FORCE_HIDE_LABELS_WHITELISTED_SOURCE.has(label.src)
1056 );
1057 });
1058 const isForceHiddenProfile = profileLabels.some((label) => {
1059 return (
1060 FORCE_HIDE_LABELS.has(label.val) &&
1061 FORCE_HIDE_LABELS_WHITELISTED_SOURCE.has(label.src)
1062 );
1063 });
1064 const isForceHiddenPost = contentLabels.some((label) => {
1065 return (
1066 FORCE_HIDE_LABELS.has(label.val) &&
1067 FORCE_HIDE_LABELS_WHITELISTED_SOURCE.has(label.src)
1068 );
1069 });
1070 const isForceHidden = isForceHiddenAuthor || isForceHiddenProfile || isForceHiddenPost
1071
1072
1073 useEffect(() => {
1074 if (!hasUserTouchedToggleYet && showContentWarning) {
1075 // eslint-disable-next-line react-hooks/set-state-in-effect
1076 setIsOpen(false);
1077 }
1078 }, [hasUserTouchedToggleYet, showContentWarning])
1079
1080
1081 console.log("HLLO HLLO HisForceHidden post UPR" + post.uri + post.author.did + isForceHidden, "1what", contentLabels, "2what", authorLabels)
1082
1083 // if (hideAuthorLabels.length > 0 || hideContentLabels.length > 0 || isForceHidden || strictModerationDontShow) {
1084 // return (
1085 // <div ref={ref} style={style} data-index={dataIndexPropPass} className=" leading-normal flex flex-col gap-4 p-4">
1086 // <span>DEBUG LOADING LABELS</span>
1087 // <span>{post.uri}</span>
1088 // <span>{verdictDebugString}</span>
1089 // </div>
1090 // );
1091 // }
1092 // if ( isForceHidden ) {
1093 // return (
1094 // <div ref={ref} style={style} data-index={dataIndexPropPass} className=" leading-normal flex flex-col gap-4 p-4">
1095 // Post Hidden
1096 // </div>
1097 // )
1098 // }
1099
1100 // todo respect the blur label def
1101 // todo scrap the verdict system and rename it into what it is (loading state)
1102 const redactWhileLoadingAuthor = authorModLoading || authorModError || authorModUnknown
1103 const redactWhileLoadingProfile = profileModLoading || profileModError || profileModUnknown
1104 const redactWhileLoadingPost = contentModLoading || contentModError || contentModUnknown
1105 const redactWhileLoadingBlock = userBlocksAuthor.isLoading || authorBlocksUser.isLoading
1106 const redactWhileLoadingSome = redactWhileLoadingAuthor || redactWhileLoadingProfile || redactWhileLoadingPost || redactWhileLoadingBlock
1107 /**
1108 * maybe rules:
1109 * if author is loading, hide everything
1110 * if post is loading, hide text and embeds
1111 * if profile is loading, hide pfp
1112 */
1113
1114 // the || !post.record?.createdAt is so that users cant imply theyre replying to a non existant post by a user
1115 // if the post doesnt exist, dont render the name or pfp
1116
1117
1118 const redactWhileLoading_name = redactWhileLoadingAuthor || !post.record?.createdAt || redactWhileLoadingBlock
1119 const redactWhileLoading_content = redactWhileLoadingAuthor || redactWhileLoadingPost || !post.record?.createdAt || redactWhileLoadingBlock
1120 const redactWhileLoading_pfp = redactWhileLoadingAuthor || redactWhileLoadingProfile || !post.record?.createdAt || redactWhileLoadingBlock
1121
1122
1123 const redactFinalBlock = userBlocksAuthor.uris.length > 0 || authorBlocksUser.uris.length > 0
1124
1125 const redactFinalAuthor = hideAuthorLabels.length > 0 || isForceHiddenAuthor || redactFinalBlock
1126 const redactFinalProfile = hideProfileLabels.length > 0 || isForceHiddenProfile || redactFinalBlock
1127 const redactFinalPost = hideContentLabels.length > 0 || isForceHiddenPost || redactFinalBlock
1128
1129 const redactFinalSome = redactFinalAuthor || redactFinalProfile || redactFinalPost || redactFinalBlock
1130
1131 // todo consider if adding an explicit "post removed" visible component is better for this
1132 //if (redactFinalSome) return null
1133 // todo preserve reply lines
1134 // todo share the component with the Missing post from above
1135 if (redactFinalSome) {
1136 if (feedviewpost) {
1137 return null // if feed view post then moderated post isnt important and just remove it from view
1138 }
1139 return (
1140 <div
1141 className={`flex flex-col gap-0 border-gray-200 dark:border-gray-800 ${bottomReplyLine ? "" : "border-b"}`}
1142 onClick={
1143 isMainItem
1144 ? onPostClick
1145 : setMainItem
1146 ? onPostClick
1147 ? (e) => {
1148 setMainItem({ post: post });
1149 onPostClick(e);
1150 }
1151 : () => {
1152 setMainItem({ post: post });
1153 }
1154 : undefined
1155 }>
1156
1157 <div style={{ width: 42, height: 16, minHeight: 16 }} className="flex items-center flex-col mx-4">
1158 <div
1159 style={{
1160 width: 2,
1161 height: 16,
1162 opacity: 0.5,
1163 }}
1164 className={`${topReplyLine ? "bg-gray-500 dark:bg-gray-400" : "bg-transparent"}`}
1165 />
1166 </div>
1167
1168 <div className="flex flex-row px-4">
1169 <div className="flex flex-col gap-1 flex-1 rounded-lg py-3 px-4 bg-gray-200 dark:bg-gray-800">
1170 <div className="flex flex-row flex-1 gap-2 items-center">
1171 <IconMdiShieldOutline width={18} height={18} />
1172 <span className=" font-semibold text-[15px]">Moderated Post</span>
1173 </div>
1174 <ul className="flex flex-col gap-0.5 list-disc list-outside">
1175 {userBlocksAuthor.uris.length > 0 && (<li className=" text-sm ml-[18px]">User Blocked by You</li>)}
1176 {authorBlocksUser.uris.length > 0 && (<li className=" text-sm ml-[18px]">User Blocking You</li>)}
1177 {hideAuthorLabels.length > 0 && (<>{hideAuthorLabels.map((label) => { return <li key={label.cid || label.exp} className=" text-sm ml-[18px]">Author Label: {getLocaleLabel(ghld(label.src, label.val))?.name || label.val}</li> })}</>)}
1178 {hideProfileLabels.length > 0 && (<>{hideProfileLabels.map((label) => { return <li key={label.cid || label.exp} className=" text-sm ml-[18px]">Profile Label: {getLocaleLabel(ghld(label.src, label.val))?.name || label.val}</li> })}</>)}
1179 {hideContentLabels.length > 0 && (<>{hideContentLabels.map((label) => { return <li key={label.cid || label.exp} className=" text-sm ml-[18px]">Post Label: {getLocaleLabel(ghld(label.src, label.val))?.name || label.val}</li> })}</>)}
1180 </ul>
1181 </div>
1182 </div>
1183
1184 <div style={{ width: 42, height: 16, minHeight: 16 }} className="flex items-center flex-col mx-4">
1185 <div
1186 style={{
1187 width: 2,
1188 height: 16,
1189 opacity: 0.5,
1190 }}
1191 className={`${bottomReplyLine ? "bg-gray-500 dark:bg-gray-400" : "bg-transparent"}`}
1192 />
1193 </div>
1194 </div>
1195 )
1196 }
1197
1198 // ${redactWhileLoadingSome && "blur"}
1199 return (
1200 <div ref={ref} style={style} data-index={dataIndexPropPass} className={` leading-normal `}>
1201 {/* <span>{JSON.stringify(post, null, 2)}</span> */}
1202 <div
1203 key={salt + "-" + (post.uri || emergencySalt)}
1204 onClick={
1205 isMainItem
1206 ? onPostClick
1207 : setMainItem
1208 ? onPostClick
1209 ? (e) => {
1210 setMainItem({ post: post });
1211 onPostClick(e);
1212 }
1213 : () => {
1214 setMainItem({ post: post });
1215 }
1216 : undefined
1217 }
1218 style={{
1219 opacity: "1 !important",
1220 background: "transparent",
1221 paddingLeft: isQuote ? 12 : 16,
1222 paddingRight: isQuote ? 12 : 16,
1223 paddingTop: isRepost ? 10 : isQuote ? 12 : topReplyLine ? 8 : 16,
1224 paddingBottom: 0,
1225 fontFamily: "system-ui, sans-serif",
1226 position: "relative",
1227 borderBottomWidth: bottomBorder ? (isQuote ? 0 : 1) : 0,
1228 }}
1229 className="border-gray-200 dark:border-gray-800"
1230 >
1231 {isRepost && (
1232 <div
1233 style={{
1234 marginLeft: 36,
1235 display: "flex",
1236 borderRadius: 12,
1237 paddingBottom: "calc(22px - 1rem)",
1238 fontSize: 14,
1239 maxHeight: "1rem",
1240 justifyContent: "flex-start",
1241 gap: 4,
1242 alignItems: "center",
1243 }}
1244 className="text-gray-500 dark:text-gray-400"
1245 // todo moderate reposts (label, and record graph)
1246 >
1247 <IconMdiRepost /> Reposted by @{isRepost}
1248 </div>
1249 )}
1250 {!isQuote && (
1251 <div
1252 style={{
1253 opacity: topReplyLine || isReply ? 0.5 : 0,
1254 position: "absolute",
1255 top: 0,
1256 left: 36,
1257 width: 2,
1258 height: isRepost
1259 ? "calc(16px + 1rem - 6px)"
1260 : topReplyLine
1261 ? 8 - 6
1262 : 16 - 6,
1263 }}
1264 className="bg-gray-500 dark:bg-gray-400"
1265 />
1266 )}
1267 <HoverCard.Root>
1268 <HoverCard.Trigger asChild>
1269 <div
1270 className={`absolute`}
1271 style={{
1272 top: isRepost
1273 ? "calc(16px + 1rem)"
1274 : isQuote
1275 ? 12
1276 : topReplyLine
1277 ? 8
1278 : 16,
1279 left: isQuote ? 12 : 16,
1280 }}
1281 onClick={onProfileClick}
1282 >
1283 {redactWhileLoading_pfp ? (
1284 <div
1285 className="rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600 animate-pulse"
1286 style={{
1287 width: isQuote ? 16 : 42,
1288 height: isQuote ? 16 : 42,
1289 }}
1290 />
1291 ) : (
1292 <img
1293 src={post.author.avatar || defaultpfp}
1294 alt="avatar"
1295 className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
1296 style={{
1297 width: isQuote ? 16 : 42,
1298 height: isQuote ? 16 : 42,
1299 }}
1300 />
1301 )
1302 }
1303
1304 </div>
1305 </HoverCard.Trigger>
1306 <HoverCard.Portal>
1307 <HoverCard.Content
1308 className="rounded-md p-4 w-72 bg-gray-50 dark:bg-gray-900 shadow-lg border border-gray-300 dark:border-gray-800 animate-slide-fade z-50"
1309 side={"bottom"}
1310 sideOffset={5}
1311 onClick={onProfileClick}
1312 >
1313 <div className="flex flex-col gap-2">
1314 <div className="flex flex-row">
1315 {redactWhileLoading_pfp ? (
1316 <div className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600 animate-pulse" />
1317 ) : (
1318 <img
1319 src={post.author.avatar || defaultpfp}
1320 alt="avatar"
1321 className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600"
1322 />
1323 )
1324 }
1325 <div className=" flex-1 flex flex-row align-middle justify-end">
1326 <FollowButton targetdidorhandle={post.author.did} />
1327 </div>
1328 </div>
1329 <div className="flex flex-col gap-3">
1330 <div>
1331 <div className={`text-gray-900 dark:text-gray-100 font-medium text-md ${redactWhileLoading_name && "animate-pulse blur"}`}>
1332 {redactWhileLoading_name ? "Person Display Name" : (post.author.displayName || post.author.handle)}
1333 </div>
1334 <div className={`text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1 ${redactWhileLoading_name && "animate-pulse blur"}`}>
1335 <Mutual targetdidorhandle={post.author.did} />@
1336 {redactWhileLoading_name ? "person.placeholder" : post.author.handle}
1337 </div>
1338 </div>
1339 {uprrrsauthor?.description && (
1340 <div className="text-gray-700 dark:text-gray-300 text-sm text-left break-words line-clamp-3">
1341 {uprrrsauthor.description}
1342 </div>
1343 )}
1344 </div>
1345 </div>
1346 </HoverCard.Content>
1347 </HoverCard.Portal>
1348 </HoverCard.Root>
1349
1350 <div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}>
1351 <div
1352 style={{
1353 display: "flex",
1354 flexDirection: "column",
1355 alignSelf: "stretch",
1356 alignItems: "center",
1357 overflow: "hidden",
1358 width: expanded || isQuote ? 0 : "auto",
1359 marginRight: expanded || isQuote ? 0 : 12,
1360 }}
1361 className=" shrink-0"
1362 >
1363 <div style={{ width: 42, height: 42 + 6, minHeight: 42 + 6 }} />
1364 {bottomReplyLine && (
1365 <div
1366 style={{
1367 width: 2,
1368 height: "100%",
1369 opacity: 0.5,
1370 }}
1371 className="bg-gray-500 dark:bg-gray-400"
1372 />
1373 )}
1374 </div>
1375 <div style={{ flex: 1, maxWidth: "100%" }}>
1376 <div
1377 style={{
1378 display: "flex",
1379 flexDirection: "row",
1380 alignItems: "center",
1381 flexWrap: "nowrap",
1382 maxWidth: `calc(100% - ${!expanded ? (isQuote ? 26 : 0) : 54}px)`,
1383 width: `calc(100% - ${!expanded ? (isQuote ? 26 : 0) : 54}px)`,
1384 marginLeft: !expanded ? (isQuote ? 26 : 0) : 54,
1385 marginBottom: !expanded ? 4 : 6,
1386 }}
1387 >
1388 <div
1389 style={{
1390 display: "flex",
1391 overflow: "hidden",
1392 textOverflow: "ellipsis",
1393 flexShrink: 1,
1394 flexGrow: 1,
1395 flexBasis: 0,
1396 width: 0,
1397 gap: expanded ? 0 : 6,
1398 alignItems: expanded ? "flex-start" : "center",
1399 flexDirection: expanded ? "column" : "row",
1400 height: expanded ? 42 : "1rem",
1401 }}
1402 >
1403 <span
1404 style={{
1405 display: "flex",
1406 fontWeight: 700,
1407 fontSize: 16,
1408 overflow: "hidden",
1409 textOverflow: "ellipsis",
1410 whiteSpace: "nowrap",
1411 flexShrink: 1,
1412 minWidth: 0,
1413 gap: 4,
1414 alignItems: "center",
1415 }}
1416 className={`text-gray-900 dark:text-gray-100 ${redactWhileLoading_name && "animate-pulse blur"}`}
1417 >
1418 {redactWhileLoading_name ? "Person Display Name" : post.author.displayName || post.author.handle}
1419 {post.author.verification?.verifiedStatus == "valid" && (
1420 <IconMdiVerified />
1421 )}
1422 </span>
1423
1424 <span
1425 style={{
1426 fontSize: 16,
1427 overflowX: "hidden",
1428 textOverflow: "ellipsis",
1429 whiteSpace: "nowrap",
1430 flexShrink: 1,
1431 flexGrow: 0,
1432 minWidth: 0,
1433 }}
1434 className={`text-gray-500 dark:text-gray-400 ${redactWhileLoading_name && "animate-pulse blur"}`}
1435 >
1436 @{redactWhileLoading_name ? "person.placeholder" : post.author.handle}
1437 </span>
1438 </div>
1439 <div
1440 style={{
1441 display: "flex",
1442 alignItems: "center",
1443 height: "1rem",
1444 }}
1445 >
1446 <span
1447 style={{
1448 fontSize: 16,
1449 marginLeft: 8,
1450 whiteSpace: "nowrap",
1451 flexShrink: 0,
1452 maxWidth: "100%",
1453 }}
1454 className="text-gray-500 dark:text-gray-400"
1455 >
1456 · {shortTimeAgo(post.indexedAt)}
1457 </span>
1458 </div>
1459 </div>
1460 {/* <ModerationInner subject={post.author.did} /> */}
1461 {authorModLoading ? (
1462 <div className="flex flex-wrap flex-row gap-1 my-1">
1463 {/* <div className="text-xs bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded-full flex flex-row items-center gap-1">
1464 / <img
1465 src={resolvedpfp || defaultpfp}
1466 alt="avatar"
1467 className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
1468 style={{
1469 width: 12,
1470 height: 12,
1471 }}
1472 /> /
1473 <span className="font-medium">loading badges...</span>
1474 </div> */}
1475 </div>
1476 ) : (
1477 <div className={`flex flex-wrap flex-row gap-1 my-1 ${redactWhileLoading_name ? "animate-pulse blur" : ""}`}>
1478 {pronoun && (
1479 <SmallAuthorLabelBadgeInner
1480 text={pronoun}
1481 disablepfp={true}
1482 />
1483 )}
1484 {informCombinedLabels.map((label, index) => (
1485 <SmallAuthorLabelBadge
1486 label={label}
1487 key={label.cts + label.src + label.val}
1488 />
1489 ))}
1490 </div>
1491 )}
1492 {!!feedviewpostreplyhandle && (
1493 <div
1494 style={{
1495 display: "flex",
1496 borderRadius: 12,
1497 paddingBottom: 2,
1498 fontSize: 14,
1499 justifyContent: "flex-start",
1500 gap: 4,
1501 alignItems: "center",
1502 height:
1503 !(expanded || isQuote) && !!feedviewpostreplyhandle
1504 ? "1rem"
1505 : 0,
1506 opacity:
1507 !(expanded || isQuote) && !!feedviewpostreplyhandle ? 1 : 0,
1508 }}
1509 className={`text-gray-500 dark:text-gray-400 ${redactWhileLoading_content && "animate-pulse blur"}`}
1510 >
1511 <IconMdiReply /> Reply to @{feedviewpostreplyhandle}
1512 </div>
1513 )}
1514 {/* <ModerationInner subject={post.uri} /> */}
1515 {/* todo migrate cw stuff to the new useAutoLabels system */}
1516 {showContentWarning && (
1517 <ContentWarning
1518 unauthedgate={hideWarnsWhenUnauthed}
1519 labels={warnContentLabels}
1520 isOpen={isOpen}
1521 onPress={(e) => {
1522 e.stopPropagation();
1523 setHasUserTouchedToggleYet(true);
1524 if (!hideWarnsWhenUnauthed) {
1525 setIsOpen(!isOpen)
1526 }
1527 }}
1528 />
1529 )}
1530 {isOpen && (<>
1531 <div
1532 style={{
1533 fontSize: 16,
1534 marginBottom: !post.embed || concise ? 0 : 8,
1535 whiteSpace: "pre-wrap",
1536 textAlign: "left",
1537 overflowWrap: "anywhere",
1538 wordBreak: "break-word",
1539 ...(concise && {
1540 display: "-webkit-box",
1541 WebkitBoxOrient: "vertical",
1542 WebkitLineClamp: 2,
1543 overflow: "hidden",
1544 }),
1545 }}
1546 className={`text-gray-900 dark:text-gray-100 ${redactWhileLoading_content && "animate-pulse blur"}`}
1547 >
1548 {fedi ? (
1549 <>
1550 <span
1551 className="dangerousFediContent"
1552 dangerouslySetInnerHTML={{
1553 __html: DOMPurify.sanitize(fedi),
1554 }}
1555 />
1556 </>
1557 ) : (
1558 <>
1559 {renderTextWithFacets({
1560 text: (post.record as { text?: string }).text ?? "",
1561 facets: (post.record.facets as Facet[]) ?? [],
1562 navigate: navigate,
1563 })}
1564 </>
1565 )}
1566 </div>
1567 {post.embed && depth < 1 && !concise ? (
1568 <PostEmbeds
1569 redactedLoading={redactWhileLoading_content}
1570 embed={post.embed}
1571 viewContext={PostEmbedViewContext.Feed}
1572 salt={salt}
1573 navigate={navigate}
1574 postid={{ did: post.author.did, rkey: parsed.rkey }}
1575 nopics={nopics}
1576 lightboxCallback={lightboxCallback}
1577 constellationLinks={constellationLinks}
1578 referral={[...referral || [], "im upr!"]}
1579 />
1580 ) : null}
1581 {post.embed && depth > 0 && (
1582 <>
1583 <div className={`border-gray-300 dark:border-gray-800 p-3 rounded-xl border italic text-gray-400 text-[14px] ${redactWhileLoading_content && "animate-pulse blur"}`}>
1584 (there is an embed here thats too deep to render)
1585 </div>
1586 </>
1587 )}
1588 </>)}
1589 <div
1590 style={{
1591 paddingTop: post.embed && !concise && depth < 1 ? 4 : 0,
1592 }}
1593 >
1594 <>
1595 {expanded && (
1596 <div
1597 style={{
1598 overflow: "hidden",
1599 fontSize: 14,
1600 display: "flex",
1601 borderBottomStyle: "solid",
1602 paddingTop: 4,
1603 paddingBottom: 8,
1604 borderBottomWidth: 1,
1605 marginBottom: 8,
1606 }}
1607 className={`text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-800 was7 ${redactWhileLoading_content && "animate-pulse blur"}`}
1608 >
1609 {fullDateTimeFormat(post.indexedAt)}
1610 </div>
1611 )}
1612 </>
1613 {!isQuote && (
1614 <div
1615 style={{
1616 display: "flex",
1617 gap: 32,
1618 paddingTop: 8,
1619 fontSize: 15,
1620 justifyContent: "space-between",
1621 }}
1622 className="text-gray-500 dark:text-gray-400"
1623 >
1624 <HitSlopButton
1625 onClick={() => {
1626 setComposerPost({ kind: "reply", parent: post.uri });
1627 }}
1628 style={{
1629 ...btnstyle,
1630 }}
1631 className={redactWhileLoading_content && "animate-pulse blur" || undefined}
1632 >
1633 <IconMdiCommentOutline />
1634 {post.replyCount}
1635 </HitSlopButton>
1636 <DropdownMenu.Root modal={false}>
1637 <DropdownMenu.Trigger asChild>
1638 <div
1639 style={{
1640 ...btnstyle,
1641 ...(hasRetweeted ? { color: "#5CEFAA" } : {}),
1642 }}
1643 aria-label="Repost or quote post"
1644 className={redactWhileLoading_content && "animate-pulse blur" || undefined}
1645 >
1646 {hasRetweeted ? (
1647 <IconMdiRepeat color="#5CEFAA" />
1648 ) : (
1649 <IconMdiRepeat />
1650 )}
1651 {post.repostCount ?? 0}
1652 </div>
1653 </DropdownMenu.Trigger>
1654
1655 <DropdownMenu.Portal>
1656 <DropdownMenu.Content
1657 align="start"
1658 sideOffset={5}
1659 className="bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-32 z-50 overflow-hidden"
1660 >
1661 <DropdownMenu.Item
1662 onSelect={repostOrUnrepostPost}
1663 className="px-3 py-2 text-sm flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-700"
1664 >
1665 <IconMdiRepeat
1666 className={hasRetweeted ? "text-green-400" : ""}
1667 />
1668 <span>{hasRetweeted ? "Undo Repost" : "Repost"}</span>
1669 </DropdownMenu.Item>
1670
1671 <DropdownMenu.Item
1672 onSelect={() => {
1673 setComposerPost({
1674 kind: "quote",
1675 subject: post.uri,
1676 });
1677 }}
1678 className="px-3 py-2 text-sm flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-700"
1679 >
1680 <IconMdiCommentOutline />
1681 <span>Quote</span>
1682 </DropdownMenu.Item>
1683 </DropdownMenu.Content>
1684 </DropdownMenu.Portal>
1685 </DropdownMenu.Root>
1686 <HitSlopButton
1687 onClick={() => {
1688 toggle();
1689 }}
1690 style={{
1691 ...btnstyle,
1692 ...(liked ? { color: "#EC4899" } : {}),
1693 }}
1694 className={redactWhileLoading_content && "animate-pulse blur" || undefined}
1695 >
1696 {liked ? (
1697 <IconMdiCardsHeart />
1698 ) : (
1699 <IconMdiCardsHeartOutline />
1700 )}
1701 {(post.likeCount || 0) + (liked ? 1 : 0)}
1702 </HitSlopButton>
1703 <div style={{ display: "flex", gap: 8 }}>
1704 <HitSlopButton
1705 onClick={async (e) => {
1706 e.stopPropagation();
1707 try {
1708 await navigator.clipboard.writeText(
1709 "https://bsky.app" +
1710 "/profile/" +
1711 post.author.handle +
1712 "/post/" +
1713 post.uri.split("/").pop(),
1714 );
1715 renderSnack({
1716 title: "Copied to clipboard!",
1717 });
1718 } catch (_e) {
1719 renderSnack({
1720 title: "Failed to copy link",
1721 });
1722 }
1723 }}
1724 style={{
1725 ...btnstyle,
1726 }}
1727 >
1728 <IconMdiShareVariant />
1729 </HitSlopButton>
1730 <HitSlopButton
1731 onClick={() => {
1732 renderSnack({
1733 title: "Not implemented yet...",
1734 });
1735 }}
1736 >
1737 <span style={btnstyle}>
1738 <IconMdiMoreHoriz />
1739 </span>
1740 </HitSlopButton>
1741 </div>
1742 </div>
1743 )}
1744 </div>
1745 <div
1746 style={{
1747 height: isQuote ? 12 : 16,
1748 }}
1749 />
1750 </div>
1751 </div>
1752 </div>
1753 </div>
1754 );
1755}
1756
1757enum PostEmbedViewContext {
1758 ThreadHighlighted = "ThreadHighlighted",
1759 Feed = "Feed",
1760 FeedEmbedRecordWithMedia = "FeedEmbedRecordWithMedia",
1761}
1762
1763export function ContentWarning({
1764 unauthedgate,
1765 labels,
1766 isOpen,
1767 onPress,
1768}: {
1769 unauthedgate?: boolean;
1770 labels: ATPAPI.ComAtprotoLabelDefs.Label[];
1771 isOpen: boolean;
1772 onPress: React.MouseEventHandler<HTMLDivElement>;
1773}) {
1774 const { getLabelInfo } = useLabelInfo();
1775
1776 // Pre-calculate text for cleaner JSX
1777 const labelText = labels
1778 .map((label) => getLabelInfo(label.src, label.val).name)
1779 .join(", ");
1780
1781 return (
1782 <div className="mb-2 w-full select-none" onClick={onPress}>
1783 <div
1784 className={`
1785 group flex items-center justify-between
1786 w-full px-4 py-3
1787 rounded-full
1788 border border-gray-200 dark:border-gray-700
1789 bg-gray-100 dark:bg-gray-800
1790 cursor-pointer
1791 transition-all duration-200 ease-out
1792 hover:bg-gray-200 dark:hover:bg-gray-700
1793 `}
1794 >
1795 <div className="flex items-center gap-3 overflow-hidden">
1796 {/* Icon Container */}
1797 <div className="flex items-center justify-center text-gray-500 dark:text-gray-400">
1798 <IconMdiWarning className="text-xl" />
1799 </div>
1800
1801 {/* Label Text */}
1802 <span className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
1803 {labelText}
1804 </span>
1805 </div>
1806
1807 {/* Chevron */}
1808 <div className="flex items-center justify-center text-gray-500 dark:text-gray-400 pl-2 gap-2 text-sm">
1809 {unauthedgate ? "please login to view" : isOpen ? "hide" : "show"}
1810 {!unauthedgate && (<IconMdiChevronDown
1811 className={`text-xl transition-transform duration-300 ease-[cubic-bezier(0.2,0,0,1)] ${isOpen ? "rotate-180" : ""
1812 }`}
1813 />)}
1814 </div>
1815 </div>
1816 </div>
1817 );
1818}
1819
1820export function SmallAuthorLabelBadge({
1821 label,
1822 large,
1823}: {
1824 label: LabelWithHydratedLocaleName;
1825 large?: boolean;
1826}) {
1827 /*
1828 -{" "}
1829 {ghld(label.src,label.val)?.severity} (from {label.sourceDid})
1830 */
1831 //const info = getLabelInfo(label.src, label.val);
1832
1833 const [imgcdn] = useAtom(imgCDNAtom);
1834
1835 const { data: opProfile } = useQueryProfile(
1836 `at://${label.src}/app.bsky.actor.profile/self`,
1837 );
1838
1839 const resolvedpfp = getAvatarUrl(opProfile, label.src, imgcdn);
1840
1841 return (
1842 <SmallAuthorLabelBadgeInner
1843 resolvedpfp={resolvedpfp || undefined}
1844 text={label.name || label.val}
1845 large={large}
1846 />
1847 );
1848}
1849
1850// todo add click event to explain the label or soemthing
1851export function SmallAuthorLabelBadgeInner({
1852 resolvedpfp,
1853 text,
1854 large,
1855 disablepfp = false,
1856}: {
1857 resolvedpfp?: string;
1858 text: string;
1859 large?: boolean;
1860 disablepfp?: boolean;
1861}) {
1862 return (
1863 <div
1864 className={`text-xs ${large ? "bg-gray-200" : "bg-gray-100"} dark:bg-gray-800 ${large ? "px-2 py-1" : "px-1 py-0.5"} rounded-full flex flex-row items-center gap-1`}
1865 >
1866 {!disablepfp && (<img
1867 src={resolvedpfp || defaultpfp}
1868 alt="avatar"
1869 className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
1870 style={{
1871 width: 12,
1872 height: 12,
1873 }}
1874 />)}
1875 <span className="font-medium">{text}</span>
1876 </div>
1877 );
1878}