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 * 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};