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