an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
94
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 1878 lines 64 kB view raw
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}