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