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