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