an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm
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 <FollowButton targetdidorhandle={post.author.did} /> 1561 </div> 1562 </div> 1563 <div className="flex flex-col gap-3"> 1564 <div> 1565 <div className="text-gray-900 dark:text-gray-100 font-medium text-md"> 1566 {post.author.displayName || post.author.handle}{" "} 1567 </div> 1568 <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1"> 1569 <Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "} 1570 </div> 1571 </div> 1572 {uprrrsauthor?.description && ( 1573 <div className="text-gray-700 dark:text-gray-300 text-sm text-left break-words line-clamp-3"> 1574 {uprrrsauthor.description} 1575 </div> 1576 )} 1577 {/* <div className="flex gap-4"> 1578 <div className="flex gap-1"> 1579 <div className="font-medium text-gray-900 dark:text-gray-100"> 1580 0 1581 </div> 1582 <div className="text-gray-500 dark:text-gray-400"> 1583 Following 1584 </div> 1585 </div> 1586 <div className="flex gap-1"> 1587 <div className="font-medium text-gray-900 dark:text-gray-100"> 1588 2,900 1589 </div> 1590 <div className="text-gray-500 dark:text-gray-400"> 1591 Followers 1592 </div> 1593 </div> 1594 </div> */} 1595 </div> 1596 </div> 1597 1598 {/* <HoverCard.Arrow className="fill-gray-50 dark:fill-gray-900" /> */} 1599 </HoverCard.Content> 1600 </HoverCard.Portal> 1601 </HoverCard.Root> 1602 1603 <div style={{ display: "flex", alignItems: "flex-start", zIndex: 2 }}> 1604 <div 1605 style={{ 1606 display: "flex", 1607 flexDirection: "column", 1608 alignSelf: "stretch", 1609 alignItems: "center", 1610 overflow: "hidden", 1611 width: expanded || isQuote ? 0 : "auto", 1612 marginRight: expanded || isQuote ? 0 : 12, 1613 }} 1614 > 1615 {/* dummy for later use */} 1616 <div style={{ width: 42, height: 42 + 6, minHeight: 42 + 6 }} /> 1617 {/* reply line !!!! bottomReplyLine */} 1618 {bottomReplyLine && ( 1619 <div 1620 style={{ 1621 width: 2, 1622 height: "100%", 1623 //background: theme.textSecondary, 1624 opacity: 0.5, 1625 // no flex here 1626 //color: "Red", 1627 //zIndex: 99 1628 }} 1629 className="bg-gray-500 dark:bg-gray-400" 1630 /> 1631 )} 1632 {/* <div 1633 layout 1634 transition={{ duration: 0.2 }} 1635 animate={{ height: expanded ? 0 : '100%' }} 1636 style={{ 1637 width: 2.4, 1638 background: theme.border, 1639 // no flex here 1640 }} 1641 /> */} 1642 </div> 1643 <div style={{ flex: 1, maxWidth: "100%" }}> 1644 <div 1645 style={{ 1646 display: "flex", 1647 flexDirection: "row", 1648 alignItems: "center", 1649 flexWrap: "nowrap", 1650 maxWidth: `calc(100% - ${!expanded ? (isQuote ? 26 : 0) : 54}px)`, 1651 width: `calc(100% - ${!expanded ? (isQuote ? 26 : 0) : 54}px)`, 1652 marginLeft: !expanded ? (isQuote ? 26 : 0) : 54, 1653 marginBottom: !expanded ? 4 : 6, 1654 }} 1655 > 1656 <div 1657 style={{ 1658 display: "flex", 1659 //overflow: "hidden", // hey why is overflow hidden unapplied 1660 overflow: "hidden", 1661 textOverflow: "ellipsis", 1662 flexShrink: 1, 1663 flexGrow: 1, 1664 flexBasis: 0, 1665 width: 0, 1666 gap: expanded ? 0 : 6, 1667 alignItems: expanded ? "flex-start" : "center", 1668 flexDirection: expanded ? "column" : "row", 1669 height: expanded ? 42 : "1rem", 1670 }} 1671 > 1672 <span 1673 style={{ 1674 display: "flex", 1675 fontWeight: 700, 1676 fontSize: 16, 1677 overflow: "hidden", 1678 textOverflow: "ellipsis", 1679 whiteSpace: "nowrap", 1680 flexShrink: 1, 1681 minWidth: 0, 1682 gap: 4, 1683 alignItems: "center", 1684 //color: theme.text, 1685 }} 1686 className="text-gray-900 dark:text-gray-100" 1687 > 1688 {/* verified checkmark */} 1689 {post.author.displayName || post.author.handle}{" "} 1690 {post.author.verification?.verifiedStatus == "valid" && ( 1691 <MdiVerified /> 1692 )} 1693 </span> 1694 1695 <span 1696 style={{ 1697 //color: theme.textSecondary, 1698 fontSize: 16, 1699 overflowX: "hidden", 1700 textOverflow: "ellipsis", 1701 whiteSpace: "nowrap", 1702 flexShrink: 1, 1703 flexGrow: 0, 1704 minWidth: 0, 1705 }} 1706 className="text-gray-500 dark:text-gray-400" 1707 > 1708 @{post.author.handle} 1709 </span> 1710 </div> 1711 <div 1712 style={{ 1713 display: "flex", 1714 alignItems: "center", 1715 height: "1rem", 1716 }} 1717 > 1718 <span 1719 style={{ 1720 //color: theme.textSecondary, 1721 fontSize: 16, 1722 marginLeft: 8, 1723 whiteSpace: "nowrap", 1724 flexShrink: 0, 1725 maxWidth: "100%", 1726 }} 1727 className="text-gray-500 dark:text-gray-400" 1728 > 1729 · {/* time placeholder */} 1730 {shortTimeAgo(post.indexedAt)} 1731 </span> 1732 </div> 1733 </div> 1734 {/* reply indicator */} 1735 {!!feedviewpostreplyhandle && ( 1736 <div 1737 style={{ 1738 display: "flex", 1739 borderRadius: 12, 1740 paddingBottom: 2, 1741 fontSize: 14, 1742 justifyContent: "flex-start", 1743 //color: theme.textSecondary, 1744 gap: 4, 1745 alignItems: "center", 1746 //marginLeft: 36, 1747 height: 1748 !(expanded || isQuote) && !!feedviewpostreplyhandle 1749 ? "1rem" 1750 : 0, 1751 opacity: 1752 !(expanded || isQuote) && !!feedviewpostreplyhandle ? 1 : 0, 1753 }} 1754 className="text-gray-500 dark:text-gray-400" 1755 > 1756 <MdiReply /> Reply to @{feedviewpostreplyhandle} 1757 </div> 1758 )} 1759 <div 1760 style={{ 1761 fontSize: 16, 1762 marginBottom: !post.embed /*|| depth > 0*/ ? 0 : 8, 1763 whiteSpace: "pre-wrap", 1764 textAlign: "left", 1765 overflowWrap: "anywhere", 1766 wordBreak: "break-word", 1767 //color: theme.text, 1768 }} 1769 className="text-gray-900 dark:text-gray-100" 1770 > 1771 {fedi ? ( 1772 <> 1773 <span 1774 className="dangerousFediContent" 1775 dangerouslySetInnerHTML={{ 1776 __html: DOMPurify.sanitize(fedi), 1777 }} 1778 /> 1779 </> 1780 ) : ( 1781 <> 1782 {renderTextWithFacets({ 1783 text: (post.record as { text?: string }).text ?? "", 1784 facets: (post.record.facets as Facet[]) ?? [], 1785 navigate: navigate, 1786 })} 1787 </> 1788 )} 1789 </div> 1790 {post.embed && depth < 1 ? ( 1791 <PostEmbeds 1792 embed={post.embed} 1793 //moderation={moderation} 1794 viewContext={PostEmbedViewContext.Feed} 1795 salt={salt} 1796 navigate={navigate} 1797 postid={{ did: post.author.did, rkey: parsed.rkey }} 1798 nopics={nopics} 1799 lightboxCallback={lightboxCallback} 1800 /> 1801 ) : null} 1802 {post.embed && depth > 0 && ( 1803 /* pretty bad hack imo. its trying to sync up with how the embed shim doesnt 1804 hydrate embeds this deep but the connection here is implicit 1805 todo: idk make this a real part of the embed shim so its not implicit */ 1806 <> 1807 <div className="border-gray-300 dark:border-gray-800 p-3 rounded-xl border italic text-gray-400 text-[14px]"> 1808 (there is an embed here thats too deep to render) 1809 </div> 1810 </> 1811 )} 1812 <div style={{ paddingTop: post.embed && depth < 1 ? 4 : 0 }}> 1813 <> 1814 {expanded && ( 1815 <div 1816 style={{ 1817 overflow: "hidden", 1818 //color: theme.textSecondary, 1819 fontSize: 14, 1820 display: "flex", 1821 borderBottomStyle: "solid", 1822 //borderBottomColor: theme.border, 1823 //background: "#f00", 1824 // height: "1rem", 1825 paddingTop: 4, 1826 paddingBottom: 8, 1827 borderBottomWidth: 1, 1828 marginBottom: 8, 1829 }} // important for height animation 1830 className="text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-800 was7" 1831 > 1832 {fullDateTimeFormat(post.indexedAt)} 1833 </div> 1834 )} 1835 </> 1836 {!isQuote && ( 1837 <div 1838 style={{ 1839 display: "flex", 1840 gap: 32, 1841 paddingTop: 8, 1842 //color: theme.textSecondary, 1843 fontSize: 15, 1844 justifyContent: "space-between", 1845 //background: "#0f0", 1846 }} 1847 className="text-gray-500 dark:text-gray-400" 1848 > 1849 <HitSlopButton 1850 onClick={() => { 1851 setComposerPost({ kind: "reply", parent: post.uri }); 1852 }} 1853 style={{ 1854 ...btnstyle, 1855 }} 1856 > 1857 <MdiCommentOutline /> 1858 {post.replyCount} 1859 </HitSlopButton> 1860 <DropdownMenu.Root modal={false}> 1861 <DropdownMenu.Trigger asChild> 1862 <div 1863 style={{ 1864 ...btnstyle, 1865 ...(hasRetweeted ? { color: "#5CEFAA" } : {}), 1866 }} 1867 aria-label="Repost or quote post" 1868 > 1869 {hasRetweeted ? <MdiRepeatGreen /> : <MdiRepeat />} 1870 {post.repostCount ?? 0} 1871 </div> 1872 </DropdownMenu.Trigger> 1873 1874 <DropdownMenu.Portal> 1875 <DropdownMenu.Content 1876 align="start" 1877 sideOffset={5} 1878 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" 1879 > 1880 <DropdownMenu.Item 1881 onSelect={repostOrUnrepostPost} 1882 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" 1883 > 1884 <MdiRepeat 1885 className={hasRetweeted ? "text-green-400" : ""} 1886 /> 1887 <span>{hasRetweeted ? "Undo Repost" : "Repost"}</span> 1888 </DropdownMenu.Item> 1889 1890 <DropdownMenu.Item 1891 onSelect={() => { 1892 setComposerPost({ 1893 kind: "quote", 1894 subject: post.uri, 1895 }); 1896 }} 1897 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" 1898 > 1899 {/* You might want a specific quote icon here */} 1900 <MdiCommentOutline /> 1901 <span>Quote</span> 1902 </DropdownMenu.Item> 1903 </DropdownMenu.Content> 1904 </DropdownMenu.Portal> 1905 </DropdownMenu.Root> 1906 <HitSlopButton 1907 onClick={() => { 1908 likeOrUnlikePost(); 1909 }} 1910 style={{ 1911 ...btnstyle, 1912 ...(hasLiked ? { color: "#EC4899" } : {}), 1913 }} 1914 > 1915 {hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />} 1916 {(post.likeCount || 0) + (hasLiked ? 1 : 0)} 1917 </HitSlopButton> 1918 <div style={{ display: "flex", gap: 8 }}> 1919 <HitSlopButton 1920 onClick={async (e) => { 1921 e.stopPropagation(); 1922 try { 1923 await navigator.clipboard.writeText( 1924 "https://bsky.app" + 1925 "/profile/" + 1926 post.author.handle + 1927 "/post/" + 1928 post.uri.split("/").pop() 1929 ); 1930 } catch (_e) { 1931 // idk 1932 } 1933 }} 1934 style={{ 1935 ...btnstyle, 1936 }} 1937 > 1938 <MdiShareVariant /> 1939 </HitSlopButton> 1940 <span style={btnstyle}> 1941 <MdiMoreHoriz /> 1942 </span> 1943 </div> 1944 </div> 1945 )} 1946 </div> 1947 <div 1948 style={{ 1949 //height: bottomReplyLine ? 16 : 0 1950 height: isQuote ? 12 : 16, 1951 }} 1952 /> 1953 </div> 1954 </div> 1955 </div> 1956 </div> 1957 ); 1958} 1959 1960const fullDateTimeFormat = (iso: string) => { 1961 const date = new Date(iso); 1962 return date.toLocaleString("en-US", { 1963 month: "long", 1964 day: "numeric", 1965 year: "numeric", 1966 hour: "numeric", 1967 minute: "2-digit", 1968 hour12: true, 1969 }); 1970}; 1971const shortTimeAgo = (iso: string) => { 1972 const diff = Date.now() - new Date(iso).getTime(); 1973 const mins = Math.floor(diff / 60000); 1974 if (mins < 1) return "now"; 1975 if (mins < 60) return `${mins}m`; 1976 const hrs = Math.floor(mins / 60); 1977 if (hrs < 24) return `${hrs}h`; 1978 const days = Math.floor(hrs / 24); 1979 return `${days}d`; 1980}; 1981 1982// const toAtUri = (url: string) => 1983// url 1984// .replace("https://bsky.app/profile/", "at://") 1985// .replace("/feed/", "/app.bsky.feed.generator/"); 1986 1987// function PostSizedElipsis() { 1988// return ( 1989// <div 1990// style={{ display: "flex", flexDirection: "row", alignItems: "center" }} 1991// > 1992// <div 1993// style={{ 1994// width: 2, 1995// height: 40, 1996// //background: theme.textSecondary, 1997// background: `repeating-linear-gradient(to bottom, var(--color-gray-400) 0px, var(--color-gray-400) 6px, transparent 6px, transparent 10px)`, 1998// backgroundSize: "100% 10px", 1999// opacity: 0.5, 2000// marginLeft: 36, // why 36 ??? 2001// }} 2002// /> 2003// <span 2004// style={{ 2005// //color: theme.textSecondary, 2006// marginLeft: 34, 2007// }} 2008// className="text-gray-500 dark:text-gray-400" 2009// > 2010// more posts 2011// </span> 2012// </div> 2013// ); 2014// } 2015 2016type Embed = 2017 | AppBskyEmbedRecord.View 2018 | AppBskyEmbedImages.View 2019 | AppBskyEmbedVideo.View 2020 | AppBskyEmbedExternal.View 2021 | AppBskyEmbedRecordWithMedia.View 2022 | { $type: string; [k: string]: unknown }; 2023 2024enum PostEmbedViewContext { 2025 ThreadHighlighted = "ThreadHighlighted", 2026 Feed = "Feed", 2027 FeedEmbedRecordWithMedia = "FeedEmbedRecordWithMedia", 2028} 2029const stopgap = { 2030 display: "flex", 2031 justifyContent: "center", 2032 padding: "32px 12px", 2033 borderRadius: 12, 2034 border: "1px solid rgba(161, 170, 174, 0.38)", 2035}; 2036 2037function PostEmbeds({ 2038 embed, 2039 moderation, 2040 onOpen, 2041 allowNestedQuotes, 2042 viewContext, 2043 salt, 2044 navigate, 2045 postid, 2046 nopics, 2047 lightboxCallback, 2048}: { 2049 embed?: Embed; 2050 moderation?: ModerationDecision; 2051 onOpen?: () => void; 2052 allowNestedQuotes?: boolean; 2053 viewContext?: PostEmbedViewContext; 2054 salt: string; 2055 navigate: (_: any) => void; 2056 postid?: { did: string; rkey: string }; 2057 nopics?: boolean; 2058 lightboxCallback?: (d: LightboxProps) => void; 2059}) { 2060 //const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); 2061 function setLightboxIndex(number: number) { 2062 navigate({ 2063 to: "/profile/$did/post/$rkey/image/$i", 2064 params: { 2065 did: postid?.did, 2066 rkey: postid?.rkey, 2067 i: number.toString(), 2068 }, 2069 }); 2070 } 2071 if ( 2072 AppBskyEmbedRecordWithMedia.isView(embed) && 2073 AppBskyEmbedRecord.isViewRecord(embed.record.record) && 2074 AppBskyFeedPost.isRecord(embed.record.record.value) //&& 2075 //AppBskyFeedPost.validateRecord(embed.record.record.value).success 2076 ) { 2077 const post: PostView = { 2078 $type: "app.bsky.feed.defs#postView", // lmao lies 2079 uri: embed.record.record.uri, 2080 cid: embed.record.record.cid, 2081 author: embed.record.record.author, 2082 record: embed.record.record.value as { [key: string]: unknown }, 2083 embed: embed.record.record.embeds 2084 ? embed.record.record.embeds?.[0] 2085 : undefined, // quotes handles embeds differently, its an array for some reason 2086 replyCount: embed.record.record.replyCount, 2087 repostCount: embed.record.record.repostCount, 2088 likeCount: embed.record.record.likeCount, 2089 quoteCount: embed.record.record.quoteCount, 2090 indexedAt: embed.record.record.indexedAt, 2091 // we dont have a viewer, so this is a best effort conversion, still requires full query later on 2092 labels: embed.record.record.labels, 2093 // neither do we have threadgate. remember to please fetch the full post later 2094 }; 2095 return ( 2096 <div> 2097 <PostEmbeds 2098 embed={embed.media} 2099 moderation={moderation} 2100 onOpen={onOpen} 2101 viewContext={viewContext} 2102 salt={salt} 2103 navigate={navigate} 2104 postid={postid} 2105 nopics={nopics} 2106 lightboxCallback={lightboxCallback} 2107 /> 2108 {/* padding empty div of 8px height */} 2109 <div style={{ height: 12 }} /> 2110 {/* stopgap sorry*/} 2111 <div 2112 style={{ 2113 display: "flex", 2114 flexDirection: "column", 2115 borderRadius: 12, 2116 //border: `1px solid ${theme.border}`, 2117 //boxShadow: theme.cardShadow, 2118 overflow: "hidden", 2119 }} 2120 className="shadow border border-gray-200 dark:border-gray-800 was7" 2121 > 2122 <UniversalPostRenderer 2123 post={post} 2124 isQuote 2125 salt={salt} 2126 onPostClick={(e) => { 2127 e.stopPropagation(); 2128 const parsed = new AtUri(post.uri); //parseAtUri(post.uri); 2129 if (parsed) { 2130 navigate({ 2131 to: "/profile/$did/post/$rkey", 2132 params: { did: parsed.host, rkey: parsed.rkey }, 2133 }); 2134 } 2135 }} 2136 depth={1} 2137 /> 2138 </div> 2139 {/* <QuotePostRenderer 2140 record={embed.record.record} 2141 moderation={moderation} 2142 /> */} 2143 {/* stopgap sorry */} 2144 {/* <div style={stopgap}>quote post placeholder</div> */} 2145 {/* {<MaybeQuoteEmbed 2146 embed={embed.record} 2147 onOpen={onOpen} 2148 viewContext={ 2149 viewContext === PostEmbedViewContext.Feed 2150 ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia 2151 : undefined 2152 } 2153 {/* <div style={stopgap}>quote post placeholder</div> */} 2154 {/* {<MaybeQuoteEmbed 2155 embed={embed.record} 2156 onOpen={onOpen} 2157 viewContext={ 2158 viewContext === PostEmbedViewContext.Feed 2159 ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia 2160 : undefined 2161 } 2162 />} */} 2163 </div> 2164 ); 2165 } 2166 2167 if (AppBskyEmbedRecord.isView(embed)) { 2168 // custom feed embed (i.e. generator view) 2169 if (AppBskyFeedDefs.isGeneratorView(embed.record)) { 2170 // stopgap sorry 2171 return <div style={stopgap}>feedgen placeholder</div>; 2172 // return ( 2173 // <div style={{ marginTop: '1rem' }}> 2174 // <MaybeFeedCard view={embed.record} /> 2175 // </div> 2176 // ) 2177 } 2178 2179 // list embed 2180 if (AppBskyGraphDefs.isListView(embed.record)) { 2181 // stopgap sorry 2182 return <div style={stopgap}>list placeholder</div>; 2183 // return ( 2184 // <div style={{ marginTop: '1rem' }}> 2185 // <MaybeListCard view={embed.record} /> 2186 // </div> 2187 // ) 2188 } 2189 2190 // starter pack embed 2191 if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) { 2192 // stopgap sorry 2193 return <div style={stopgap}>starter pack card placeholder</div>; 2194 // return ( 2195 // <div style={{ marginTop: '1rem' }}> 2196 // <StarterPackCard starterPack={embed.record} /> 2197 // </div> 2198 // ) 2199 } 2200 2201 // quote post 2202 // = 2203 // stopgap sorry 2204 2205 if ( 2206 AppBskyEmbedRecord.isViewRecord(embed.record) && 2207 AppBskyFeedPost.isRecord(embed.record.value) // && 2208 //AppBskyFeedPost.validateRecord(embed.record.value).success 2209 ) { 2210 const post: PostView = { 2211 $type: "app.bsky.feed.defs#postView", // lmao lies 2212 uri: embed.record.uri, 2213 cid: embed.record.cid, 2214 author: embed.record.author, 2215 record: embed.record.value as { [key: string]: unknown }, 2216 embed: embed.record.embeds ? embed.record.embeds?.[0] : undefined, // quotes handles embeds differently, its an array for some reason 2217 replyCount: embed.record.replyCount, 2218 repostCount: embed.record.repostCount, 2219 likeCount: embed.record.likeCount, 2220 quoteCount: embed.record.quoteCount, 2221 indexedAt: embed.record.indexedAt, 2222 // we dont have a viewer, so this is a best effort conversion, still requires full query later on 2223 labels: embed.record.labels, 2224 // neither do we have threadgate. remember to please fetch the full post later 2225 }; 2226 2227 return ( 2228 <div 2229 style={{ 2230 display: "flex", 2231 flexDirection: "column", 2232 borderRadius: 12, 2233 //border: `1px solid ${theme.border}`, 2234 //boxShadow: theme.cardShadow, 2235 overflow: "hidden", 2236 }} 2237 className="shadow border border-gray-200 dark:border-gray-800 was7" 2238 > 2239 <UniversalPostRenderer 2240 post={post} 2241 isQuote 2242 salt={salt} 2243 onPostClick={(e) => { 2244 e.stopPropagation(); 2245 const parsed = new AtUri(post.uri); //parseAtUri(post.uri); 2246 if (parsed) { 2247 navigate({ 2248 to: "/profile/$did/post/$rkey", 2249 params: { did: parsed.host, rkey: parsed.rkey }, 2250 }); 2251 } 2252 }} 2253 depth={1} 2254 /> 2255 </div> 2256 ); 2257 } else { 2258 return <>sorry</>; 2259 } 2260 //return <QuotePostRenderer record={embed.record} moderation={moderation} />; 2261 2262 //return <div style={stopgap}>quote post placeholder</div>; 2263 // return ( 2264 // <MaybeQuoteEmbed 2265 // embed={embed} 2266 // onOpen={onOpen} 2267 // allowNestedQuotes={allowNestedQuotes} 2268 // /> 2269 // ) 2270 } 2271 2272 // image embed 2273 // = 2274 if (AppBskyEmbedImages.isView(embed)) { 2275 const { images } = embed; 2276 2277 const lightboxImages = images.map((img) => ({ 2278 src: img.fullsize, 2279 alt: img.alt, 2280 })); 2281 console.log("rendering images"); 2282 if (lightboxCallback) { 2283 lightboxCallback({ images: lightboxImages }); 2284 console.log("rendering images"); 2285 } 2286 2287 if (nopics) return; 2288 2289 if (images.length > 0) { 2290 // const items = embed.images.map(img => ({ 2291 // uri: img.fullsize, 2292 // thumbUri: img.thumb, 2293 // alt: img.alt, 2294 // dimensions: img.aspectRatio ?? null, 2295 // })) 2296 2297 if (images.length === 1) { 2298 const image = images[0]; 2299 return ( 2300 <div style={{ marginTop: 0 }}> 2301 <div 2302 style={{ 2303 position: "relative", 2304 width: "100%", 2305 aspectRatio: image.aspectRatio 2306 ? (() => { 2307 const { width, height } = image.aspectRatio; 2308 const ratio = width / height; 2309 return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`; 2310 })() 2311 : "1 / 1", // fallback to square 2312 //backgroundColor: theme.background, // fallback letterboxing color 2313 borderRadius: 12, 2314 //border: `1px solid ${theme.border}`, 2315 overflow: "hidden", 2316 }} 2317 className="border border-gray-200 dark:border-gray-800 was7 bg-gray-200 dark:bg-gray-900" 2318 > 2319 {/* {lightboxIndex !== null && ( 2320 <Lightbox 2321 images={lightboxImages} 2322 index={lightboxIndex} 2323 onClose={() => setLightboxIndex(null)} 2324 onNavigate={(newIndex) => setLightboxIndex(newIndex)} 2325 post={postid} 2326 /> 2327 )} */} 2328 <img 2329 src={image.fullsize} 2330 alt={image.alt} 2331 style={{ 2332 width: "100%", 2333 height: "100%", 2334 objectFit: "contain", // letterbox or scale to fit 2335 }} 2336 onClick={(e) => { 2337 e.stopPropagation(); 2338 setLightboxIndex(0); 2339 }} 2340 /> 2341 </div> 2342 </div> 2343 ); 2344 } 2345 // 2 images: side by side, both 1:1, cropped 2346 if (images.length === 2) { 2347 return ( 2348 <div 2349 style={{ 2350 display: "flex", 2351 gap: 4, 2352 marginTop: 0, 2353 width: "100%", 2354 borderRadius: 12, 2355 overflow: "hidden", 2356 //border: `1px solid ${theme.border}`, 2357 }} 2358 className="border border-gray-200 dark:border-gray-800 was7" 2359 > 2360 {/* {lightboxIndex !== null && ( 2361 <Lightbox 2362 images={lightboxImages} 2363 index={lightboxIndex} 2364 onClose={() => setLightboxIndex(null)} 2365 onNavigate={(newIndex) => setLightboxIndex(newIndex)} 2366 post={postid} 2367 /> 2368 )} */} 2369 {images.map((img, i) => ( 2370 <div 2371 key={i} 2372 style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }} 2373 > 2374 <img 2375 src={img.fullsize} 2376 alt={img.alt} 2377 style={{ 2378 width: "100%", 2379 height: "100%", 2380 objectFit: "cover", 2381 borderRadius: i === 0 ? "12px 0 0 12px" : "0 12px 12px 0", 2382 }} 2383 onClick={(e) => { 2384 e.stopPropagation(); 2385 setLightboxIndex(i); 2386 }} 2387 /> 2388 </div> 2389 ))} 2390 </div> 2391 ); 2392 } 2393 2394 // 3 images: left is 1:1, right is two stacked 2:1 2395 if (images.length === 3) { 2396 return ( 2397 <div 2398 style={{ 2399 display: "flex", 2400 gap: 4, 2401 marginTop: 0, 2402 width: "100%", 2403 borderRadius: 12, 2404 overflow: "hidden", 2405 //border: `1px solid ${theme.border}`, 2406 // height: 240, // fixed height for cropping 2407 }} 2408 className="border border-gray-200 dark:border-gray-800 was7" 2409 > 2410 {/* {lightboxIndex !== null && ( 2411 <Lightbox 2412 images={lightboxImages} 2413 index={lightboxIndex} 2414 onClose={() => setLightboxIndex(null)} 2415 onNavigate={(newIndex) => setLightboxIndex(newIndex)} 2416 post={postid} 2417 /> 2418 )} */} 2419 {/* Left: 1:1 */} 2420 <div 2421 style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }} 2422 > 2423 <img 2424 src={images[0].fullsize} 2425 alt={images[0].alt} 2426 style={{ 2427 width: "100%", 2428 height: "100%", 2429 objectFit: "cover", 2430 borderRadius: "12px 0 0 12px", 2431 }} 2432 onClick={(e) => { 2433 e.stopPropagation(); 2434 setLightboxIndex(0); 2435 }} 2436 /> 2437 </div> 2438 {/* Right: two stacked 2:1 */} 2439 <div 2440 style={{ 2441 flex: 1, 2442 display: "flex", 2443 flexDirection: "column", 2444 gap: 4, 2445 }} 2446 > 2447 {[1, 2].map((i) => ( 2448 <div 2449 key={i} 2450 style={{ 2451 flex: 1, 2452 aspectRatio: "2 / 1", 2453 position: "relative", 2454 }} 2455 > 2456 <img 2457 src={images[i].fullsize} 2458 alt={images[i].alt} 2459 style={{ 2460 width: "100%", 2461 height: "100%", 2462 objectFit: "cover", 2463 borderRadius: i === 1 ? "0 12px 0 0" : "0 0 12px 0", 2464 }} 2465 onClick={(e) => { 2466 e.stopPropagation(); 2467 setLightboxIndex(i + 1); 2468 }} 2469 /> 2470 </div> 2471 ))} 2472 </div> 2473 </div> 2474 ); 2475 } 2476 2477 // 4 images: 2x2 grid, all 3:2 2478 if (images.length === 4) { 2479 return ( 2480 <div 2481 style={{ 2482 display: "grid", 2483 gridTemplateColumns: "1fr 1fr", 2484 gridTemplateRows: "1fr 1fr", 2485 gap: 4, 2486 marginTop: 0, 2487 width: "100%", 2488 borderRadius: 12, 2489 overflow: "hidden", 2490 //border: `1px solid ${theme.border}`, 2491 //aspectRatio: "3 / 2", // overall grid aspect 2492 }} 2493 className="border border-gray-200 dark:border-gray-800 was7" 2494 > 2495 {/* {lightboxIndex !== null && ( 2496 <Lightbox 2497 images={lightboxImages} 2498 index={lightboxIndex} 2499 onClose={() => setLightboxIndex(null)} 2500 onNavigate={(newIndex) => setLightboxIndex(newIndex)} 2501 post={postid} 2502 /> 2503 )} */} 2504 {images.map((img, i) => ( 2505 <div 2506 key={i} 2507 style={{ 2508 width: "100%", 2509 height: "100%", 2510 aspectRatio: "3 / 2", 2511 position: "relative", 2512 }} 2513 > 2514 <img 2515 src={img.fullsize} 2516 alt={img.alt} 2517 style={{ 2518 width: "100%", 2519 height: "100%", 2520 objectFit: "cover", 2521 borderRadius: 2522 i === 0 2523 ? "12px 0 0 0" 2524 : i === 1 2525 ? "0 12px 0 0" 2526 : i === 2 2527 ? "0 0 0 12px" 2528 : "0 0 12px 0", 2529 }} 2530 onClick={(e) => { 2531 e.stopPropagation(); 2532 setLightboxIndex(i); 2533 }} 2534 /> 2535 </div> 2536 ))} 2537 </div> 2538 ); 2539 } 2540 2541 // stopgap sorry 2542 return <div style={stopgap}>image count more than one placeholder</div>; 2543 // return ( 2544 // <div style={{ marginTop: '1rem' }}> 2545 // <ImageLayoutGrid 2546 // images={images} 2547 // viewContext={viewContext} 2548 // /> 2549 // </div> 2550 // ) 2551 } 2552 } 2553 2554 // external link embed 2555 // = 2556 if (AppBskyEmbedExternal.isView(embed)) { 2557 const link = embed.external; 2558 return ( 2559 <ExternalLinkEmbed link={link} onOpen={onOpen} style={{ marginTop: 0 }} /> 2560 ); 2561 } 2562 2563 // video embed 2564 // = 2565 if (AppBskyEmbedVideo.isView(embed)) { 2566 // hls playlist 2567 const playlist = embed.playlist; 2568 return ( 2569 <SmartHLSPlayer 2570 url={playlist} 2571 thumbnail={embed.thumbnail} 2572 aspect={embed.aspectRatio} 2573 /> 2574 ); 2575 // stopgap sorry 2576 //return (<div>video</div>) 2577 // return ( 2578 // <VideoEmbed 2579 // embed={embed} 2580 // crop={ 2581 // viewContext === PostEmbedViewContext.ThreadHighlighted 2582 // ? 'none' 2583 // : viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia 2584 // ? 'square' 2585 // : 'constrained' 2586 // } 2587 // /> 2588 // ) 2589 } 2590 2591 return <div />; 2592} 2593 2594function getDomain(url: string) { 2595 try { 2596 const { hostname } = new URL(url); 2597 return hostname; 2598 } catch (e) { 2599 // In case it's a bare domain like "example.com" 2600 if (!url.startsWith("http")) { 2601 try { 2602 const { hostname } = new URL("http://" + url); 2603 return hostname; 2604 } catch { 2605 return null; 2606 } 2607 } 2608 return null; 2609 } 2610} 2611function getByteToCharMap(text: string): number[] { 2612 const encoder = new TextEncoder(); 2613 //const utf8 = encoder.encode(text); 2614 2615 const map: number[] = []; 2616 let byteIndex = 0; 2617 let charIndex = 0; 2618 2619 for (const char of text) { 2620 const bytes = encoder.encode(char); 2621 for (let i = 0; i < bytes.length; i++) { 2622 map[byteIndex++] = charIndex; 2623 } 2624 charIndex += char.length; 2625 } 2626 2627 return map; 2628} 2629 2630function facetByteRangeToCharRange( 2631 byteStart: number, 2632 byteEnd: number, 2633 byteToCharMap: number[] 2634): [number, number] { 2635 return [ 2636 byteToCharMap[byteStart] ?? 0, 2637 byteToCharMap[byteEnd - 1]! + 1, // inclusive end -> exclusive char end 2638 ]; 2639} 2640 2641interface FacetRange { 2642 start: number; 2643 end: number; 2644 feature: Facet["features"][number]; 2645} 2646 2647function extractFacetRanges(text: string, facets: Facet[]): FacetRange[] { 2648 const map = getByteToCharMap(text); 2649 return facets.map((f) => { 2650 const [start, end] = facetByteRangeToCharRange( 2651 f.index.byteStart, 2652 f.index.byteEnd, 2653 map 2654 ); 2655 return { start, end, feature: f.features[0] }; 2656 }); 2657} 2658export function renderTextWithFacets({ 2659 text, 2660 facets, 2661 navigate, 2662}: { 2663 text: string; 2664 facets: Facet[]; 2665 navigate: (_: any) => void; 2666}) { 2667 const ranges = extractFacetRanges(text, facets).sort( 2668 (a: any, b: any) => a.start - b.start 2669 ); 2670 2671 const result: React.ReactNode[] = []; 2672 let current = 0; 2673 2674 for (const { start, end, feature } of ranges) { 2675 if (current < start) { 2676 result.push(<span key={current}>{text.slice(current, start)}</span>); 2677 } 2678 2679 const fragment = text.slice(start, end); 2680 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 2681 if (feature.$type === "app.bsky.richtext.facet#link" && feature.uri) { 2682 result.push( 2683 <a 2684 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 2685 href={feature.uri} 2686 key={start} 2687 className="link" 2688 style={{ 2689 textDecoration: "none", 2690 color: "rgb(29, 122, 242)", 2691 wordBreak: "break-all", 2692 }} 2693 target="_blank" 2694 rel="noreferrer" 2695 onClick={(e) => { 2696 e.stopPropagation(); 2697 }} 2698 > 2699 {fragment} 2700 </a> 2701 ); 2702 } else if ( 2703 feature.$type === "app.bsky.richtext.facet#mention" && 2704 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 2705 feature.did 2706 ) { 2707 result.push( 2708 <span 2709 key={start} 2710 style={{ color: "rgb(29, 122, 242)" }} 2711 className=" cursor-pointer" 2712 onClick={(e) => { 2713 e.stopPropagation(); 2714 navigate({ 2715 to: "/profile/$did", 2716 // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 2717 params: { did: feature.did }, 2718 }); 2719 }} 2720 > 2721 {fragment} 2722 </span> 2723 ); 2724 } else if (feature.$type === "app.bsky.richtext.facet#tag") { 2725 result.push( 2726 <span 2727 key={start} 2728 style={{ color: "rgb(29, 122, 242)" }} 2729 onClick={(e) => { 2730 e.stopPropagation(); 2731 }} 2732 > 2733 {fragment} 2734 </span> 2735 ); 2736 } else { 2737 result.push(<span key={start}>{fragment}</span>); 2738 } 2739 2740 current = end; 2741 } 2742 2743 if (current < text.length) { 2744 result.push(<span key={current}>{text.slice(current)}</span>); 2745 } 2746 2747 return result; 2748} 2749function ExternalLinkEmbed({ 2750 link, 2751 onOpen, 2752 style, 2753}: { 2754 link: AppBskyEmbedExternal.ViewExternal; 2755 onOpen?: () => void; 2756 style?: React.CSSProperties; 2757}) { 2758 //const { theme } = useTheme(); 2759 const { uri, title, description, thumb } = link; 2760 const thumbAspectRatio = 1.91; 2761 const titleStyle = { 2762 fontSize: 16, 2763 fontWeight: 700, 2764 marginBottom: 4, 2765 //color: theme.text, 2766 wordBreak: "break-word", 2767 textAlign: "left", 2768 maxHeight: "4em", // 2 lines * 1.5em line-height 2769 // stupid shit 2770 display: "-webkit-box", 2771 WebkitBoxOrient: "vertical", 2772 overflow: "hidden", 2773 WebkitLineClamp: 2, 2774 }; 2775 const descriptionStyle = { 2776 fontSize: 14, 2777 //color: theme.textSecondary, 2778 marginBottom: 8, 2779 wordBreak: "break-word", 2780 textAlign: "left", 2781 maxHeight: "5em", // 3 lines * 1.5em line-height 2782 // stupid shit 2783 display: "-webkit-box", 2784 WebkitBoxOrient: "vertical", 2785 overflow: "hidden", 2786 WebkitLineClamp: 3, 2787 }; 2788 const linkStyle = { 2789 textDecoration: "none", 2790 //color: theme.textSecondary, 2791 wordBreak: "break-all", 2792 textAlign: "left", 2793 }; 2794 const containerStyle = { 2795 display: "flex", 2796 flexDirection: "column", 2797 //backgroundColor: theme.background, 2798 //background: '#eee', 2799 borderRadius: 12, 2800 //border: `1px solid ${theme.border}`, 2801 //boxShadow: theme.cardShadow, 2802 maxWidth: "100%", 2803 overflow: "hidden", 2804 ...style, 2805 }; 2806 return ( 2807 <a 2808 href={uri} 2809 target="_blank" 2810 rel="noopener noreferrer" 2811 onClick={(e) => { 2812 e.stopPropagation(); 2813 if (onOpen) onOpen(); 2814 }} 2815 /* @ts-expect-error css arent typed or something idk fuck you */ 2816 style={linkStyle} 2817 className="text-gray-500 dark:text-gray-400" 2818 > 2819 <div 2820 style={containerStyle as React.CSSProperties} 2821 className="border border-gray-200 dark:border-gray-800 was7" 2822 > 2823 {thumb && ( 2824 <div 2825 style={{ 2826 position: "relative", 2827 width: "100%", 2828 aspectRatio: thumbAspectRatio, 2829 overflow: "hidden", 2830 borderTopLeftRadius: 12, 2831 borderTopRightRadius: 12, 2832 marginBottom: 8, 2833 //borderBottom: `1px solid ${theme.border}`, 2834 }} 2835 className="border-b border-gray-200 dark:border-gray-800 was7" 2836 > 2837 <img 2838 src={thumb} 2839 alt={description} 2840 style={{ 2841 position: "absolute", 2842 top: 0, 2843 left: 0, 2844 width: "100%", 2845 height: "100%", 2846 objectFit: "cover", 2847 }} 2848 /> 2849 </div> 2850 )} 2851 <div 2852 style={{ 2853 paddingBottom: 12, 2854 paddingLeft: 12, 2855 paddingRight: 12, 2856 paddingTop: thumb ? 0 : 12, 2857 }} 2858 > 2859 {/* @ts-expect-error css */} 2860 <div style={titleStyle} className="text-gray-900 dark:text-gray-100"> 2861 {title} 2862 </div> 2863 <div 2864 style={descriptionStyle as React.CSSProperties} 2865 className="text-gray-500 dark:text-gray-400" 2866 > 2867 {description} 2868 </div> 2869 {/* small 1px divider here */} 2870 <div 2871 style={{ 2872 height: 1, 2873 //backgroundColor: theme.border, 2874 marginBottom: 8, 2875 }} 2876 className="bg-gray-200 dark:bg-gray-700" 2877 /> 2878 <div 2879 style={{ 2880 display: "flex", 2881 alignItems: "center", 2882 gap: 4, 2883 }} 2884 > 2885 <MdiGlobe /> 2886 <span 2887 style={{ 2888 fontSize: 12, 2889 //color: theme.textSecondary 2890 }} 2891 className="text-gray-500 dark:text-gray-400" 2892 > 2893 {getDomain(uri)} 2894 </span> 2895 </div> 2896 </div> 2897 </div> 2898 </a> 2899 ); 2900} 2901 2902const SmartHLSPlayer = ({ 2903 url, 2904 thumbnail, 2905 aspect, 2906}: { 2907 url: string; 2908 thumbnail?: string; 2909 aspect?: AppBskyEmbedDefs.AspectRatio; 2910}) => { 2911 const [playing, setPlaying] = useState(false); 2912 const containerRef = useRef(null); 2913 2914 // pause the player if it goes out of viewport 2915 useEffect(() => { 2916 const observer = new IntersectionObserver( 2917 ([entry]) => { 2918 if (!entry.isIntersecting && playing) { 2919 setPlaying(false); 2920 } 2921 }, 2922 { 2923 root: null, 2924 threshold: 0.25, 2925 } 2926 ); 2927 2928 if (containerRef.current) { 2929 observer.observe(containerRef.current); 2930 } 2931 2932 return () => { 2933 if (containerRef.current) { 2934 observer.unobserve(containerRef.current); 2935 } 2936 }; 2937 }, [playing]); 2938 2939 return ( 2940 <div 2941 ref={containerRef} 2942 style={{ 2943 position: "relative", 2944 width: "100%", 2945 maxWidth: 640, 2946 cursor: "pointer", 2947 }} 2948 > 2949 {!playing && ( 2950 <> 2951 <img 2952 src={thumbnail} 2953 alt="Video thumbnail" 2954 style={{ 2955 width: "100%", 2956 display: "block", 2957 aspectRatio: aspect ? aspect?.width / aspect?.height : 16 / 9, 2958 borderRadius: 12, 2959 //border: `1px solid ${theme.border}`, 2960 }} 2961 className="border border-gray-200 dark:border-gray-800 was7" 2962 onClick={async (e) => { 2963 e.stopPropagation(); 2964 setPlaying(true); 2965 }} 2966 /> 2967 <div 2968 onClick={async (e) => { 2969 e.stopPropagation(); 2970 setPlaying(true); 2971 }} 2972 style={{ 2973 position: "absolute", 2974 top: "50%", 2975 left: "50%", 2976 transform: "translate(-50%, -50%)", 2977 //fontSize: 48, 2978 color: "white", 2979 //textShadow: theme.cardShadow, 2980 pointerEvents: "none", 2981 userSelect: "none", 2982 }} 2983 className="text-shadow-md" 2984 > 2985 {/*▶️*/} 2986 <MdiPlayCircle /> 2987 </div> 2988 </> 2989 )} 2990 {playing && ( 2991 <div 2992 style={{ 2993 position: "relative", 2994 width: "100%", 2995 borderRadius: 12, 2996 overflow: "hidden", 2997 //border: `1px solid ${theme.border}`, 2998 paddingTop: `${ 2999 100 / (aspect ? aspect.width / aspect.height : 16 / 9) 3000 }%`, // 16:9 = 56.25%, 4:3 = 75% 3001 }} 3002 className="border border-gray-200 dark:border-gray-800 was7" 3003 > 3004 <ReactPlayer 3005 src={url} 3006 playing={true} 3007 controls={true} 3008 width="100%" 3009 height="100%" 3010 style={{ position: "absolute", top: 0, left: 0 }} 3011 /> 3012 {/* <ReactPlayer 3013 url={url} 3014 playing={true} 3015 controls={true} 3016 width="100%" 3017 style={{width: "100% !important", aspectRatio: aspect ? aspect?.width/aspect?.height : 16/9}} 3018 onPause={() => setPlaying(false)} 3019 onEnded={() => setPlaying(false)} 3020 /> */} 3021 </div> 3022 )} 3023 </div> 3024 ); 3025};