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

Configure Feed

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

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