an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm
at main 97 kB view raw
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 { renderSnack } from "~/routes/__root"; 1256import { 1257 FeedItemRenderAturiLoader, 1258 FollowButton, 1259 Mutual, 1260} from "~/routes/profile.$did"; 1261import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 1262import { useFastLike } from "~/utils/likeMutationQueue"; 1263// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; 1264// import type { 1265// ViewRecord, 1266// ViewNotFound, 1267// ViewBlocked, 1268// ViewDetached, 1269// } from "@atproto/api/dist/client/types/app/bsky/embed/record"; 1270//import type { MasonryItemData } from "./onemason/masonry.types"; 1271//import { MasonryLayout } from "./onemason/MasonryLayout"; 1272// const agent = new AtpAgent({ 1273// service: 'https://public.api.bsky.app' 1274// }) 1275type HitSlopButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & { 1276 hitSlop?: number; 1277}; 1278 1279const HitSlopButtonCustom: React.FC<HitSlopButtonProps> = ({ 1280 children, 1281 hitSlop = 8, 1282 style, 1283 ...rest 1284}) => ( 1285 <button 1286 {...rest} 1287 style={{ 1288 position: "relative", 1289 background: "none", 1290 border: "none", 1291 padding: 0, 1292 cursor: "pointer", 1293 ...style, 1294 }} 1295 > 1296 {/* Invisible hit slop area */} 1297 <span 1298 style={{ 1299 position: "absolute", 1300 top: -hitSlop, 1301 left: -hitSlop, 1302 right: -hitSlop, 1303 bottom: -hitSlop, 1304 }} 1305 /> 1306 {/* Actual button content stays positioned normally */} 1307 <span style={{ position: "relative", zIndex: 1 }}>{children}</span> 1308 </button> 1309); 1310 1311const HitSlopButton = ({ 1312 onClick, 1313 children, 1314 style = {}, 1315 ...rest 1316}: React.HTMLAttributes<HTMLSpanElement> & { 1317 onClick?: (e: React.MouseEvent) => void; 1318 children: React.ReactNode; 1319 style?: React.CSSProperties; 1320}) => ( 1321 <span 1322 style={{ position: "relative", display: "inline-block", cursor: "pointer" }} 1323 > 1324 <span 1325 style={{ 1326 position: "absolute", 1327 top: -8, 1328 left: -8, 1329 right: -8, 1330 bottom: -8, 1331 zIndex: 0, 1332 }} 1333 onClick={(e) => { 1334 e.stopPropagation(); 1335 onClick?.(e); 1336 }} 1337 /> 1338 <span 1339 style={{ 1340 ...style, 1341 position: "relative", 1342 zIndex: 1, 1343 pointerEvents: "none", 1344 }} 1345 {...rest} 1346 > 1347 {children} 1348 </span> 1349 </span> 1350); 1351 1352const btnstyle = { 1353 display: "flex", 1354 gap: 4, 1355 cursor: "pointer", 1356 alignItems: "center", 1357 fontSize: 14, 1358}; 1359function randomString(length = 8) { 1360 const chars = 1361 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 1362 return Array.from( 1363 { length }, 1364 () => chars[Math.floor(Math.random() * chars.length)] 1365 ).join(""); 1366} 1367 1368function UniversalPostRenderer({ 1369 post, 1370 uprrrsauthor, 1371 //setMainItem, 1372 //isMainItem, 1373 onPostClick, 1374 onProfileClick, 1375 expanded, 1376 //expanded, 1377 isQuote, 1378 //isQuote, 1379 extraOptionalItemInfo, 1380 bottomReplyLine, 1381 topReplyLine, 1382 salt, 1383 bottomBorder = true, 1384 feedviewpostreplyhandle, 1385 depth = 0, 1386 repostedby, 1387 style, 1388 ref, 1389 dataIndexPropPass, 1390 nopics, 1391 concise, 1392 lightboxCallback, 1393 maxReplies, 1394}: { 1395 post: PostView; 1396 uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed; 1397 // optional for now because i havent ported every use to this yet 1398 // setMainItem?: React.Dispatch< 1399 // React.SetStateAction<AppBskyFeedDefs.FeedViewPost> 1400 // >; 1401 //isMainItem?: boolean; 1402 onPostClick?: (e: React.MouseEvent) => void; 1403 onProfileClick?: (e: React.MouseEvent) => void; 1404 expanded?: boolean; 1405 isQuote?: boolean; 1406 extraOptionalItemInfo?: FeedViewPost; 1407 bottomReplyLine?: boolean; 1408 topReplyLine?: boolean; 1409 salt: string; 1410 bottomBorder?: boolean; 1411 feedviewpostreplyhandle?: string; 1412 depth?: number; 1413 repostedby?: string; 1414 style?: React.CSSProperties; 1415 ref?: React.RefObject<HTMLDivElement>; 1416 dataIndexPropPass?: number; 1417 nopics?: boolean; 1418 concise?: boolean; 1419 lightboxCallback?: (d: LightboxProps) => void; 1420 maxReplies?: number; 1421}) { 1422 const parsed = new AtUri(post.uri); 1423 const navigate = useNavigate(); 1424 const [hasRetweeted, setHasRetweeted] = useState<boolean>( 1425 post.viewer?.repost ? true : false 1426 ); 1427 const [, setComposerPost] = useAtom(composerAtom); 1428 const { agent } = useAuth(); 1429 const [retweetUri, setRetweetUri] = useState<string | undefined>( 1430 post.viewer?.repost 1431 ); 1432 const { liked, toggle, backfill } = useFastLike(post.uri, post.cid); 1433 // const bovref = useBackfillOnView(post.uri, post.cid); 1434 // React.useLayoutEffect(()=>{ 1435 // if (expanded && !isQuote) { 1436 // backfill(); 1437 // } 1438 // },[backfill, expanded, isQuote]) 1439 1440 const repostOrUnrepostPost = async () => { 1441 if (!agent) { 1442 console.error("Agent is null or undefined"); 1443 return; 1444 } 1445 if (hasRetweeted) { 1446 if (retweetUri) { 1447 await agent.deleteRepost(retweetUri); 1448 setHasRetweeted(false); 1449 } 1450 } else { 1451 const { uri } = await agent.repost(post.uri, post.cid); 1452 setRetweetUri(uri); 1453 setHasRetweeted(true); 1454 } 1455 }; 1456 1457 const isRepost = repostedby 1458 ? repostedby 1459 : extraOptionalItemInfo 1460 ? AppBskyFeedDefs.isReasonRepost(extraOptionalItemInfo.reason) 1461 ? extraOptionalItemInfo.reason?.by.displayName 1462 : undefined 1463 : undefined; 1464 const isReply = extraOptionalItemInfo 1465 ? extraOptionalItemInfo.reply 1466 : undefined; 1467 1468 const emergencySalt = randomString(); 1469 1470 const [showBridgyText] = useAtom(enableBridgyTextAtom); 1471 const [showWafrnText] = useAtom(enableWafrnTextAtom); 1472 1473 const unfedibridgy = (post.record as { bridgyOriginalText?: string }) 1474 .bridgyOriginalText; 1475 const unfediwafrnPartial = (post.record as { fullText?: string }).fullText; 1476 const unfediwafrnTags = (post.record as { fullTags?: string }).fullTags; 1477 const unfediwafrnUnHost = (post.record as { fediverseId?: string }) 1478 .fediverseId; 1479 1480 const undfediwafrnHost = unfediwafrnUnHost 1481 ? new URL(unfediwafrnUnHost).hostname 1482 : undefined; 1483 1484 const tags = unfediwafrnTags 1485 ? unfediwafrnTags 1486 .split("\n") 1487 .map((t) => t.trim()) 1488 .filter(Boolean) 1489 : undefined; 1490 1491 const links = tags 1492 ? tags 1493 .map((tag) => { 1494 const encoded = encodeURIComponent(tag); 1495 return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`; 1496 }) 1497 .join("<br>") 1498 : ""; 1499 1500 const unfediwafrn = unfediwafrnPartial 1501 ? unfediwafrnPartial + (links ? `<br>${links}` : "") 1502 : undefined; 1503 1504 const fedi = 1505 (showBridgyText ? unfedibridgy : undefined) ?? 1506 (showWafrnText ? unfediwafrn : undefined); 1507 1508 /* fuck you */ 1509 const isMainItem = false; 1510 const setMainItem = (any: any) => {}; 1511 // eslint-disable-next-line react-hooks/refs 1512 //console.log("Received ref in UniversalPostRenderer:", usedref); 1513 return ( 1514 <div ref={ref} style={style} data-index={dataIndexPropPass}> 1515 <div 1516 //ref={ref} 1517 key={salt + "-" + (post.uri || emergencySalt)} 1518 onClick={ 1519 isMainItem 1520 ? onPostClick 1521 : setMainItem 1522 ? onPostClick 1523 ? (e) => { 1524 setMainItem({ post: post }); 1525 onPostClick(e); 1526 } 1527 : () => { 1528 setMainItem({ post: post }); 1529 } 1530 : undefined 1531 } 1532 style={{ 1533 //...style, 1534 //border: "1px solid #e1e8ed", 1535 //borderRadius: 12, 1536 opacity: "1 !important", 1537 background: "transparent", 1538 paddingLeft: isQuote ? 12 : 16, 1539 paddingRight: isQuote ? 12 : 16, 1540 //paddingTop: 16, 1541 paddingTop: isRepost ? 10 : isQuote ? 12 : topReplyLine ? 8 : 16, 1542 //paddingBottom: bottomReplyLine ? 0 : 16, 1543 paddingBottom: 0, 1544 fontFamily: "system-ui, sans-serif", 1545 //boxShadow: "0 2px 8px rgba(0,0,0,0.04)", 1546 position: "relative", 1547 // dont cursor: "pointer", 1548 borderBottomWidth: bottomBorder ? (isQuote ? 0 : 1) : 0, 1549 }} 1550 className="border-gray-300 dark:border-gray-800" 1551 > 1552 {isRepost && ( 1553 <div 1554 style={{ 1555 marginLeft: 36, 1556 display: "flex", 1557 borderRadius: 12, 1558 paddingBottom: "calc(22px - 1rem)", 1559 fontSize: 14, 1560 maxHeight: "1rem", 1561 justifyContent: "flex-start", 1562 //color: theme.textSecondary, 1563 gap: 4, 1564 alignItems: "center", 1565 }} 1566 className="text-gray-500 dark:text-gray-400" 1567 > 1568 <MdiRepost /> Reposted by @{isRepost}{" "} 1569 </div> 1570 )} 1571 {!isQuote && ( 1572 <div 1573 style={{ 1574 opacity: 1575 topReplyLine || isReply /*&& (true || expanded)*/ ? 0.5 : 0, 1576 position: "absolute", 1577 top: 0, 1578 left: 36, // why 36 ??? 1579 //left: 16 + (42 / 2), 1580 width: 2, 1581 //height: "100%", 1582 height: isRepost 1583 ? "calc(16px + 1rem - 6px)" 1584 : topReplyLine 1585 ? 8 - 6 1586 : 16 - 6, 1587 // background: theme.textSecondary, 1588 //opacity: 0.5, 1589 // no flex here 1590 }} 1591 className="bg-gray-500 dark:bg-gray-400" 1592 /> 1593 )} 1594 <HoverCard.Root> 1595 <HoverCard.Trigger asChild> 1596 <div 1597 className={`absolute`} 1598 style={{ 1599 top: isRepost 1600 ? "calc(16px + 1rem)" 1601 : isQuote 1602 ? 12 1603 : topReplyLine 1604 ? 8 1605 : 16, 1606 left: isQuote ? 12 : 16, 1607 }} 1608 onClick={onProfileClick} 1609 > 1610 <img 1611 src={post.author.avatar || defaultpfp} 1612 alt="avatar" 1613 className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`} 1614 style={{ 1615 width: isQuote ? 16 : 42, 1616 height: isQuote ? 16 : 42, 1617 }} 1618 /> 1619 </div> 1620 </HoverCard.Trigger> 1621 <HoverCard.Portal> 1622 <HoverCard.Content 1623 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" 1624 side={"bottom"} 1625 sideOffset={5} 1626 onClick={onProfileClick} 1627 > 1628 <div className="flex flex-col gap-2"> 1629 <div className="flex flex-row"> 1630 <img 1631 src={post.author.avatar || defaultpfp} 1632 alt="avatar" 1633 className="rounded-full w-[58px] h-[58px] object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600" 1634 /> 1635 <div className=" flex-1 flex flex-row align-middle justify-end"> 1636 <FollowButton targetdidorhandle={post.author.did} /> 1637 </div> 1638 </div> 1639 <div className="flex flex-col gap-3"> 1640 <div> 1641 <div className="text-gray-900 dark:text-gray-100 font-medium text-md"> 1642 {post.author.displayName || post.author.handle}{" "} 1643 </div> 1644 <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1"> 1645 <Mutual targetdidorhandle={post.author.did} />@ 1646 {post.author.handle}{" "} 1647 </div> 1648 </div> 1649 {uprrrsauthor?.description && ( 1650 <div className="text-gray-700 dark:text-gray-300 text-sm text-left break-words line-clamp-3"> 1651 {uprrrsauthor.description} 1652 </div> 1653 )} 1654 {/* <div className="flex gap-4"> 1655 <div className="flex gap-1"> 1656 <div className="font-medium text-gray-900 dark:text-gray-100"> 1657 0 1658 </div> 1659 <div className="text-gray-500 dark:text-gray-400"> 1660 Following 1661 </div> 1662 </div> 1663 <div className="flex gap-1"> 1664 <div className="font-medium text-gray-900 dark:text-gray-100"> 1665 2,900 1666 </div> 1667 <div className="text-gray-500 dark:text-gray-400"> 1668 Followers 1669 </div> 1670 </div> 1671 </div> */} 1672 </div> 1673 </div> 1674 1675 {/* <HoverCard.Arrow className="fill-gray-50 dark:fill-gray-900" /> */} 1676 </HoverCard.Content> 1677 </HoverCard.Portal> 1678 </HoverCard.Root> 1679 1680 <div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}> 1681 <div 1682 style={{ 1683 display: "flex", 1684 flexDirection: "column", 1685 alignSelf: "stretch", 1686 alignItems: "center", 1687 overflow: "hidden", 1688 width: expanded || isQuote ? 0 : "auto", 1689 marginRight: expanded || isQuote ? 0 : 12, 1690 }} 1691 > 1692 {/* dummy for later use */} 1693 <div style={{ width: 42, height: 42 + 6, minHeight: 42 + 6 }} /> 1694 {/* reply line !!!! bottomReplyLine */} 1695 {bottomReplyLine && ( 1696 <div 1697 style={{ 1698 width: 2, 1699 height: "100%", 1700 //background: theme.textSecondary, 1701 opacity: 0.5, 1702 // no flex here 1703 //color: "Red", 1704 //zIndex: 99 1705 }} 1706 className="bg-gray-500 dark:bg-gray-400" 1707 /> 1708 )} 1709 {/* <div 1710 layout 1711 transition={{ duration: 0.2 }} 1712 animate={{ height: expanded ? 0 : '100%' }} 1713 style={{ 1714 width: 2.4, 1715 background: theme.border, 1716 // no flex here 1717 }} 1718 /> */} 1719 </div> 1720 <div style={{ flex: 1, maxWidth: "100%" }}> 1721 <div 1722 style={{ 1723 display: "flex", 1724 flexDirection: "row", 1725 alignItems: "center", 1726 flexWrap: "nowrap", 1727 maxWidth: `calc(100% - ${!expanded ? (isQuote ? 26 : 0) : 54}px)`, 1728 width: `calc(100% - ${!expanded ? (isQuote ? 26 : 0) : 54}px)`, 1729 marginLeft: !expanded ? (isQuote ? 26 : 0) : 54, 1730 marginBottom: !expanded ? 4 : 6, 1731 }} 1732 > 1733 <div 1734 style={{ 1735 display: "flex", 1736 //overflow: "hidden", // hey why is overflow hidden unapplied 1737 overflow: "hidden", 1738 textOverflow: "ellipsis", 1739 flexShrink: 1, 1740 flexGrow: 1, 1741 flexBasis: 0, 1742 width: 0, 1743 gap: expanded ? 0 : 6, 1744 alignItems: expanded ? "flex-start" : "center", 1745 flexDirection: expanded ? "column" : "row", 1746 height: expanded ? 42 : "1rem", 1747 }} 1748 > 1749 <span 1750 style={{ 1751 display: "flex", 1752 fontWeight: 700, 1753 fontSize: 16, 1754 overflow: "hidden", 1755 textOverflow: "ellipsis", 1756 whiteSpace: "nowrap", 1757 flexShrink: 1, 1758 minWidth: 0, 1759 gap: 4, 1760 alignItems: "center", 1761 //color: theme.text, 1762 }} 1763 className="text-gray-900 dark:text-gray-100" 1764 > 1765 {/* verified checkmark */} 1766 {post.author.displayName || post.author.handle}{" "} 1767 {post.author.verification?.verifiedStatus == "valid" && ( 1768 <MdiVerified /> 1769 )} 1770 </span> 1771 1772 <span 1773 style={{ 1774 //color: theme.textSecondary, 1775 fontSize: 16, 1776 overflowX: "hidden", 1777 textOverflow: "ellipsis", 1778 whiteSpace: "nowrap", 1779 flexShrink: 1, 1780 flexGrow: 0, 1781 minWidth: 0, 1782 }} 1783 className="text-gray-500 dark:text-gray-400" 1784 > 1785 @{post.author.handle} 1786 </span> 1787 </div> 1788 <div 1789 style={{ 1790 display: "flex", 1791 alignItems: "center", 1792 height: "1rem", 1793 }} 1794 > 1795 <span 1796 style={{ 1797 //color: theme.textSecondary, 1798 fontSize: 16, 1799 marginLeft: 8, 1800 whiteSpace: "nowrap", 1801 flexShrink: 0, 1802 maxWidth: "100%", 1803 }} 1804 className="text-gray-500 dark:text-gray-400" 1805 > 1806 · {/* time placeholder */} 1807 {shortTimeAgo(post.indexedAt)} 1808 </span> 1809 </div> 1810 </div> 1811 {/* reply indicator */} 1812 {!!feedviewpostreplyhandle && ( 1813 <div 1814 style={{ 1815 display: "flex", 1816 borderRadius: 12, 1817 paddingBottom: 2, 1818 fontSize: 14, 1819 justifyContent: "flex-start", 1820 //color: theme.textSecondary, 1821 gap: 4, 1822 alignItems: "center", 1823 //marginLeft: 36, 1824 height: 1825 !(expanded || isQuote) && !!feedviewpostreplyhandle 1826 ? "1rem" 1827 : 0, 1828 opacity: 1829 !(expanded || isQuote) && !!feedviewpostreplyhandle ? 1 : 0, 1830 }} 1831 className="text-gray-500 dark:text-gray-400" 1832 > 1833 <MdiReply /> Reply to @{feedviewpostreplyhandle} 1834 </div> 1835 )} 1836 <div 1837 style={{ 1838 fontSize: 16, 1839 marginBottom: !post.embed || concise ? 0 : 8, 1840 whiteSpace: "pre-wrap", 1841 textAlign: "left", 1842 overflowWrap: "anywhere", 1843 wordBreak: "break-word", 1844 ...(concise && { 1845 display: "-webkit-box", 1846 WebkitBoxOrient: "vertical", 1847 WebkitLineClamp: 2, 1848 overflow: "hidden", 1849 }), 1850 }} 1851 className="text-gray-900 dark:text-gray-100" 1852 > 1853 {fedi ? ( 1854 <> 1855 <span 1856 className="dangerousFediContent" 1857 dangerouslySetInnerHTML={{ 1858 __html: DOMPurify.sanitize(fedi), 1859 }} 1860 /> 1861 </> 1862 ) : ( 1863 <> 1864 {renderTextWithFacets({ 1865 text: (post.record as { text?: string }).text ?? "", 1866 facets: (post.record.facets as Facet[]) ?? [], 1867 navigate: navigate, 1868 })} 1869 </> 1870 )} 1871 </div> 1872 {post.embed && depth < 1 && !concise ? ( 1873 <PostEmbeds 1874 embed={post.embed} 1875 //moderation={moderation} 1876 viewContext={PostEmbedViewContext.Feed} 1877 salt={salt} 1878 navigate={navigate} 1879 postid={{ did: post.author.did, rkey: parsed.rkey }} 1880 nopics={nopics} 1881 lightboxCallback={lightboxCallback} 1882 /> 1883 ) : null} 1884 {post.embed && depth > 0 && ( 1885 /* pretty bad hack imo. its trying to sync up with how the embed shim doesnt 1886 hydrate embeds this deep but the connection here is implicit 1887 todo: idk make this a real part of the embed shim so its not implicit */ 1888 <> 1889 <div className="border-gray-300 dark:border-gray-800 p-3 rounded-xl border italic text-gray-400 text-[14px]"> 1890 (there is an embed here thats too deep to render) 1891 </div> 1892 </> 1893 )} 1894 <div 1895 style={{ 1896 paddingTop: post.embed && !concise && depth < 1 ? 4 : 0, 1897 }} 1898 > 1899 <> 1900 {expanded && ( 1901 <div 1902 style={{ 1903 overflow: "hidden", 1904 //color: theme.textSecondary, 1905 fontSize: 14, 1906 display: "flex", 1907 borderBottomStyle: "solid", 1908 //borderBottomColor: theme.border, 1909 //background: "#f00", 1910 // height: "1rem", 1911 paddingTop: 4, 1912 paddingBottom: 8, 1913 borderBottomWidth: 1, 1914 marginBottom: 8, 1915 }} // important for height animation 1916 className="text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-800 was7" 1917 > 1918 {fullDateTimeFormat(post.indexedAt)} 1919 </div> 1920 )} 1921 </> 1922 {!isQuote && ( 1923 <div 1924 style={{ 1925 display: "flex", 1926 gap: 32, 1927 paddingTop: 8, 1928 //color: theme.textSecondary, 1929 fontSize: 15, 1930 justifyContent: "space-between", 1931 //background: "#0f0", 1932 }} 1933 className="text-gray-500 dark:text-gray-400" 1934 > 1935 <HitSlopButton 1936 onClick={() => { 1937 setComposerPost({ kind: "reply", parent: post.uri }); 1938 }} 1939 style={{ 1940 ...btnstyle, 1941 }} 1942 > 1943 <MdiCommentOutline /> 1944 {post.replyCount} 1945 </HitSlopButton> 1946 <DropdownMenu.Root modal={false}> 1947 <DropdownMenu.Trigger asChild> 1948 <div 1949 style={{ 1950 ...btnstyle, 1951 ...(hasRetweeted ? { color: "#5CEFAA" } : {}), 1952 }} 1953 aria-label="Repost or quote post" 1954 > 1955 {hasRetweeted ? <MdiRepeatGreen /> : <MdiRepeat />} 1956 {post.repostCount ?? 0} 1957 </div> 1958 </DropdownMenu.Trigger> 1959 1960 <DropdownMenu.Portal> 1961 <DropdownMenu.Content 1962 align="start" 1963 sideOffset={5} 1964 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" 1965 > 1966 <DropdownMenu.Item 1967 onSelect={repostOrUnrepostPost} 1968 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" 1969 > 1970 <MdiRepeat 1971 className={hasRetweeted ? "text-green-400" : ""} 1972 /> 1973 <span>{hasRetweeted ? "Undo Repost" : "Repost"}</span> 1974 </DropdownMenu.Item> 1975 1976 <DropdownMenu.Item 1977 onSelect={() => { 1978 setComposerPost({ 1979 kind: "quote", 1980 subject: post.uri, 1981 }); 1982 }} 1983 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" 1984 > 1985 {/* You might want a specific quote icon here */} 1986 <MdiCommentOutline /> 1987 <span>Quote</span> 1988 </DropdownMenu.Item> 1989 </DropdownMenu.Content> 1990 </DropdownMenu.Portal> 1991 </DropdownMenu.Root> 1992 <HitSlopButton 1993 onClick={() => { 1994 toggle(); 1995 }} 1996 style={{ 1997 ...btnstyle, 1998 ...(liked ? { color: "#EC4899" } : {}), 1999 }} 2000 > 2001 {liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />} 2002 {(post.likeCount || 0) + (liked ? 1 : 0)} 2003 </HitSlopButton> 2004 <div style={{ display: "flex", gap: 8 }}> 2005 <HitSlopButton 2006 onClick={async (e) => { 2007 e.stopPropagation(); 2008 try { 2009 await navigator.clipboard.writeText( 2010 "https://bsky.app" + 2011 "/profile/" + 2012 post.author.handle + 2013 "/post/" + 2014 post.uri.split("/").pop() 2015 ); 2016 renderSnack({ 2017 title: "Copied to clipboard!", 2018 }); 2019 } catch (_e) { 2020 // idk 2021 renderSnack({ 2022 title: "Failed to copy link", 2023 }); 2024 } 2025 }} 2026 style={{ 2027 ...btnstyle, 2028 }} 2029 > 2030 <MdiShareVariant /> 2031 </HitSlopButton> 2032 <HitSlopButton 2033 onClick={() => { 2034 renderSnack({ 2035 title: "Not implemented yet...", 2036 }); 2037 }} 2038 > 2039 <span style={btnstyle}> 2040 <MdiMoreHoriz /> 2041 </span> 2042 </HitSlopButton> 2043 </div> 2044 </div> 2045 )} 2046 </div> 2047 <div 2048 style={{ 2049 //height: bottomReplyLine ? 16 : 0 2050 height: isQuote ? 12 : 16, 2051 }} 2052 /> 2053 </div> 2054 </div> 2055 </div> 2056 </div> 2057 ); 2058} 2059 2060const fullDateTimeFormat = (iso: string) => { 2061 const date = new Date(iso); 2062 return date.toLocaleString("en-US", { 2063 month: "long", 2064 day: "numeric", 2065 year: "numeric", 2066 hour: "numeric", 2067 minute: "2-digit", 2068 hour12: true, 2069 }); 2070}; 2071const shortTimeAgo = (iso: string) => { 2072 const diff = Date.now() - new Date(iso).getTime(); 2073 const mins = Math.floor(diff / 60000); 2074 if (mins < 1) return "now"; 2075 if (mins < 60) return `${mins}m`; 2076 const hrs = Math.floor(mins / 60); 2077 if (hrs < 24) return `${hrs}h`; 2078 const days = Math.floor(hrs / 24); 2079 return `${days}d`; 2080}; 2081 2082// const toAtUri = (url: string) => 2083// url 2084// .replace("https://bsky.app/profile/", "at://") 2085// .replace("/feed/", "/app.bsky.feed.generator/"); 2086 2087// function PostSizedElipsis() { 2088// return ( 2089// <div 2090// style={{ display: "flex", flexDirection: "row", alignItems: "center" }} 2091// > 2092// <div 2093// style={{ 2094// width: 2, 2095// height: 40, 2096// //background: theme.textSecondary, 2097// background: `repeating-linear-gradient(to bottom, var(--color-gray-400) 0px, var(--color-gray-400) 6px, transparent 6px, transparent 10px)`, 2098// backgroundSize: "100% 10px", 2099// opacity: 0.5, 2100// marginLeft: 36, // why 36 ??? 2101// }} 2102// /> 2103// <span 2104// style={{ 2105// //color: theme.textSecondary, 2106// marginLeft: 34, 2107// }} 2108// className="text-gray-500 dark:text-gray-400" 2109// > 2110// more posts 2111// </span> 2112// </div> 2113// ); 2114// } 2115 2116type Embed = 2117 | AppBskyEmbedRecord.View 2118 | AppBskyEmbedImages.View 2119 | AppBskyEmbedVideo.View 2120 | AppBskyEmbedExternal.View 2121 | AppBskyEmbedRecordWithMedia.View 2122 | { $type: string; [k: string]: unknown }; 2123 2124enum PostEmbedViewContext { 2125 ThreadHighlighted = "ThreadHighlighted", 2126 Feed = "Feed", 2127 FeedEmbedRecordWithMedia = "FeedEmbedRecordWithMedia", 2128} 2129const stopgap = { 2130 display: "flex", 2131 justifyContent: "center", 2132 padding: "32px 12px", 2133 borderRadius: 12, 2134 border: "1px solid rgba(161, 170, 174, 0.38)", 2135}; 2136 2137function PostEmbeds({ 2138 embed, 2139 moderation, 2140 onOpen, 2141 allowNestedQuotes, 2142 viewContext, 2143 salt, 2144 navigate, 2145 postid, 2146 nopics, 2147 lightboxCallback, 2148}: { 2149 embed?: Embed; 2150 moderation?: ModerationDecision; 2151 onOpen?: () => void; 2152 allowNestedQuotes?: boolean; 2153 viewContext?: PostEmbedViewContext; 2154 salt: string; 2155 navigate: (_: any) => void; 2156 postid?: { did: string; rkey: string }; 2157 nopics?: boolean; 2158 lightboxCallback?: (d: LightboxProps) => void; 2159}) { 2160 //const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); 2161 function setLightboxIndex(number: number) { 2162 navigate({ 2163 to: "/profile/$did/post/$rkey/image/$i", 2164 params: { 2165 did: postid?.did, 2166 rkey: postid?.rkey, 2167 i: number.toString(), 2168 }, 2169 }); 2170 } 2171 if ( 2172 AppBskyEmbedRecordWithMedia.isView(embed) && 2173 AppBskyEmbedRecord.isViewRecord(embed.record.record) && 2174 AppBskyFeedPost.isRecord(embed.record.record.value) //&& 2175 //AppBskyFeedPost.validateRecord(embed.record.record.value).success 2176 ) { 2177 const post: PostView = { 2178 $type: "app.bsky.feed.defs#postView", // lmao lies 2179 uri: embed.record.record.uri, 2180 cid: embed.record.record.cid, 2181 author: embed.record.record.author, 2182 record: embed.record.record.value as { [key: string]: unknown }, 2183 embed: embed.record.record.embeds 2184 ? embed.record.record.embeds?.[0] 2185 : undefined, // quotes handles embeds differently, its an array for some reason 2186 replyCount: embed.record.record.replyCount, 2187 repostCount: embed.record.record.repostCount, 2188 likeCount: embed.record.record.likeCount, 2189 quoteCount: embed.record.record.quoteCount, 2190 indexedAt: embed.record.record.indexedAt, 2191 // we dont have a viewer, so this is a best effort conversion, still requires full query later on 2192 labels: embed.record.record.labels, 2193 // neither do we have threadgate. remember to please fetch the full post later 2194 }; 2195 return ( 2196 <div> 2197 <PostEmbeds 2198 embed={embed.media} 2199 moderation={moderation} 2200 onOpen={onOpen} 2201 viewContext={viewContext} 2202 salt={salt} 2203 navigate={navigate} 2204 postid={postid} 2205 nopics={nopics} 2206 lightboxCallback={lightboxCallback} 2207 /> 2208 {/* padding empty div of 8px height */} 2209 <div style={{ height: 12 }} /> 2210 {/* stopgap sorry*/} 2211 <div 2212 style={{ 2213 display: "flex", 2214 flexDirection: "column", 2215 borderRadius: 12, 2216 //border: `1px solid ${theme.border}`, 2217 //boxShadow: theme.cardShadow, 2218 overflow: "hidden", 2219 }} 2220 className="shadow border border-gray-200 dark:border-gray-800 was7" 2221 > 2222 <UniversalPostRenderer 2223 post={post} 2224 isQuote 2225 salt={salt} 2226 onPostClick={(e) => { 2227 e.stopPropagation(); 2228 const parsed = new AtUri(post.uri); //parseAtUri(post.uri); 2229 if (parsed) { 2230 navigate({ 2231 to: "/profile/$did/post/$rkey", 2232 params: { did: parsed.host, rkey: parsed.rkey }, 2233 }); 2234 } 2235 }} 2236 depth={1} 2237 /> 2238 </div> 2239 {/* <QuotePostRenderer 2240 record={embed.record.record} 2241 moderation={moderation} 2242 /> */} 2243 {/* stopgap sorry */} 2244 {/* <div style={stopgap}>quote post placeholder</div> */} 2245 {/* {<MaybeQuoteEmbed 2246 embed={embed.record} 2247 onOpen={onOpen} 2248 viewContext={ 2249 viewContext === PostEmbedViewContext.Feed 2250 ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia 2251 : undefined 2252 } 2253 {/* <div style={stopgap}>quote post placeholder</div> */} 2254 {/* {<MaybeQuoteEmbed 2255 embed={embed.record} 2256 onOpen={onOpen} 2257 viewContext={ 2258 viewContext === PostEmbedViewContext.Feed 2259 ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia 2260 : undefined 2261 } 2262 />} */} 2263 </div> 2264 ); 2265 } 2266 2267 if (AppBskyEmbedRecord.isView(embed)) { 2268 // hey im really lazy and im gonna do it the bad way 2269 const reallybaduri = (embed?.record as any)?.uri as string | undefined; 2270 const reallybadaturi = reallybaduri ? new AtUri(reallybaduri) : undefined; 2271 2272 // custom feed embed (i.e. generator view) 2273 if (AppBskyFeedDefs.isGeneratorView(embed.record)) { 2274 // stopgap sorry 2275 return <div style={stopgap}>feedgen placeholder</div>; 2276 // return ( 2277 // <div style={{ marginTop: '1rem' }}> 2278 // <MaybeFeedCard view={embed.record} /> 2279 // </div> 2280 // ) 2281 } else if ( 2282 !!reallybaduri && 2283 !!reallybadaturi && 2284 reallybadaturi.collection === "app.bsky.feed.generator" 2285 ) { 2286 return ( 2287 <div className="rounded-xl border"> 2288 <FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder /> 2289 </div> 2290 ); 2291 } 2292 2293 // list embed 2294 if (AppBskyGraphDefs.isListView(embed.record)) { 2295 // stopgap sorry 2296 return <div style={stopgap}>list placeholder</div>; 2297 // return ( 2298 // <div style={{ marginTop: '1rem' }}> 2299 // <MaybeListCard view={embed.record} /> 2300 // </div> 2301 // ) 2302 } else if ( 2303 !!reallybaduri && 2304 !!reallybadaturi && 2305 reallybadaturi.collection === "app.bsky.graph.list" 2306 ) { 2307 return ( 2308 <div className="rounded-xl border"> 2309 <FeedItemRenderAturiLoader 2310 aturi={reallybaduri} 2311 disableBottomBorder 2312 listmode 2313 disablePropagation 2314 /> 2315 </div> 2316 ); 2317 } 2318 2319 // starter pack embed 2320 if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) { 2321 // stopgap sorry 2322 return <div style={stopgap}>starter pack card placeholder</div>; 2323 // return ( 2324 // <div style={{ marginTop: '1rem' }}> 2325 // <StarterPackCard starterPack={embed.record} /> 2326 // </div> 2327 // ) 2328 } else if ( 2329 !!reallybaduri && 2330 !!reallybadaturi && 2331 reallybadaturi.collection === "app.bsky.graph.starterpack" 2332 ) { 2333 return ( 2334 <div className="rounded-xl border"> 2335 <FeedItemRenderAturiLoader 2336 aturi={reallybaduri} 2337 disableBottomBorder 2338 listmode 2339 disablePropagation 2340 /> 2341 </div> 2342 ); 2343 } 2344 2345 // quote post 2346 // = 2347 // stopgap sorry 2348 2349 if ( 2350 AppBskyEmbedRecord.isViewRecord(embed.record) && 2351 AppBskyFeedPost.isRecord(embed.record.value) // && 2352 //AppBskyFeedPost.validateRecord(embed.record.value).success 2353 ) { 2354 const post: PostView = { 2355 $type: "app.bsky.feed.defs#postView", // lmao lies 2356 uri: embed.record.uri, 2357 cid: embed.record.cid, 2358 author: embed.record.author, 2359 record: embed.record.value as { [key: string]: unknown }, 2360 embed: embed.record.embeds ? embed.record.embeds?.[0] : undefined, // quotes handles embeds differently, its an array for some reason 2361 replyCount: embed.record.replyCount, 2362 repostCount: embed.record.repostCount, 2363 likeCount: embed.record.likeCount, 2364 quoteCount: embed.record.quoteCount, 2365 indexedAt: embed.record.indexedAt, 2366 // we dont have a viewer, so this is a best effort conversion, still requires full query later on 2367 labels: embed.record.labels, 2368 // neither do we have threadgate. remember to please fetch the full post later 2369 }; 2370 2371 return ( 2372 <div 2373 style={{ 2374 display: "flex", 2375 flexDirection: "column", 2376 borderRadius: 12, 2377 //border: `1px solid ${theme.border}`, 2378 //boxShadow: theme.cardShadow, 2379 overflow: "hidden", 2380 }} 2381 className="shadow border border-gray-200 dark:border-gray-800 was7" 2382 > 2383 <UniversalPostRenderer 2384 post={post} 2385 isQuote 2386 salt={salt} 2387 onPostClick={(e) => { 2388 e.stopPropagation(); 2389 const parsed = new AtUri(post.uri); //parseAtUri(post.uri); 2390 if (parsed) { 2391 navigate({ 2392 to: "/profile/$did/post/$rkey", 2393 params: { did: parsed.host, rkey: parsed.rkey }, 2394 }); 2395 } 2396 }} 2397 depth={1} 2398 /> 2399 </div> 2400 ); 2401 } else { 2402 console.log("what the hell is a ", embed); 2403 return <>sorry</>; 2404 } 2405 //return <QuotePostRenderer record={embed.record} moderation={moderation} />; 2406 2407 //return <div style={stopgap}>quote post placeholder</div>; 2408 // return ( 2409 // <MaybeQuoteEmbed 2410 // embed={embed} 2411 // onOpen={onOpen} 2412 // allowNestedQuotes={allowNestedQuotes} 2413 // /> 2414 // ) 2415 } 2416 2417 // image embed 2418 // = 2419 if (AppBskyEmbedImages.isView(embed)) { 2420 const { images } = embed; 2421 2422 const lightboxImages = images.map((img) => ({ 2423 src: img.fullsize, 2424 alt: img.alt, 2425 })); 2426 console.log("rendering images"); 2427 if (lightboxCallback) { 2428 lightboxCallback({ images: lightboxImages }); 2429 console.log("rendering images"); 2430 } 2431 2432 if (nopics) return; 2433 2434 if (images.length > 0) { 2435 // const items = embed.images.map(img => ({ 2436 // uri: img.fullsize, 2437 // thumbUri: img.thumb, 2438 // alt: img.alt, 2439 // dimensions: img.aspectRatio ?? null, 2440 // })) 2441 2442 if (images.length === 1) { 2443 const image = images[0]; 2444 return ( 2445 <div style={{ marginTop: 0 }}> 2446 <div 2447 style={{ 2448 position: "relative", 2449 width: "100%", 2450 aspectRatio: image.aspectRatio 2451 ? (() => { 2452 const { width, height } = image.aspectRatio; 2453 const ratio = width / height; 2454 return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`; 2455 })() 2456 : "1 / 1", // fallback to square 2457 //backgroundColor: theme.background, // fallback letterboxing color 2458 borderRadius: 12, 2459 //border: `1px solid ${theme.border}`, 2460 overflow: "hidden", 2461 }} 2462 className="border border-gray-200 dark:border-gray-800 was7 bg-gray-200 dark:bg-gray-900" 2463 > 2464 {/* {lightboxIndex !== null && ( 2465 <Lightbox 2466 images={lightboxImages} 2467 index={lightboxIndex} 2468 onClose={() => setLightboxIndex(null)} 2469 onNavigate={(newIndex) => setLightboxIndex(newIndex)} 2470 post={postid} 2471 /> 2472 )} */} 2473 <img 2474 src={image.fullsize} 2475 alt={image.alt} 2476 style={{ 2477 width: "100%", 2478 height: "100%", 2479 objectFit: "contain", // letterbox or scale to fit 2480 }} 2481 onClick={(e) => { 2482 e.stopPropagation(); 2483 setLightboxIndex(0); 2484 }} 2485 /> 2486 </div> 2487 </div> 2488 ); 2489 } 2490 // 2 images: side by side, both 1:1, cropped 2491 if (images.length === 2) { 2492 return ( 2493 <div 2494 style={{ 2495 display: "flex", 2496 gap: 4, 2497 marginTop: 0, 2498 width: "100%", 2499 borderRadius: 12, 2500 overflow: "hidden", 2501 //border: `1px solid ${theme.border}`, 2502 }} 2503 className="border border-gray-200 dark:border-gray-800 was7" 2504 > 2505 {/* {lightboxIndex !== null && ( 2506 <Lightbox 2507 images={lightboxImages} 2508 index={lightboxIndex} 2509 onClose={() => setLightboxIndex(null)} 2510 onNavigate={(newIndex) => setLightboxIndex(newIndex)} 2511 post={postid} 2512 /> 2513 )} */} 2514 {images.map((img, i) => ( 2515 <div 2516 key={i} 2517 style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }} 2518 > 2519 <img 2520 src={img.fullsize} 2521 alt={img.alt} 2522 style={{ 2523 width: "100%", 2524 height: "100%", 2525 objectFit: "cover", 2526 borderRadius: i === 0 ? "12px 0 0 12px" : "0 12px 12px 0", 2527 }} 2528 onClick={(e) => { 2529 e.stopPropagation(); 2530 setLightboxIndex(i); 2531 }} 2532 /> 2533 </div> 2534 ))} 2535 </div> 2536 ); 2537 } 2538 2539 // 3 images: left is 1:1, right is two stacked 2:1 2540 if (images.length === 3) { 2541 return ( 2542 <div 2543 style={{ 2544 display: "flex", 2545 gap: 4, 2546 marginTop: 0, 2547 width: "100%", 2548 borderRadius: 12, 2549 overflow: "hidden", 2550 //border: `1px solid ${theme.border}`, 2551 // height: 240, // fixed height for cropping 2552 }} 2553 className="border border-gray-200 dark:border-gray-800 was7" 2554 > 2555 {/* {lightboxIndex !== null && ( 2556 <Lightbox 2557 images={lightboxImages} 2558 index={lightboxIndex} 2559 onClose={() => setLightboxIndex(null)} 2560 onNavigate={(newIndex) => setLightboxIndex(newIndex)} 2561 post={postid} 2562 /> 2563 )} */} 2564 {/* Left: 1:1 */} 2565 <div 2566 style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }} 2567 > 2568 <img 2569 src={images[0].fullsize} 2570 alt={images[0].alt} 2571 style={{ 2572 width: "100%", 2573 height: "100%", 2574 objectFit: "cover", 2575 borderRadius: "12px 0 0 12px", 2576 }} 2577 onClick={(e) => { 2578 e.stopPropagation(); 2579 setLightboxIndex(0); 2580 }} 2581 /> 2582 </div> 2583 {/* Right: two stacked 2:1 */} 2584 <div 2585 style={{ 2586 flex: 1, 2587 display: "flex", 2588 flexDirection: "column", 2589 gap: 4, 2590 }} 2591 > 2592 {[1, 2].map((i) => ( 2593 <div 2594 key={i} 2595 style={{ 2596 flex: 1, 2597 aspectRatio: "2 / 1", 2598 position: "relative", 2599 }} 2600 > 2601 <img 2602 src={images[i].fullsize} 2603 alt={images[i].alt} 2604 style={{ 2605 width: "100%", 2606 height: "100%", 2607 objectFit: "cover", 2608 borderRadius: i === 1 ? "0 12px 0 0" : "0 0 12px 0", 2609 }} 2610 onClick={(e) => { 2611 e.stopPropagation(); 2612 setLightboxIndex(i + 1); 2613 }} 2614 /> 2615 </div> 2616 ))} 2617 </div> 2618 </div> 2619 ); 2620 } 2621 2622 // 4 images: 2x2 grid, all 3:2 2623 if (images.length === 4) { 2624 return ( 2625 <div 2626 style={{ 2627 display: "grid", 2628 gridTemplateColumns: "1fr 1fr", 2629 gridTemplateRows: "1fr 1fr", 2630 gap: 4, 2631 marginTop: 0, 2632 width: "100%", 2633 borderRadius: 12, 2634 overflow: "hidden", 2635 //border: `1px solid ${theme.border}`, 2636 //aspectRatio: "3 / 2", // overall grid aspect 2637 }} 2638 className="border border-gray-200 dark:border-gray-800 was7" 2639 > 2640 {/* {lightboxIndex !== null && ( 2641 <Lightbox 2642 images={lightboxImages} 2643 index={lightboxIndex} 2644 onClose={() => setLightboxIndex(null)} 2645 onNavigate={(newIndex) => setLightboxIndex(newIndex)} 2646 post={postid} 2647 /> 2648 )} */} 2649 {images.map((img, i) => ( 2650 <div 2651 key={i} 2652 style={{ 2653 width: "100%", 2654 height: "100%", 2655 aspectRatio: "3 / 2", 2656 position: "relative", 2657 }} 2658 > 2659 <img 2660 src={img.fullsize} 2661 alt={img.alt} 2662 style={{ 2663 width: "100%", 2664 height: "100%", 2665 objectFit: "cover", 2666 borderRadius: 2667 i === 0 2668 ? "12px 0 0 0" 2669 : i === 1 2670 ? "0 12px 0 0" 2671 : i === 2 2672 ? "0 0 0 12px" 2673 : "0 0 12px 0", 2674 }} 2675 onClick={(e) => { 2676 e.stopPropagation(); 2677 setLightboxIndex(i); 2678 }} 2679 /> 2680 </div> 2681 ))} 2682 </div> 2683 ); 2684 } 2685 2686 // stopgap sorry 2687 return <div style={stopgap}>image count more than one placeholder</div>; 2688 // return ( 2689 // <div style={{ marginTop: '1rem' }}> 2690 // <ImageLayoutGrid 2691 // images={images} 2692 // viewContext={viewContext} 2693 // /> 2694 // </div> 2695 // ) 2696 } 2697 } 2698 2699 // external link embed 2700 // = 2701 if (AppBskyEmbedExternal.isView(embed)) { 2702 const link = embed.external; 2703 return ( 2704 <ExternalLinkEmbed link={link} onOpen={onOpen} style={{ marginTop: 0 }} /> 2705 ); 2706 } 2707 2708 // video embed 2709 // = 2710 if (AppBskyEmbedVideo.isView(embed)) { 2711 // hls playlist 2712 if (nopics) return; 2713 const playlist = embed.playlist; 2714 return ( 2715 <SmartHLSPlayer 2716 url={playlist} 2717 thumbnail={embed.thumbnail} 2718 aspect={embed.aspectRatio} 2719 /> 2720 ); 2721 // stopgap sorry 2722 //return (<div>video</div>) 2723 // return ( 2724 // <VideoEmbed 2725 // embed={embed} 2726 // crop={ 2727 // viewContext === PostEmbedViewContext.ThreadHighlighted 2728 // ? 'none' 2729 // : viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia 2730 // ? 'square' 2731 // : 'constrained' 2732 // } 2733 // /> 2734 // ) 2735 } 2736 2737 return <div />; 2738} 2739 2740function getDomain(url: string) { 2741 try { 2742 const { hostname } = new URL(url); 2743 return hostname; 2744 } catch (e) { 2745 // In case it's a bare domain like "example.com" 2746 if (!url.startsWith("http")) { 2747 try { 2748 const { hostname } = new URL("http://" + url); 2749 return hostname; 2750 } catch { 2751 return null; 2752 } 2753 } 2754 return null; 2755 } 2756} 2757function getByteToCharMap(text: string): number[] { 2758 const encoder = new TextEncoder(); 2759 //const utf8 = encoder.encode(text); 2760 2761 const map: number[] = []; 2762 let byteIndex = 0; 2763 let charIndex = 0; 2764 2765 for (const char of text) { 2766 const bytes = encoder.encode(char); 2767 for (let i = 0; i < bytes.length; i++) { 2768 map[byteIndex++] = charIndex; 2769 } 2770 charIndex += char.length; 2771 } 2772 2773 return map; 2774} 2775 2776function facetByteRangeToCharRange( 2777 byteStart: number, 2778 byteEnd: number, 2779 byteToCharMap: number[] 2780): [number, number] { 2781 return [ 2782 byteToCharMap[byteStart] ?? 0, 2783 byteToCharMap[byteEnd - 1]! + 1, // inclusive end -> exclusive char end 2784 ]; 2785} 2786 2787interface FacetRange { 2788 start: number; 2789 end: number; 2790 feature: Facet["features"][number]; 2791} 2792 2793function extractFacetRanges(text: string, facets: Facet[]): FacetRange[] { 2794 const map = getByteToCharMap(text); 2795 return facets.map((f) => { 2796 const [start, end] = facetByteRangeToCharRange( 2797 f.index.byteStart, 2798 f.index.byteEnd, 2799 map 2800 ); 2801 return { start, end, feature: f.features[0] }; 2802 }); 2803} 2804export function renderTextWithFacets({ 2805 text, 2806 facets, 2807 navigate, 2808}: { 2809 text: string; 2810 facets: Facet[]; 2811 navigate: (_: any) => void; 2812}) { 2813 const ranges = extractFacetRanges(text, facets).sort( 2814 (a: any, b: any) => a.start - b.start 2815 ); 2816 2817 const result: React.ReactNode[] = []; 2818 let current = 0; 2819 2820 for (const { start, end, feature } of ranges) { 2821 if (current < start) { 2822 result.push(<span key={current}>{text.slice(current, start)}</span>); 2823 } 2824 2825 const fragment = text.slice(start, end); 2826 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 2827 if (feature.$type === "app.bsky.richtext.facet#link" && feature.uri) { 2828 result.push( 2829 <a 2830 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 2831 href={feature.uri} 2832 key={start} 2833 className="link" 2834 style={{ 2835 textDecoration: "none", 2836 color: "var(--link-text-color)", 2837 wordBreak: "break-all", 2838 }} 2839 target="_blank" 2840 rel="noreferrer" 2841 onClick={(e) => { 2842 e.stopPropagation(); 2843 }} 2844 > 2845 {fragment} 2846 </a> 2847 ); 2848 } else if ( 2849 feature.$type === "app.bsky.richtext.facet#mention" && 2850 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 2851 feature.did 2852 ) { 2853 result.push( 2854 <span 2855 key={start} 2856 style={{ color: "var(--link-text-color)" }} 2857 className=" cursor-pointer" 2858 onClick={(e) => { 2859 e.stopPropagation(); 2860 navigate({ 2861 to: "/profile/$did", 2862 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 2863 params: { did: feature.did }, 2864 }); 2865 }} 2866 > 2867 {fragment} 2868 </span> 2869 ); 2870 } else if (feature.$type === "app.bsky.richtext.facet#tag") { 2871 result.push( 2872 <span 2873 key={start} 2874 style={{ color: "var(--link-text-color)" }} 2875 onClick={(e) => { 2876 e.stopPropagation(); 2877 }} 2878 > 2879 {fragment} 2880 </span> 2881 ); 2882 } else { 2883 result.push(<span key={start}>{fragment}</span>); 2884 } 2885 2886 current = end; 2887 } 2888 2889 if (current < text.length) { 2890 result.push(<span key={current}>{text.slice(current)}</span>); 2891 } 2892 2893 return result; 2894} 2895function ExternalLinkEmbed({ 2896 link, 2897 onOpen, 2898 style, 2899}: { 2900 link: AppBskyEmbedExternal.ViewExternal; 2901 onOpen?: () => void; 2902 style?: React.CSSProperties; 2903}) { 2904 //const { theme } = useTheme(); 2905 const { uri, title, description, thumb } = link; 2906 const thumbAspectRatio = 1.91; 2907 const titleStyle = { 2908 fontSize: 16, 2909 fontWeight: 700, 2910 marginBottom: 4, 2911 //color: theme.text, 2912 wordBreak: "break-word", 2913 textAlign: "left", 2914 maxHeight: "4em", // 2 lines * 1.5em line-height 2915 // stupid shit 2916 display: "-webkit-box", 2917 WebkitBoxOrient: "vertical", 2918 overflow: "hidden", 2919 WebkitLineClamp: 2, 2920 }; 2921 const descriptionStyle = { 2922 fontSize: 14, 2923 //color: theme.textSecondary, 2924 marginBottom: 8, 2925 wordBreak: "break-word", 2926 textAlign: "left", 2927 maxHeight: "5em", // 3 lines * 1.5em line-height 2928 // stupid shit 2929 display: "-webkit-box", 2930 WebkitBoxOrient: "vertical", 2931 overflow: "hidden", 2932 WebkitLineClamp: 3, 2933 }; 2934 const linkStyle = { 2935 textDecoration: "none", 2936 //color: theme.textSecondary, 2937 wordBreak: "break-all", 2938 textAlign: "left", 2939 }; 2940 const containerStyle = { 2941 display: "flex", 2942 flexDirection: "column", 2943 //backgroundColor: theme.background, 2944 //background: '#eee', 2945 borderRadius: 12, 2946 //border: `1px solid ${theme.border}`, 2947 //boxShadow: theme.cardShadow, 2948 maxWidth: "100%", 2949 overflow: "hidden", 2950 ...style, 2951 }; 2952 return ( 2953 <a 2954 href={uri} 2955 target="_blank" 2956 rel="noopener noreferrer" 2957 onClick={(e) => { 2958 e.stopPropagation(); 2959 if (onOpen) onOpen(); 2960 }} 2961 /* @ts-expect-error css arent typed or something idk fuck you */ 2962 style={linkStyle} 2963 className="text-gray-500 dark:text-gray-400" 2964 > 2965 <div 2966 style={containerStyle as React.CSSProperties} 2967 className="border border-gray-200 dark:border-gray-800 was7" 2968 > 2969 {thumb && ( 2970 <div 2971 style={{ 2972 position: "relative", 2973 width: "100%", 2974 aspectRatio: thumbAspectRatio, 2975 overflow: "hidden", 2976 borderTopLeftRadius: 12, 2977 borderTopRightRadius: 12, 2978 marginBottom: 8, 2979 //borderBottom: `1px solid ${theme.border}`, 2980 }} 2981 className="border-b border-gray-200 dark:border-gray-800 was7" 2982 > 2983 <img 2984 src={thumb} 2985 alt={description} 2986 style={{ 2987 position: "absolute", 2988 top: 0, 2989 left: 0, 2990 width: "100%", 2991 height: "100%", 2992 objectFit: "cover", 2993 }} 2994 /> 2995 </div> 2996 )} 2997 <div 2998 style={{ 2999 paddingBottom: 12, 3000 paddingLeft: 12, 3001 paddingRight: 12, 3002 paddingTop: thumb ? 0 : 12, 3003 }} 3004 > 3005 {/* @ts-expect-error css */} 3006 <div style={titleStyle} className="text-gray-900 dark:text-gray-100"> 3007 {title} 3008 </div> 3009 <div 3010 style={descriptionStyle as React.CSSProperties} 3011 className="text-gray-500 dark:text-gray-400" 3012 > 3013 {description} 3014 </div> 3015 {/* small 1px divider here */} 3016 <div 3017 style={{ 3018 height: 1, 3019 //backgroundColor: theme.border, 3020 marginBottom: 8, 3021 }} 3022 className="bg-gray-200 dark:bg-gray-700" 3023 /> 3024 <div 3025 style={{ 3026 display: "flex", 3027 alignItems: "center", 3028 gap: 4, 3029 }} 3030 > 3031 <MdiGlobe /> 3032 <span 3033 style={{ 3034 fontSize: 12, 3035 //color: theme.textSecondary 3036 }} 3037 className="text-gray-500 dark:text-gray-400" 3038 > 3039 {getDomain(uri)} 3040 </span> 3041 </div> 3042 </div> 3043 </div> 3044 </a> 3045 ); 3046} 3047 3048const SmartHLSPlayer = ({ 3049 url, 3050 thumbnail, 3051 aspect, 3052}: { 3053 url: string; 3054 thumbnail?: string; 3055 aspect?: AppBskyEmbedDefs.AspectRatio; 3056}) => { 3057 const [playing, setPlaying] = useState(false); 3058 const containerRef = useRef(null); 3059 3060 // pause the player if it goes out of viewport 3061 useEffect(() => { 3062 const observer = new IntersectionObserver( 3063 ([entry]) => { 3064 if (!entry.isIntersecting && playing) { 3065 setPlaying(false); 3066 } 3067 }, 3068 { 3069 root: null, 3070 threshold: 0.25, 3071 } 3072 ); 3073 3074 if (containerRef.current) { 3075 observer.observe(containerRef.current); 3076 } 3077 3078 return () => { 3079 if (containerRef.current) { 3080 observer.unobserve(containerRef.current); 3081 } 3082 }; 3083 }, [playing]); 3084 3085 return ( 3086 <div 3087 ref={containerRef} 3088 style={{ 3089 position: "relative", 3090 width: "100%", 3091 maxWidth: 640, 3092 cursor: "pointer", 3093 }} 3094 > 3095 {!playing && ( 3096 <> 3097 <img 3098 src={thumbnail} 3099 alt="Video thumbnail" 3100 style={{ 3101 width: "100%", 3102 display: "block", 3103 aspectRatio: aspect ? aspect?.width / aspect?.height : 16 / 9, 3104 borderRadius: 12, 3105 //border: `1px solid ${theme.border}`, 3106 }} 3107 className="border border-gray-200 dark:border-gray-800 was7" 3108 onClick={async (e) => { 3109 e.stopPropagation(); 3110 setPlaying(true); 3111 }} 3112 /> 3113 <div 3114 onClick={async (e) => { 3115 e.stopPropagation(); 3116 setPlaying(true); 3117 }} 3118 style={{ 3119 position: "absolute", 3120 top: "50%", 3121 left: "50%", 3122 transform: "translate(-50%, -50%)", 3123 //fontSize: 48, 3124 color: "white", 3125 //textShadow: theme.cardShadow, 3126 pointerEvents: "none", 3127 userSelect: "none", 3128 }} 3129 className="text-shadow-md" 3130 > 3131 {/*▶️*/} 3132 <MdiPlayCircle /> 3133 </div> 3134 </> 3135 )} 3136 {playing && ( 3137 <div 3138 style={{ 3139 position: "relative", 3140 width: "100%", 3141 borderRadius: 12, 3142 overflow: "hidden", 3143 //border: `1px solid ${theme.border}`, 3144 paddingTop: `${ 3145 100 / (aspect ? aspect.width / aspect.height : 16 / 9) 3146 }%`, // 16:9 = 56.25%, 4:3 = 75% 3147 }} 3148 className="border border-gray-200 dark:border-gray-800 was7" 3149 > 3150 <ReactPlayer 3151 src={url} 3152 playing={true} 3153 controls={true} 3154 width="100%" 3155 height="100%" 3156 style={{ position: "absolute", top: 0, left: 0 }} 3157 /> 3158 {/* <ReactPlayer 3159 url={url} 3160 playing={true} 3161 controls={true} 3162 width="100%" 3163 style={{width: "100% !important", aspectRatio: aspect ? aspect?.width/aspect?.height : 16/9}} 3164 onPause={() => setPlaying(false)} 3165 onEnded={() => setPlaying(false)} 3166 /> */} 3167 </div> 3168 )} 3169 </div> 3170 ); 3171};