an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

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