diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts index 074b47d..ffc2c6e 100644 --- a/src/auto-imports.d.ts +++ b/src/auto-imports.d.ts @@ -19,16 +19,27 @@ declare global { const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default + const IconMdiCardsHeart: typeof import('~icons/mdi/cards-heart.jsx').default + const IconMdiCardsHeartOutline: typeof import('~icons/mdi/cards-heart-outline.jsx').default const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default const IconMdiCheckCircle: typeof import('~icons/mdi/check-circle.jsx').default const IconMdiCheckboxMultipleMarked: typeof import('~icons/mdi/checkbox-multiple-marked.jsx').default const IconMdiClock: typeof import('~icons/mdi/clock.jsx').default const IconMdiClockOutline: typeof import('~icons/mdi/clock-outline.jsx').default const IconMdiClose: typeof import('~icons/mdi/close.jsx').default + const IconMdiCommentOutline: typeof import('~icons/mdi/comment-outline.jsx').default const IconMdiGlobe: typeof import('~icons/mdi/globe.jsx').default const IconMdiLock: typeof import('~icons/mdi/lock.jsx').default const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default + const IconMdiMoreHoriz: typeof import('~icons/mdi/more-horiz.jsx').default const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default + const IconMdiPlayCircle: typeof import('~icons/mdi/play-circle.jsx').default + const IconMdiRepeat: typeof import('~icons/mdi/repeat.jsx').default + const IconMdiRepeatGreen: typeof import('~icons/mdi/repeat-green.jsx').default + const IconMdiReply: typeof import('~icons/mdi/reply.jsx').default + const IconMdiRepost: typeof import('~icons/mdi/repost.jsx').default + const IconMdiShareVariant: typeof import('~icons/mdi/share-variant.jsx').default const IconMdiShield: typeof import('~icons/mdi/shield.jsx').default const IconMdiShieldOutline: typeof import('~icons/mdi/shield-outline.jsx').default + const IconMdiVerified: typeof import('~icons/mdi/verified.jsx').default } diff --git a/src/components/DefaultCatchBoundary.tsx b/src/components/DefaultCatchBoundary.tsx index 3455bcd..3f6fd1c 100644 --- a/src/components/DefaultCatchBoundary.tsx +++ b/src/components/DefaultCatchBoundary.tsx @@ -1,3 +1,4 @@ +import type { ErrorComponentProps } from "@tanstack/react-router"; import { ErrorComponent, Link, @@ -5,7 +6,6 @@ import { useMatch, useRouter, } from "@tanstack/react-router"; -import type { ErrorComponentProps } from "@tanstack/react-router"; export function DefaultCatchBoundary({ error }: ErrorComponentProps) { const router = useRouter(); diff --git a/src/components/IconComponents.tsx b/src/components/IconComponents.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/components/LogoSvg.tsx b/src/components/LogoSvg.tsx new file mode 100644 index 0000000..53c6ecc --- /dev/null +++ b/src/components/LogoSvg.tsx @@ -0,0 +1,29 @@ +import type { SVGProps } from 'react'; +import React from 'react'; + +// FluentEmojiHighContrastGlowingStar +export default function FluentEmojiHighContrastGlowingStar(props: SVGProps) { + return (); +} + +export function MaterialSymbolsAppBadgingOutline(props: SVGProps) { + return (); +} + +export function MaterialSymbolsAppBadging(props: SVGProps) { + return (); +} + +export function MaterialSymbolsCircles(props: SVGProps) { + return (); +} + +export function WheyMadeModernistMonogram(props: SVGProps) { + return ( + + ) +} \ No newline at end of file diff --git a/src/components/PollComponents.tsx b/src/components/PollComponents.tsx new file mode 100644 index 0000000..7694ca0 --- /dev/null +++ b/src/components/PollComponents.tsx @@ -0,0 +1,301 @@ +import { useAtom } from "jotai"; +import * as React from "react"; + +import { + usePollData, + usePollMutationQueue, +} from "~/providers/PollMutationQueueProvider"; +import { useAuth } from "~/providers/UnifiedAuthProvider"; +import { renderSnack } from "~/routes/__root"; +import { imgCDNAtom } from "~/utils/atoms"; +import { useQueryArbitrary, useQueryConstellation, useQueryProfile } from "~/utils/useQuery"; + +export function PollEmbed({ did, rkey }: { did: string; rkey: string }) { + const { agent } = useAuth(); + const { refreshPollData } = usePollMutationQueue(); + const pollUri = `at://${did}/app.reddwarf.embed.poll/${rkey}`; + const { data: pollRecord, isLoading, error } = useQueryArbitrary(pollUri); + + const { data: voteCountsA } = useQueryConstellation({ + method: "/links/count/distinct-dids", + target: pollUri, + collection: "app.reddwarf.poll.vote.a", + path: ".subject.uri", + customkey: "constellation-polls", + }); + + const { data: voteCountsB } = useQueryConstellation({ + method: "/links/count/distinct-dids", + target: pollUri, + collection: "app.reddwarf.poll.vote.b", + path: ".subject.uri", + customkey: "constellation-polls", + }); + + const { data: voteCountsC } = useQueryConstellation({ + method: "/links/count/distinct-dids", + target: pollUri, + collection: "app.reddwarf.poll.vote.c", + path: ".subject.uri", + customkey: "constellation-polls", + }); + + const { data: voteCountsD } = useQueryConstellation({ + method: "/links/count/distinct-dids", + target: pollUri, + collection: "app.reddwarf.poll.vote.d", + path: ".subject.uri", + customkey: "constellation-polls", + }); + + const { data: votersA } = useQueryConstellation({ + method: "/links", + target: pollUri, + collection: "app.reddwarf.poll.vote.a", + path: ".subject.uri", + customkey: "constellation-polls", + }); + const { data: votersB } = useQueryConstellation({ + method: "/links", + target: pollUri, + collection: "app.reddwarf.poll.vote.b", + path: ".subject.uri", + customkey: "constellation-polls", + }); + const { data: votersC } = useQueryConstellation({ + method: "/links", + target: pollUri, + collection: "app.reddwarf.poll.vote.c", + path: ".subject.uri", + customkey: "constellation-polls", + }); + const { data: votersD } = useQueryConstellation({ + method: "/links", + target: pollUri, + collection: "app.reddwarf.poll.vote.d", + path: ".subject.uri", + customkey: "constellation-polls", + }); + + const poll = { + ...(pollRecord?.value ?? {}), + multiple: true, + } as { + a: string; + b: string; + c?: string; + d?: string; + expiry?: string; + multiple?: boolean; + createdAt: string; + }; + + const options = [poll.a, poll.b, poll.c, poll.d].filter(Boolean); + + const serverCounts = { + a: parseInt((voteCountsA as any)?.total || "0"), + b: parseInt((voteCountsB as any)?.total || "0"), + c: parseInt((voteCountsC as any)?.total || "0"), + d: parseInt((voteCountsD as any)?.total || "0"), + }; + + const { results, totalVotes, handleVote } = usePollData( + pollUri, + pollRecord?.cid, + !!poll.multiple, + serverCounts, + ); + + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + if (error || !pollRecord?.value) { + return
Failed to load poll
; + } + const isExpired = false; + + return ( + <> +
+
+
+ + Public Poll +
+ + + {poll.multiple ? ( + + ) : ( + + )} + + {poll.multiple + ? "Select one or more options" + : "Select one option"} + + + + +
+ +
+ {options.map((optionText, index) => { + const optionKey = ["a", "b", "c", "d"][index] as + | "a" + | "b" + | "c" + | "d"; + const { topVoterDids } = results[optionKey]; + const optionState = results[optionKey]; + const hasVotedForOption = optionState.hasVoted; + const votePercentage = + totalVotes > 0 ? (optionState.count / totalVotes) * 100 : 0; + + const votersData = (() => { + if (optionKey === "a") return votersA?.linking_records || []; + if (optionKey === "b") return votersB?.linking_records || []; + if (optionKey === "c") return votersC?.linking_records || []; + if (optionKey === "d") return votersD?.linking_records || []; + return []; + })(); + const topVoters = votersData + .filter((v: any) => !!v.did) + .slice(0, 5); + + return ( +
{ + e.stopPropagation(); + if (!isExpired) { + handleVote(optionKey); + } + }} + > +
+ + + {optionText} + {hasVotedForOption && ( + + {poll.multiple ? "✓" : "✓ (click to remove)"} + + )} + + +
+ {topVoterDids.length > 0 && ( +
+ {topVoterDids.map((did, idx) => ( +
+ +
+ ))} +
+ )} + + + {votePercentage.toFixed(0)}% + +
+
+ ); + })} +
+ +
+
+ + Never expires +
+ + +
+
+ + ); +} + +export function PollOptionAvatar({ did }: { did: string }) { + const [imgcdn] = useAtom(imgCDNAtom); + const { data: profileRecord } = useQueryProfile( + `at://${did}/app.bsky.actor.profile/self`, + ); + + const avatarUrl = getAvatarUrl(profileRecord, did, imgcdn); + + if (!avatarUrl) { + return
; + } + + return ( + voter { + const target = e.target as HTMLImageElement; + target.style.display = "none"; + target.parentElement!.style.backgroundColor = "#6b7280"; + }} + /> + ); +} + +function getAvatarUrl(opProfile: any, did: string, cdn: string) { + const link = opProfile?.value?.avatar?.ref?.["$link"]; + if (!link) return null; + return `https://${cdn}/img/avatar/plain/${did}/${link}@jpeg`; +} \ No newline at end of file diff --git a/src/components/PostEmbeds.tsx b/src/components/PostEmbeds.tsx new file mode 100644 index 0000000..7c28f68 --- /dev/null +++ b/src/components/PostEmbeds.tsx @@ -0,0 +1,773 @@ +import { + AppBskyEmbedDefs, + AppBskyEmbedExternal, + AppBskyEmbedImages, + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyEmbedVideo, + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyGraphDefs, + AtUri, + ModerationDecision, +} from "@atproto/api"; +import * as React from "react"; +import { useEffect, useRef, useState } from "react"; +import ReactPlayer from "react-player"; + +import { FeedItemRenderAturiLoader } from "~/routes/profile.$did"; +import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; + +import { PollEmbed } from "./PollComponents"; +import { UniversalPostRenderer } from "./UniversalPostRenderer"; + +type Embed = + | AppBskyEmbedRecord.View + | AppBskyEmbedImages.View + | AppBskyEmbedVideo.View + | AppBskyEmbedExternal.View + | AppBskyEmbedRecordWithMedia.View + | { $type: string; [k: string]: unknown }; + +enum PostEmbedViewContext { + ThreadHighlighted = "ThreadHighlighted", + Feed = "Feed", + FeedEmbedRecordWithMedia = "FeedEmbedRecordWithMedia", +} + +const stopgap = { + display: "flex", + justifyContent: "center", + padding: "32px 12px", + borderRadius: 12, + border: "1px solid rgba(161, 170, 174, 0.38)", +}; + +export function PostEmbeds({ + embed, + moderation, + onOpen, + allowNestedQuotes, + viewContext, + salt, + navigate, + postid, + nopics, + lightboxCallback, + constellationLinks, +}: { + embed?: Embed; + moderation?: ModerationDecision; + onOpen?: () => void; + allowNestedQuotes?: boolean; + viewContext?: PostEmbedViewContext; + salt: string; + navigate: (_: any) => void; + postid?: { did: string; rkey: string }; + nopics?: boolean; + lightboxCallback?: (d: LightboxProps) => void; + constellationLinks?: any; +}) { + function setLightboxIndex(number: number) { + navigate({ + to: "/profile/$did/post/$rkey/image/$i", + params: { + did: postid?.did, + rkey: postid?.rkey, + i: number.toString(), + }, + }); + } + + if ( + AppBskyEmbedRecordWithMedia.isView(embed) && + AppBskyEmbedRecord.isViewRecord(embed.record.record) && + AppBskyFeedPost.isRecord(embed.record.record.value) + ) { + const post: AppBskyFeedDefs.PostView = { + $type: "app.bsky.feed.defs#postView", + uri: embed.record.record.uri, + cid: embed.record.record.cid, + author: embed.record.record.author, + record: embed.record.record.value as { [key: string]: unknown }, + embed: embed.record.record.embeds + ? embed.record.record.embeds?.[0] + : undefined, + replyCount: embed.record.record.replyCount, + repostCount: embed.record.record.repostCount, + likeCount: embed.record.record.likeCount, + quoteCount: embed.record.record.quoteCount, + indexedAt: embed.record.record.indexedAt, + labels: embed.record.record.labels, + }; + + return ( +
+ +
+
+ { + e.stopPropagation(); + const parsed = new AtUri(post.uri); + if (parsed) { + navigate({ + to: "/profile/$did/post/$rkey", + params: { did: parsed.host, rkey: parsed.rkey }, + }); + } + }} + depth={1} + /> +
+
+ ); + } + + if (AppBskyEmbedRecord.isView(embed)) { + const reallybaduri = (embed?.record as any)?.uri as string | undefined; + const reallybadaturi = reallybaduri ? new AtUri(reallybaduri) : undefined; + + if (AppBskyFeedDefs.isGeneratorView(embed.record)) { + return
feedgen placeholder
; + } else if ( + !!reallybaduri && + !!reallybadaturi && + reallybadaturi.collection === "app.bsky.feed.generator" + ) { + return ( +
+ +
+ ); + } + + if (AppBskyGraphDefs.isListView(embed.record)) { + return
list placeholder
; + } else if ( + !!reallybaduri && + !!reallybadaturi && + reallybadaturi.collection === "app.bsky.graph.list" + ) { + return ( +
+ +
+ ); + } + + if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) { + return
starter pack card placeholder
; + } else if ( + !!reallybaduri && + !!reallybadaturi && + reallybadaturi.collection === "app.bsky.graph.starterpack" + ) { + return ( +
+ +
+ ); + } + + if ( + AppBskyEmbedRecord.isViewRecord(embed.record) && + AppBskyFeedPost.isRecord(embed.record.value) + ) { + const post: AppBskyFeedDefs.PostView = { + $type: "app.bsky.feed.defs#postView", + uri: embed.record.uri, + cid: embed.record.cid, + author: embed.record.author, + record: embed.record.value as { [key: string]: unknown }, + embed: embed.record.embeds ? embed.record.embeds?.[0] : undefined, + replyCount: embed.record.replyCount, + repostCount: embed.record.repostCount, + likeCount: embed.record.likeCount, + quoteCount: embed.record.quoteCount, + indexedAt: embed.record.indexedAt, + labels: embed.record.labels, + }; + + return ( +
+ { + e.stopPropagation(); + const parsed = new AtUri(post.uri); + if (parsed) { + navigate({ + to: "/profile/$did/post/$rkey", + params: { did: parsed.host, rkey: parsed.rkey }, + }); + } + }} + depth={1} + /> +
+ ); + } else { + console.log("what the hell is a ", embed); + return <>sorry; + } + } + + if (AppBskyEmbedImages.isView(embed)) { + const { images } = embed; + + const lightboxImages = images.map((img) => ({ + src: img.fullsize, + alt: img.alt, + })); + + if (lightboxCallback) { + lightboxCallback({ images: lightboxImages }); + } + + if (nopics) return; + + if (images.length > 0) { + if (images.length === 1) { + const image = images[0]; + return ( +
+
{ + const { width, height } = image.aspectRatio; + const ratio = width / height; + return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`; + })() + : "1 / 1", + borderRadius: 12, + overflow: "hidden", + }} + className="border border-gray-200 dark:border-gray-800 was7 bg-gray-200 dark:bg-gray-900" + > + {image.alt} { + e.stopPropagation(); + setLightboxIndex(0); + }} + /> +
+
+ ); + } + + if (images.length === 2) { + return ( +
+ {images.map((img, i) => ( +
+ {img.alt} { + e.stopPropagation(); + setLightboxIndex(i); + }} + /> +
+ ))} +
+ ); + } + + if (images.length === 3) { + return ( +
+
+ {images[0].alt} { + e.stopPropagation(); + setLightboxIndex(0); + }} + /> +
+
+ {[1, 2].map((i) => ( +
+ {images[i].alt} { + e.stopPropagation(); + setLightboxIndex(i + 1); + }} + /> +
+ ))} +
+
+ ); + } + + if (images.length === 4) { + return ( +
+ {images.map((img, i) => ( +
+ {img.alt} { + e.stopPropagation(); + setLightboxIndex(i); + }} + /> +
+ ))} +
+ ); + } + + return
image count more than one placeholder
; + } + } + + if (AppBskyEmbedExternal.isView(embed)) { + const pollLinks = constellationLinks?.links?.["app.reddwarf.embed.poll"]; + const hasPollLink = pollLinks && Object.keys(pollLinks).length > 0; + + if (hasPollLink && postid) { + return ; + } + + const link = embed.external; + return ( + + ); + } + + if (AppBskyEmbedVideo.isView(embed)) { + if (nopics) return; + const playlist = embed.playlist; + return ( + + ); + } + + return
; +} + +export function ExternalLinkEmbed({ + link, + onOpen, + style, +}: { + link: AppBskyEmbedExternal.ViewExternal; + onOpen?: () => void; + style?: React.CSSProperties; +}) { + const { uri, title, description, thumb } = link; + const thumbAspectRatio = 1.91; + + const titleStyle = { + fontSize: 16, + fontWeight: 700, + marginBottom: 4, + wordBreak: "break-word", + textAlign: "left", + maxHeight: "4em", + display: "-webkit-box", + WebkitBoxOrient: "vertical", + overflow: "hidden", + WebkitLineClamp: 2, + }; + + const descriptionStyle = { + fontSize: 14, + marginBottom: 8, + wordBreak: "break-word", + textAlign: "left", + maxHeight: "5em", + display: "-webkit-box", + WebkitBoxOrient: "vertical", + overflow: "hidden", + WebkitLineClamp: 3, + }; + + const linkStyle = { + textDecoration: "none", + wordBreak: "break-all", + textAlign: "left", + }; + + const containerStyle = { + display: "flex", + flexDirection: "column", + borderRadius: 12, + maxWidth: "100%", + overflow: "hidden", + ...style, + }; + + return ( + { + e.stopPropagation(); + if (onOpen) onOpen(); + }} + style={linkStyle as React.CSSProperties} + className="text-gray-500 dark:text-gray-400" + > +
+ {thumb && ( +
+ {description} +
+ )} +
+
+ {title} +
+
+ {description} +
+
+
+ + + {getDomain(uri)} + +
+
+
+
+ ); +} + +export const SmartHLSPlayer = ({ + url, + thumbnail, + aspect, +}: { + url: string; + thumbnail?: string; + aspect?: AppBskyEmbedDefs.AspectRatio; +}) => { + const [playing, setPlaying] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting && playing) { + setPlaying(false); + } + }, + { + root: null, + threshold: 0.25, + }, + ); + + if (containerRef.current) { + observer.observe(containerRef.current); + } + + return () => { + if (containerRef.current) { + observer.unobserve(containerRef.current); + } + }; + }, [playing]); + + return ( +
+ {!playing && ( + <> + Video thumbnail { + e.stopPropagation(); + setPlaying(true); + }} + /> +
{ + e.stopPropagation(); + setPlaying(true); + }} + style={{ + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + color: "white", + pointerEvents: "none", + userSelect: "none", + }} + className="text-shadow-md" + > + +
+ + )} + {playing && ( +
+ +
+ )} +
+ ); +}; + +function getDomain(url: string) { + try { + const { hostname } = new URL(url); + return hostname; + } catch (e) { + if (!url.startsWith("http")) { + try { + const { hostname } = new URL("http://" + url); + return hostname; + } catch { + return null; + } + } + return null; + } +} \ No newline at end of file diff --git a/src/components/Star.tsx b/src/components/Star.tsx deleted file mode 100644 index 7b5bb3d..0000000 --- a/src/components/Star.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { SVGProps } from 'react'; -import React from 'react'; - -export function FluentEmojiHighContrastGlowingStar(props: SVGProps) { - return (); -} \ No newline at end of file diff --git a/src/components/UniversalPostRenderer.tsx b/src/components/UniversalPostRenderer.tsx index f059a84..cba0033 100644 --- a/src/components/UniversalPostRenderer.tsx +++ b/src/components/UniversalPostRenderer.tsx @@ -1,12 +1,28 @@ import * as ATPAPI from "@atproto/api"; +import { + AppBskyActorDefs, + AppBskyFeedDefs, + AppBskyFeedPost, + AtUri, + type Facet, +} from "@atproto/api"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import DOMPurify from "dompurify"; import { useAtom } from "jotai"; import { DropdownMenu } from "radix-ui"; import { HoverCard } from "radix-ui"; import * as React from "react"; -import { type SVGProps } from "react"; +import { useEffect, useState } from "react"; +import defaultpfp from "~/../public/favicon.png"; +import { useAuth } from "~/providers/UnifiedAuthProvider"; +import { renderSnack } from "~/routes/__root"; +import { + FollowButton, + Mutual, +} from "~/routes/profile.$did"; +import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; import { composerAtom, constellationURLAtom, @@ -14,9 +30,9 @@ import { enableWafrnTextAtom, imgCDNAtom, } from "~/utils/atoms"; +import { useFastLike } from "~/utils/likeMutationQueue"; import { useHydratedEmbed } from "~/utils/useHydrated"; import { - useQueryArbitrary, useQueryConstellation, useQueryIdentity, useQueryPost, @@ -24,12 +40,15 @@ import { yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks, } from "~/utils/useQuery"; -function asTyped(obj: T): $Typed { - return obj as $Typed; -} - -export const CACHE_TIMEOUT = 5 * 60 * 1000; -const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour +import { PostEmbeds } from "./PostEmbeds"; +import { + btnstyle, + fullDateTimeFormat, + HitSlopButton, + randomString, + renderTextWithFacets, + shortTimeAgo, +} from "./UtilityFunctions"; export interface UniversalPostRendererATURILoaderProps { atUri: string; @@ -53,99 +72,6 @@ export interface UniversalPostRendererATURILoaderProps { filterMustBeReply?: boolean; } -// export async function cachedGetRecord({ -// atUri, -// cacheTimeout = CACHE_TIMEOUT, -// get, -// set, -// }: { -// atUri: string; -// //resolved: { pdsUrl: string; did: string } | null | undefined; -// cacheTimeout?: number; -// get: (key: string) => any; -// set: (key: string, value: string) => void; -// }): Promise { -// const cacheKey = `record:${atUri}`; -// const cached = get(cacheKey); -// const now = Date.now(); -// if ( -// cached && -// cached.value && -// cached.time && -// now - cached.time < cacheTimeout -// ) { -// try { -// return JSON.parse(cached.value); -// } catch { -// // fall through to fetch -// } -// } -// const parsed = parseAtUri(atUri); -// if (!parsed) return null; -// const resolved = await cachedResolveIdentity({ -// didOrHandle: parsed.did, -// get, -// set, -// }); -// if (!resolved?.pdsUrl || !resolved?.did) -// throw new Error("Missing resolved PDS info"); - -// if (!parsed) throw new Error("Invalid atUri"); -// const { collection, rkey } = parsed; -// const url = `${ -// resolved.pdsUrl -// }/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent( -// resolved.did, -// )}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent( -// rkey, -// )}`; -// const res = await fetch(url); -// if (!res.ok) throw new Error("Failed to fetch base record"); -// const data = await res.json(); -// set(cacheKey, JSON.stringify(data)); -// return data; -// } - -// export async function cachedResolveIdentity({ -// didOrHandle, -// cacheTimeout = HANDLE_DID_CACHE_TIMEOUT, -// get, -// set, -// }: { -// didOrHandle: string; -// cacheTimeout?: number; -// get: (key: string) => any; -// set: (key: string, value: string) => void; -// }): Promise { -// const isDidInput = didOrHandle.startsWith("did:"); -// const cacheKey = `handleDid:${didOrHandle}`; -// const now = Date.now(); -// const cached = get(cacheKey); -// if ( -// cached && -// cached.value && -// cached.time && -// now - cached.time < cacheTimeout -// ) { -// try { -// return JSON.parse(cached.value); -// } catch {} -// } -// const url = `https://free-fly-24.deno.dev/?${ -// isDidInput -// ? `did=${encodeURIComponent(didOrHandle)}` -// : `handle=${encodeURIComponent(didOrHandle)}` -// }`; -// const res = await fetch(url); -// if (!res.ok) throw new Error("Failed to resolve handle/did"); -// const data = await res.json(); -// set(cacheKey, JSON.stringify(data)); -// if (!isDidInput && data.did) { -// set(`handleDid:${data.did}`, JSON.stringify(data)); -// } -// return data; -// } - export function UniversalPostRendererATURILoader({ atUri, onConstellation, @@ -167,234 +93,33 @@ export function UniversalPostRendererATURILoader({ filterMustHaveMedia, filterMustBeReply, }: UniversalPostRendererATURILoaderProps) { - // todo remove this once tree rendering is implemented, use a prop like isTree const TEMPLINEAR = true; - // /*mass comment*/ console.log("atUri", atUri); - //const { get, set } = usePersistentStore(); - //const [record, setRecord] = React.useState(null); - //const [links, setLinks] = React.useState(null); - //const [error, setError] = React.useState(null); - //const [cacheTime, setCacheTime] = React.useState(null); - //const [resolved, setResolved] = React.useState(null); // { did, pdsUrl, bskyPds, handle } - //const [opProfile, setOpProfile] = React.useState(null); - // const [opProfileCacheTime, setOpProfileCacheTime] = React.useState< - // number | null - // >(null); - //const router = useRouter(); - - //const parsed = React.useMemo(() => parseAtUri(atUri), [atUri]); const parsed = new AtUri(atUri); const did = parsed?.host; const rkey = parsed?.rkey; - // /*mass comment*/ console.log("did", did); - // /*mass comment*/ console.log("rkey", rkey); - - // React.useEffect(() => { - // const checkCache = async () => { - // const postUri = atUri; - // const cacheKey = `record:${postUri}`; - // const cached = await get(cacheKey); - // const now = Date.now(); - // // /*mass comment*/ console.log( - // "UniversalPostRenderer checking cache for", - // cacheKey, - // "cached:", - // !!cached, - // ); - // if ( - // cached && - // cached.value && - // cached.time && - // now - cached.time < CACHE_TIMEOUT - // ) { - // try { - // // /*mass comment*/ console.log("UniversalPostRenderer found cached data for", cacheKey); - // setRecord(JSON.parse(cached.value)); - // } catch { - // setRecord(null); - // } - // } - // }; - // checkCache(); - // }, [atUri, get]); const { data: postQuery, isLoading: isPostLoading, isError: isPostError, } = useQueryPost(atUri); - //const record = postQuery?.value; - - // React.useEffect(() => { - // if (!did || record) return; - // (async () => { - // try { - // const resolvedData = await cachedResolveIdentity({ - // didOrHandle: did, - // get, - // set, - // }); - // setResolved(resolvedData); - // } catch (e: any) { - // //setError("Failed to resolve handle/did: " + e?.message); - // } - // })(); - // }, [did, get, set, record]); const { data: resolved } = useQueryIdentity(did || ""); - // React.useEffect(() => { - // if (!resolved || !resolved.pdsUrl || !resolved.did || !rkey || record) - // return; - // let ignore = false; - // (async () => { - // try { - // const data = await cachedGetRecord({ - // atUri, - // get, - // set, - // }); - // if (!ignore) setRecord(data); - // } catch (e: any) { - // //if (!ignore) setError("Failed to fetch base record: " + e?.message); - // } - // })(); - // return () => { - // ignore = true; - // }; - // }, [resolved, rkey, atUri, record]); - - // React.useEffect(() => { - // if (!resolved || !resolved.did || !rkey) return; - // const fetchLinks = async () => { - // const postUri = atUri; - // const cacheKey = `constellation:${postUri}`; - // const cached = await get(cacheKey); - // const now = Date.now(); - // if ( - // cached && - // cached.value && - // cached.time && - // now - cached.time < CACHE_TIMEOUT - // ) { - // try { - // const data = JSON.parse(cached.value); - // setLinks(data); - // if (onConstellation) onConstellation(data); - // } catch { - // setLinks(null); - // } - // //setCacheTime(cached.time); - // return; - // } - // try { - // const url = `https://constellation.microcosm.blue/links/all?target=${encodeURIComponent( - // atUri, - // )}`; - // const res = await fetch(url); - // if (!res.ok) throw new Error("Failed to fetch constellation links"); - // const data = await res.json(); - // setLinks(data); - // //setCacheTime(now); - // set(cacheKey, JSON.stringify(data)); - // if (onConstellation) onConstellation(data); - // } catch (e: any) { - // //setError("Failed to fetch constellation links: " + e?.message); - // } - // }; - // fetchLinks(); - // }, [resolved, rkey, get, set, atUri, onConstellation]); - const { data: links } = useQueryConstellation({ method: "/links/all", target: atUri, }); - // React.useEffect(() => { - // if (!record || !resolved || !resolved.did) return; - // const fetchOpProfile = async () => { - // const opDid = resolved.did; - // const postUri = atUri; - // const cacheKey = `profile:${postUri}`; - // const cached = await get(cacheKey); - // const now = Date.now(); - // if ( - // cached && - // cached.value && - // cached.time && - // now - cached.time < CACHE_TIMEOUT - // ) { - // try { - // setOpProfile(JSON.parse(cached.value)); - // } catch { - // setOpProfile(null); - // } - // //setOpProfileCacheTime(cached.time); - // return; - // } - // try { - // let opResolvedRaw = await get(`handleDid:${opDid}`); - // let opResolved: any = null; - // if ( - // opResolvedRaw && - // opResolvedRaw.value && - // opResolvedRaw.time && - // now - opResolvedRaw.time < HANDLE_DID_CACHE_TIMEOUT - // ) { - // try { - // opResolved = JSON.parse(opResolvedRaw.value); - // } catch { - // opResolved = null; - // } - // } else { - // const url = `https://free-fly-24.deno.dev/?did=${encodeURIComponent( - // opDid, - // )}`; - // const res = await fetch(url); - // if (!res.ok) throw new Error("Failed to resolve OP did"); - // opResolved = await res.json(); - // set(`handleDid:${opDid}`, JSON.stringify(opResolved)); - // } - // if (!opResolved || !opResolved.pdsUrl) - // throw new Error("OP did resolution failed or missing pdsUrl"); - // const profileUrl = `${ - // opResolved.pdsUrl - // }/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent( - // opDid, - // )}&collection=app.bsky.actor.profile&rkey=self`; - // const profileRes = await fetch(profileUrl); - // if (!profileRes.ok) throw new Error("Failed to fetch OP profile"); - // const profileData = await profileRes.json(); - // setOpProfile(profileData); - // //setOpProfileCacheTime(now); - // set(cacheKey, JSON.stringify(profileData)); - // } catch (e: any) { - // //setError("Failed to fetch OP profile: " + e?.message); - // } - // }; - // fetchOpProfile(); - // }, [record, get, set, rkey, resolved, atUri]); - const { data: opProfile } = useQueryProfile( resolved ? `at://${resolved?.did}/app.bsky.actor.profile/self` : undefined, ); - // const displayName = - // opProfile?.value?.displayName || resolved?.handle || resolved?.did; - // const handle = resolved?.handle ? `@${resolved.handle}` : resolved?.did; - - // const postText = record?.value?.text || ""; - // const createdAt = record?.value?.createdAt - // ? new Date(record.value.createdAt) - // : null; - // const langTags = record?.value?.langs || []; - const [likes, setLikes] = React.useState(null); const [reposts, setReposts] = React.useState(null); const [replies, setReplies] = React.useState(null); React.useEffect(() => { - // /*mass comment*/ console.log(JSON.stringify(links, null, 2)); setLikes( links ? links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0 @@ -413,13 +138,6 @@ export function UniversalPostRendererATURILoader({ ); }, [links]); - // const { data: repliesData } = useQueryConstellation({ - // method: "/links", - // target: atUri, - // collection: "app.bsky.feed.post", - // path: ".reply.parent.uri", - // }); - const [constellationurl] = useAtom(constellationURLAtom); const infinitequeryresults = useInfiniteQuery({ @@ -435,14 +153,8 @@ export function UniversalPostRendererATURILoader({ enabled: !!atUri && !!maxReplies && !isQuote, }); - const { - data: repliesData, - // fetchNextPage, - // hasNextPage, - // isFetchingNextPage, - } = infinitequeryresults; + const { data: repliesData } = infinitequeryresults; - // auto-fetch all pages useEffect(() => { if (!maxReplies || isQuote || TEMPLINEAR) return; if ( @@ -465,8 +177,6 @@ export function UniversalPostRendererATURILoader({ ) : []; - //const [oldestOpsReply, setOldestOpsReply] = useState(undefined); - const { oldestOpsReply, oldestOpsReplyElseNewestNonOpsReply } = (() => { if (isQuote || !replyAturis || replyAturis.length === 0 || !maxReplies) return { @@ -474,10 +184,7 @@ export function UniversalPostRendererATURILoader({ oldestOpsReplyElseNewestNonOpsReply: undefined, }; - const opdid = new AtUri( - //postQuery?.value.reply?.root.uri ?? postQuery?.uri ?? atUri - atUri, - ).host; + const opdid = new AtUri(atUri).host; const opReplies = replyAturis.filter( (aturi) => new AtUri(aturi).host === opdid, @@ -485,7 +192,6 @@ export function UniversalPostRendererATURILoader({ if (opReplies.length > 0) { const opreply = opReplies[opReplies.length - 1]; - //setOldestOpsReply(opreply); return { oldestOpsReply: opreply, oldestOpsReplyElseNewestNonOpsReply: opreply, @@ -498,23 +204,12 @@ export function UniversalPostRendererATURILoader({ } })(); - // const navigateToProfile = (e: React.MouseEvent) => { - // e.stopPropagation(); - // if (resolved?.did) { - // router.navigate({ - // to: "/profile/$did", - // params: { did: resolved.did }, - // }); - // } - // }; if (!postQuery?.value) { - // deleted post more often than a non-resolvable post return <>; } return ( <> - {/* uprrs {maxReplies} {!!maxReplies&&!!oldestOpsReplyElseNewestNonOpsReply ? "true" : "false"} */} {maxReplies && maxReplies === 0 && replies && replies > 0 ? ( <> - {/*
hello
*/} ) : ( @@ -570,9 +262,7 @@ export function UniversalPostRendererATURILoader({ {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( <> - {/* hello {maxReplies} */} 0} topReplyLine={ @@ -622,7 +312,6 @@ function MoreReplies({ atUri }: { atUri: string }) { opacity: 0.5, }} className="dark:bg-[repeating-linear-gradient(to_bottom,var(--color-gray-500)_0,var(--color-gray-400)_4px,transparent_4px,transparent_8px)]" - //className="border-gray-400 dark:border-gray-500" />
@@ -692,75 +381,8 @@ export function UniversalPostRendererRawRecordShim({ filterMustHaveMedia?: boolean; filterMustBeReply?: boolean; }) { - // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`); const navigate = useNavigate(); - //const { get, set } = usePersistentStore(); - // const [hydratedEmbed, setHydratedEmbed] = useState(undefined); - - // useEffect(() => { - // const run = async () => { - // if (!postRecord?.value?.embed) return; - // const embed = postRecord?.value?.embed; - // if (!embed || !embed.$type) { - // setHydratedEmbed(undefined); - // return; - // } - - // try { - // let result: any; - - // if (embed?.$type === "app.bsky.embed.recordWithMedia") { - // const mediaEmbed = embed.media; - - // let hydratedMedia; - // if (mediaEmbed?.$type === "app.bsky.embed.images") { - // hydratedMedia = hydrateEmbedImages(mediaEmbed, resolved?.did); - // } else if (mediaEmbed?.$type === "app.bsky.embed.external") { - // hydratedMedia = hydrateEmbedExternal(mediaEmbed, resolved?.did); - // } else if (mediaEmbed?.$type === "app.bsky.embed.video") { - // hydratedMedia = hydrateEmbedVideo(mediaEmbed, resolved?.did); - // } else { - // throw new Error("idiot"); - // } - // if (!hydratedMedia) throw new Error("idiot"); - - // // hydrate the outer recordWithMedia now using the hydrated media - // result = await hydrateEmbedRecordWithMedia( - // embed, - // resolved?.did, - // hydratedMedia, - // get, - // set, - // ); - // } else { - // const hydrated = - // embed?.$type === "app.bsky.embed.images" - // ? hydrateEmbedImages(embed, resolved?.did) - // : embed?.$type === "app.bsky.embed.external" - // ? hydrateEmbedExternal(embed, resolved?.did) - // : embed?.$type === "app.bsky.embed.video" - // ? hydrateEmbedVideo(embed, resolved?.did) - // : embed?.$type === "app.bsky.embed.record" - // ? hydrateEmbedRecord(embed, resolved?.did, get, set) - // : undefined; - - // result = hydrated instanceof Promise ? await hydrated : hydrated; - // } - - // // /*mass comment*/ console.log( - // String(result) + " hydrateEmbedRecordWithMedia hey hyeh ye", - // ); - // setHydratedEmbed(result); - // } catch (e) { - // console.error("Error hydrating embed", e); - // setHydratedEmbed(undefined); - // } - // }; - - // run(); - // }, [postRecord, resolved?.did]); - const hasEmbed = (postRecord?.value as ATPAPI.AppBskyFeedPost.Record)?.embed; const hasImages = hasEmbed?.$type === "app.bsky.embed.images"; const hasVideo = hasEmbed?.$type === "app.bsky.embed.video"; @@ -786,7 +408,7 @@ export function UniversalPostRendererRawRecordShim({ const [imgcdn] = useAtom(imgCDNAtom); - const parsedaturi = new AtUri(aturi); //parseAtUri(aturi); + const parsedaturi = new AtUri(aturi); const fakeprofileviewbasic = React.useMemo( () => ({ @@ -841,35 +463,6 @@ export function UniversalPostRendererRawRecordShim({ ], ); - //const [feedviewpostreplyhandle, setFeedviewpostreplyhandle] = useState(undefined); - - // useEffect(() => { - // if(!feedviewpost) return; - // let cancelled = false; - - // const run = async () => { - // const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent?.uri; - // const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined; - - // if (feedviewpostreplydid) { - // const opi = await cachedResolveIdentity({ - // didOrHandle: feedviewpostreplydid, - // get, - // set, - // }); - - // if (!cancelled) { - // setFeedviewpostreplyhandle(opi?.handle); - // } - // } - // }; - - // run(); - - // return () => { - // cancelled = true; - // }; - // }, [fakepost, get, set]); const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent ?.uri; const feedviewpostreplydid = @@ -893,11 +486,6 @@ export function UniversalPostRendererRawRecordShim({ return ( <> - {/*

- {postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)} -

*/} - {/* filtermustbereply is {filterMustBeReply ? "true" : "false"} - thereply is {thereply ? "true" : "false"} */} @@ -907,9 +495,6 @@ export function UniversalPostRendererRawRecordShim({ params: { did: parsedaturi.host, rkey: parsedaturi.rkey }, }) } - // onProfileClick={() => parsedaturi && navigate({to: "/profile/$did", - // params: {did: parsedaturi.did} - // })} onProfileClick={(e) => { e.stopPropagation(); if (parsedaturi) { @@ -925,7 +510,6 @@ export function UniversalPostRendererRawRecordShim({ bottomReplyLine={bottomReplyLine} topReplyLine={topReplyLine} bottomBorder={bottomBorder} - //extraOptionalItemInfo={{reply: postRecord?.value?.reply as AppBskyFeedDefs.ReplyRef, post: fakepost}} feedviewpostreplyhandle={feedviewpostreplyhandle} repostedby={feedviewpostrepostedbyhandle} style={style} @@ -942,450 +526,13 @@ export function UniversalPostRendererRawRecordShim({ ); } -// export function parseAtUri( -// atUri: string -// ): { did: string; collection: string; rkey: string } | null { -// const PREFIX = "at://"; -// if (!atUri.startsWith(PREFIX)) { -// return null; -// } - -// const parts = atUri.slice(PREFIX.length).split("/"); - -// if (parts.length !== 3) { -// return null; -// } - -// const [did, collection, rkey] = parts; - -// if (!did || !collection || !rkey) { -// return null; -// } - -// return { did, collection, rkey }; -// } - -export function MdiCommentOutline(props: SVGProps) { - return ( - - - - ); -} - -export function MdiRepeat(props: SVGProps) { - return ( - - - - ); -} - -export function MdiRepeatGreen(props: SVGProps) { - return ( - - - - ); -} - -export function MdiCardsHeart(props: SVGProps) { - return ( - - - - ); -} - -export function MdiCardsHeartOutline(props: SVGProps) { - return ( - - - - ); -} - -export function MdiShareVariant(props: SVGProps) { - return ( - - - - ); -} - -export function MdiMoreHoriz(props: SVGProps) { - return ( - - - - ); -} - -export function MdiGlobe(props: SVGProps) { - return ( - - - - ); -} - -export function MdiVerified(props: SVGProps) { - return ( - - - - ); -} - -export function MdiReply(props: SVGProps) { - return ( - - - - ); -} - -export function LineMdLoadingLoop(props: SVGProps) { - return ( - - - - - - - ); -} - -export function MdiRepost(props: SVGProps) { - return ( - - - - ); -} - -export function MdiRepeatVariant(props: SVGProps) { - return ( - - - - ); -} - -export function MdiPlayCircle(props: SVGProps) { - return ( - - - - ); -} - -/* what imported from testfront */ -//import Masonry from "@mui/lab/Masonry"; -import { - type $Typed, - AppBskyActorDefs, - AppBskyEmbedDefs, - AppBskyEmbedExternal, - AppBskyEmbedImages, - AppBskyEmbedRecord, - AppBskyEmbedRecordWithMedia, - AppBskyEmbedVideo, - AppBskyFeedDefs, - AppBskyFeedPost, - AppBskyGraphDefs, - AtUri, - type Facet, - //AppBskyLabelerDefs, - //AtUri, - //ComAtprotoRepoStrongRef, - ModerationDecision, -} from "@atproto/api"; -import type { - //BlockedPost, - FeedViewPost, - //NotFoundPost, - PostView, - //ThreadViewPost, -} from "@atproto/api/dist/client/types/app/bsky/feed/defs"; -import { useInfiniteQuery } from "@tanstack/react-query"; -import { useEffect, useRef, useState } from "react"; -import ReactPlayer from "react-player"; - -import defaultpfp from "~/../public/favicon.png"; -import { - usePollData, - usePollMutationQueue, -} from "~/providers/PollMutationQueueProvider"; -import { useAuth } from "~/providers/UnifiedAuthProvider"; -import { renderSnack } from "~/routes/__root"; -import { - FeedItemRenderAturiLoader, - FollowButton, - Mutual, -} from "~/routes/profile.$did"; -import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; -import { useFastLike } from "~/utils/likeMutationQueue"; - -// import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; -// import type { -// ViewRecord, -// ViewNotFound, -// ViewBlocked, -// ViewDetached, -// } from "@atproto/api/dist/client/types/app/bsky/embed/record"; -//import type { MasonryItemData } from "./onemason/masonry.types"; -//import { MasonryLayout } from "./onemason/MasonryLayout"; -// const agent = new AtpAgent({ -// service: 'https://public.api.bsky.app' -// }) -type HitSlopButtonProps = React.ButtonHTMLAttributes & { - hitSlop?: number; -}; - -const HitSlopButtonCustom: React.FC = ({ - children, - hitSlop = 8, - style, - ...rest -}) => ( - -); - -const HitSlopButton = ({ - onClick, - children, - style = {}, - ...rest -}: React.HTMLAttributes & { - onClick?: (e: React.MouseEvent) => void; - children: React.ReactNode; - style?: React.CSSProperties; -}) => ( - - { - e.stopPropagation(); - onClick?.(e); - }} - /> - - {children} - - -); - -const btnstyle = { - display: "flex", - gap: 4, - cursor: "pointer", - alignItems: "center", - fontSize: 14, -}; -function randomString(length = 8) { - const chars = - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - return Array.from( - { length }, - () => chars[Math.floor(Math.random() * chars.length)], - ).join(""); -} - -function UniversalPostRenderer({ +export function UniversalPostRenderer({ post, uprrrsauthor, - //setMainItem, - //isMainItem, onPostClick, onProfileClick, expanded, - //expanded, isQuote, - //isQuote, extraOptionalItemInfo, bottomReplyLine, topReplyLine, @@ -1403,18 +550,13 @@ function UniversalPostRenderer({ maxReplies, constellationLinks, }: { - post: PostView; + post: AppBskyFeedDefs.PostView; uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed; - // optional for now because i havent ported every use to this yet - // setMainItem?: React.Dispatch< - // React.SetStateAction - // >; - //isMainItem?: boolean; onPostClick?: (e: React.MouseEvent) => void; onProfileClick?: (e: React.MouseEvent) => void; expanded?: boolean; isQuote?: boolean; - extraOptionalItemInfo?: FeedViewPost; + extraOptionalItemInfo?: AppBskyFeedDefs.FeedViewPost; bottomReplyLine?: boolean; topReplyLine?: boolean; salt: string; @@ -1442,12 +584,6 @@ function UniversalPostRenderer({ post.viewer?.repost, ); const { liked, toggle, backfill } = useFastLike(post.uri, post.cid); - // const bovref = useBackfillOnView(post.uri, post.cid); - // React.useLayoutEffect(()=>{ - // if (expanded && !isQuote) { - // backfill(); - // } - // },[backfill, expanded, isQuote]) const repostOrUnrepostPost = async () => { if (!agent) { @@ -1517,15 +653,12 @@ function UniversalPostRenderer({ (showBridgyText ? unfedibridgy : undefined) ?? (showWafrnText ? unfediwafrn : undefined); - /* fuck you */ const isMainItem = false; const setMainItem = (any: any) => {}; - // eslint-disable-next-line react-hooks/refs - //console.log("Received ref in UniversalPostRenderer:", usedref); + return (
- Reposted by @{isRepost}{" "} + Reposted by @{isRepost}
)} {!isQuote && (
@@ -1651,11 +770,11 @@ function UniversalPostRenderer({
- {post.author.displayName || post.author.handle}{" "} + {post.author.displayName || post.author.handle}
@ - {post.author.handle}{" "} + {post.author.handle}
{uprrrsauthor?.description && ( @@ -1663,28 +782,8 @@ function UniversalPostRenderer({ {uprrrsauthor.description}
)} - {/*
-
-
- 0 -
-
- Following -
-
-
-
- 2,900 -
-
- Followers -
-
-
*/}
- - {/* */} @@ -1701,33 +800,17 @@ function UniversalPostRenderer({ marginRight: expanded || isQuote ? 0 : 12, }} > - {/* dummy for later use */}
- {/* reply line !!!! bottomReplyLine */} {bottomReplyLine && (
)} - {/*
*/}
- {/* reply indicator */} {!!feedviewpostreplyhandle && (
- Reply to @{feedviewpostreplyhandle} + Reply to @{feedviewpostreplyhandle}
)}
) : null} {post.embed && depth > 0 && ( - /* pretty bad hack imo. its trying to sync up with how the embed shim doesnt - hydrate embeds this deep but the connection here is implicit - todo: idk make this a real part of the embed shim so its not implicit */ <>
(there is an embed here thats too deep to render) @@ -1914,18 +984,14 @@ function UniversalPostRenderer({
{fullDateTimeFormat(post.indexedAt)} @@ -1938,10 +1004,8 @@ function UniversalPostRenderer({ display: "flex", gap: 32, paddingTop: 8, - //color: theme.textSecondary, fontSize: 15, justifyContent: "space-between", - //background: "#0f0", }} className="text-gray-500 dark:text-gray-400" > @@ -1953,7 +1017,7 @@ function UniversalPostRenderer({ ...btnstyle, }} > - + {post.replyCount} @@ -1965,7 +1029,7 @@ function UniversalPostRenderer({ }} aria-label="Repost or quote post" > - {hasRetweeted ? : } + {hasRetweeted ? : } {post.repostCount ?? 0}
@@ -1980,7 +1044,7 @@ function UniversalPostRenderer({ onSelect={repostOrUnrepostPost} className="px-3 py-2 text-sm flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-700" > - {hasRetweeted ? "Undo Repost" : "Repost"} @@ -1995,8 +1059,7 @@ function UniversalPostRenderer({ }} className="px-3 py-2 text-sm flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-700" > - {/* You might want a specific quote icon here */} - + Quote @@ -2011,7 +1074,7 @@ function UniversalPostRenderer({ ...(liked ? { color: "#EC4899" } : {}), }} > - {liked ? : } + {liked ? : } {(post.likeCount || 0) + (liked ? 1 : 0)}
@@ -2030,7 +1093,6 @@ function UniversalPostRenderer({ title: "Copied to clipboard!", }); } catch (_e) { - // idk renderSnack({ title: "Failed to copy link", }); @@ -2040,7 +1102,7 @@ function UniversalPostRenderer({ ...btnstyle, }} > - + { @@ -2050,7 +1112,7 @@ function UniversalPostRenderer({ }} > - +
@@ -2059,7 +1121,6 @@ function UniversalPostRenderer({
@@ -2070,1563 +1131,8 @@ function UniversalPostRenderer({ ); } -const fullDateTimeFormat = (iso: string) => { - const date = new Date(iso); - return date.toLocaleString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "2-digit", - hour12: true, - }); -}; -const shortTimeAgo = (iso: string) => { - const diff = Date.now() - new Date(iso).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 1) return "now"; - if (mins < 60) return `${mins}m`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h`; - const days = Math.floor(hrs / 24); - return `${days}d`; -}; - -// const toAtUri = (url: string) => -// url -// .replace("https://bsky.app/profile/", "at://") -// .replace("/feed/", "/app.bsky.feed.generator/"); - -// function PostSizedElipsis() { -// return ( -//
-//
-// -// more posts -// -//
-// ); -// } - -type Embed = - | AppBskyEmbedRecord.View - | AppBskyEmbedImages.View - | AppBskyEmbedVideo.View - | AppBskyEmbedExternal.View - | AppBskyEmbedRecordWithMedia.View - | { $type: string; [k: string]: unknown }; - enum PostEmbedViewContext { ThreadHighlighted = "ThreadHighlighted", Feed = "Feed", FeedEmbedRecordWithMedia = "FeedEmbedRecordWithMedia", } -const stopgap = { - display: "flex", - justifyContent: "center", - padding: "32px 12px", - borderRadius: 12, - border: "1px solid rgba(161, 170, 174, 0.38)", -}; - -function PollEmbed({ did, rkey }: { did: string; rkey: string }) { - const { agent } = useAuth(); - const { refreshPollData } = usePollMutationQueue(); - const pollUri = `at://${did}/app.reddwarf.embed.poll/${rkey}`; - const { data: pollRecord, isLoading, error } = useQueryArbitrary(pollUri); - - // --- 1. Fetch Aggregate Counts & Avatars (Public Data) --- - // (We still fetch these here as they are View-specific data dependencies) - - const { data: voteCountsA } = useQueryConstellation({ - method: "/links/count/distinct-dids", - target: pollUri, - collection: "app.reddwarf.poll.vote.a", - path: ".subject.uri", - customkey: "constellation-polls", - }); - - const { data: voteCountsB } = useQueryConstellation({ - method: "/links/count/distinct-dids", - target: pollUri, - collection: "app.reddwarf.poll.vote.b", - path: ".subject.uri", - customkey: "constellation-polls", - }); - - const { data: voteCountsC } = useQueryConstellation({ - method: "/links/count/distinct-dids", - target: pollUri, - collection: "app.reddwarf.poll.vote.c", - path: ".subject.uri", - customkey: "constellation-polls", - }); - - const { data: voteCountsD } = useQueryConstellation({ - method: "/links/count/distinct-dids", - target: pollUri, - collection: "app.reddwarf.poll.vote.d", - path: ".subject.uri", - customkey: "constellation-polls", - }); - - // Query first page of voters for Avatars - const { data: votersA } = useQueryConstellation({ - method: "/links", - target: pollUri, - collection: "app.reddwarf.poll.vote.a", - path: ".subject.uri", - customkey: "constellation-polls", - }); - const { data: votersB } = useQueryConstellation({ - method: "/links", - target: pollUri, - collection: "app.reddwarf.poll.vote.b", - path: ".subject.uri", - customkey: "constellation-polls", - }); - const { data: votersC } = useQueryConstellation({ - method: "/links", - target: pollUri, - collection: "app.reddwarf.poll.vote.c", - path: ".subject.uri", - customkey: "constellation-polls", - }); - const { data: votersD } = useQueryConstellation({ - method: "/links", - target: pollUri, - collection: "app.reddwarf.poll.vote.d", - path: ".subject.uri", - customkey: "constellation-polls", - }); - - // --- 2. Prepare Data --- - // todo: hardcoded to multiple for all public polls - const poll = { - ...(pollRecord?.value ?? {}), - multiple: true, - } as { - a: string; - b: string; - c?: string; - d?: string; - expiry?: string; - multiple?: boolean; - createdAt: string; - }; - - const options = [poll.a, poll.b, poll.c, poll.d].filter(Boolean); - - const serverCounts = { - a: parseInt((voteCountsA as any)?.total || "0"), - b: parseInt((voteCountsB as any)?.total || "0"), - c: parseInt((voteCountsC as any)?.total || "0"), - d: parseInt((voteCountsD as any)?.total || "0"), - }; - - // --- 3. THE MAGIC HOOK (Now centralized) --- - // This hook now fetches self-votes internally and merges them with the serverCounts we passed in - const { results, totalVotes, handleVote } = usePollData( - pollUri, - pollRecord?.cid, - !!poll.multiple, - serverCounts, - ); - - // --- 4. Render --- - - if (isLoading) { - return ( -
-
-
-
-
-
-
-
-
-
- ); - } - - if (error || !pollRecord?.value) { - return
Failed to load poll
; - } - const isExpired = false; //poll.expiry ? new Date(poll.expiry) < new Date() : false; - - // todo unused waiting for private polls - // undefined for public polls which equals never expires - const formattedDate = undefined; - // const formattedDate = poll.expiry - // ? new Date(poll.expiry).toLocaleDateString("en-US", { - // month: "short", - // day: "numeric", - // hour: "numeric", - // minute: "2-digit", - // }) - // : null; - - // const totalVotes = voteData.reduce((sum, item) => sum + item.count, 0); - - // const handleVote = async (option: string) => { - // if (!agent || isExpired) return; - - // try { - // // Get existing votes for this option - // const existingVotes = (() => { - // switch (option) { - // case "a": - // return userVotesA; - // case "b": - // return userVotesB; - // case "c": - // return userVotesC; - // case "d": - // return userVotesD; - // default: - // return []; - // } - // })(); - - // // If user has already voted for this option, delete all votes (unvote) - // if (existingVotes && existingVotes.length > 0) { - // for (const voteUri of existingVotes) { - // const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); - // if (match) { - // const [, did, collection, rkey] = match; - // await agent.com.atproto.repo.deleteRecord({ - // repo: did, - // collection, - // rkey, - // }); - // } - // } - // } else { - // // If not voted for this option, create new vote - // // First, delete votes from other options if poll doesn't allow multiple votes - // if (!poll.multiple) { - // const otherVotes = [ - // ...(userVotesA || []), - // ...(userVotesB || []), - // ...(userVotesC || []), - // ...(userVotesD || []), - // ].filter((vote) => { - // // Filter out votes for the current option - // return !vote.includes(`app.reddwarf.poll.vote.${option}`); - // }); - - // for (const voteUri of otherVotes) { - // const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); - // if (match) { - // const [, did, collection, rkey] = match; - // await agent.com.atproto.repo.deleteRecord({ - // repo: did, - // collection, - // rkey, - // }); - // } - // } - // } - - // // Create new vote - // await agent.com.atproto.repo.createRecord({ - // collection: `app.reddwarf.poll.vote.${option}`, - // repo: agent.assertDid, - // record: { - // $type: `app.reddwarf.poll.vote.${option}`, - // subject: { - // $type: "com.atproto.repo.strongRef", - // uri: pollUri, - // cid: pollRecord.cid, - // }, - // createdAt: new Date().toISOString(), - // }, - // // Let PDS generate rkey automatically - // }); - // } - // } catch (error) { - // console.error("Failed to vote:", error); - // } - // }; - - return ( - <> -
- {/* Header */} -
- {/* Type Pill */} -
- - Public Poll -
- - {/* Multiplicity */} - - {poll.multiple ? ( - - ) : ( - - )} - - {poll.multiple - ? "Select one or more options" - : "Select one option"} - - - - {/* Refresh Button */} - -
- - {/* Options List with Results */} -
- {options.map((optionText, index) => { - const optionKey = ["a", "b", "c", "d"][index] as - | "a" - | "b" - | "c" - | "d"; - const { topVoterDids } = results[optionKey]; - const optionState = results[optionKey]; - const hasVotedForOption = optionState.hasVoted; - const votePercentage = - totalVotes > 0 ? (optionState.count / totalVotes) * 100 : 0; - - // Helper to get voters for avatars - const votersData = (() => { - if (optionKey === "a") return votersA?.linking_records || []; - if (optionKey === "b") return votersB?.linking_records || []; - if (optionKey === "c") return votersC?.linking_records || []; - if (optionKey === "d") return votersD?.linking_records || []; - return []; - })(); - const topVoters = votersData - .filter((v: any) => !!v.did) - .slice(0, 5); - - return ( -
{ - e.stopPropagation(); - if (!isExpired) { - handleVote(optionKey); - } - }} - > - {/* Vote percentage bar - always show */} -
- - {/* Option text */} - - {optionText} - {hasVotedForOption && ( - - {poll.multiple ? "✓" : "✓ (click to remove)"} - - )} - - - {/* Avatar circles and vote count */} -
- {/* Avatar circles - semi overlapping */} - {topVoterDids.length > 0 && ( -
- {topVoterDids.map((did, idx) => ( -
- -
- ))} -
- )} - - {/* Vote count */} - - {votePercentage.toFixed(0)}% - -
-
- ); - })} -
- - {/* Footer */} -
- {/* Expiry */} -
- - {/* Expires {formattedDate} */} - {formattedDate ? ( - !isExpired ? ( - Expires {formattedDate} - ) : ( - Expired at {formattedDate} - ) - ) : ( - Never expires - )} -
- - {/* Status */} - {/*
- {isExpired ? ( - - Poll ended - - ) : ( - - All votes are public - - )} -
*/} - -
-
- {/*
- -
*/} - - ); -} - -function PollOptionAvatar({ did }: { did: string }) { - const [imgcdn] = useAtom(imgCDNAtom); - // Each avatar handles its own data fetching - // If this specific DID is already in cache, it loads instantly - const { data: profileRecord } = useQueryProfile( - `at://${did}/app.bsky.actor.profile/self`, - ); - - //const profile = profileRecord?.value as ATPAPI.AppBskyActorProfile.Record; - const avatarUrl = getAvatarUrl(profileRecord, did, imgcdn); - - if (!avatarUrl) { - // Fallback grey circle - return
; - } - - return ( - voter { - const target = e.target as HTMLImageElement; - target.style.display = "none"; - target.parentElement!.style.backgroundColor = "#6b7280"; - }} - /> - ); -} - -function PostEmbeds({ - embed, - moderation, - onOpen, - allowNestedQuotes, - viewContext, - salt, - navigate, - postid, - nopics, - lightboxCallback, - constellationLinks, -}: { - embed?: Embed; - moderation?: ModerationDecision; - onOpen?: () => void; - allowNestedQuotes?: boolean; - viewContext?: PostEmbedViewContext; - salt: string; - navigate: (_: any) => void; - postid?: { did: string; rkey: string }; - nopics?: boolean; - lightboxCallback?: (d: LightboxProps) => void; - constellationLinks?: any; -}) { - //const [lightboxIndex, setLightboxIndex] = useState(null); - function setLightboxIndex(number: number) { - navigate({ - to: "/profile/$did/post/$rkey/image/$i", - params: { - did: postid?.did, - rkey: postid?.rkey, - i: number.toString(), - }, - }); - } - if ( - AppBskyEmbedRecordWithMedia.isView(embed) && - AppBskyEmbedRecord.isViewRecord(embed.record.record) && - AppBskyFeedPost.isRecord(embed.record.record.value) //&& - //AppBskyFeedPost.validateRecord(embed.record.record.value).success - ) { - const post: PostView = { - $type: "app.bsky.feed.defs#postView", // lmao lies - uri: embed.record.record.uri, - cid: embed.record.record.cid, - author: embed.record.record.author, - record: embed.record.record.value as { [key: string]: unknown }, - embed: embed.record.record.embeds - ? embed.record.record.embeds?.[0] - : undefined, // quotes handles embeds differently, its an array for some reason - replyCount: embed.record.record.replyCount, - repostCount: embed.record.record.repostCount, - likeCount: embed.record.record.likeCount, - quoteCount: embed.record.record.quoteCount, - indexedAt: embed.record.record.indexedAt, - // we dont have a viewer, so this is a best effort conversion, still requires full query later on - labels: embed.record.record.labels, - // neither do we have threadgate. remember to please fetch the full post later - }; - return ( -
- - {/* padding empty div of 8px height */} -
- {/* stopgap sorry*/} -
- { - e.stopPropagation(); - const parsed = new AtUri(post.uri); //parseAtUri(post.uri); - if (parsed) { - navigate({ - to: "/profile/$did/post/$rkey", - params: { did: parsed.host, rkey: parsed.rkey }, - }); - } - }} - depth={1} - /> -
- {/* */} - {/* stopgap sorry */} - {/*
quote post placeholder
*/} - {/* {quote post placeholder
*/} - {/* {} */} -
- ); - } - - if (AppBskyEmbedRecord.isView(embed)) { - // hey im really lazy and im gonna do it the bad way - const reallybaduri = (embed?.record as any)?.uri as string | undefined; - const reallybadaturi = reallybaduri ? new AtUri(reallybaduri) : undefined; - - // custom feed embed (i.e. generator view) - if (AppBskyFeedDefs.isGeneratorView(embed.record)) { - // stopgap sorry - return
feedgen placeholder
; - // return ( - //
- // - //
- // ) - } else if ( - !!reallybaduri && - !!reallybadaturi && - reallybadaturi.collection === "app.bsky.feed.generator" - ) { - return ( -
- -
- ); - } - - // list embed - if (AppBskyGraphDefs.isListView(embed.record)) { - // stopgap sorry - return
list placeholder
; - // return ( - //
- // - //
- // ) - } else if ( - !!reallybaduri && - !!reallybadaturi && - reallybadaturi.collection === "app.bsky.graph.list" - ) { - return ( -
- -
- ); - } - - // starter pack embed - if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) { - // stopgap sorry - return
starter pack card placeholder
; - // return ( - //
- // - //
- // ) - } else if ( - !!reallybaduri && - !!reallybadaturi && - reallybadaturi.collection === "app.bsky.graph.starterpack" - ) { - return ( -
- -
- ); - } - - // quote post - // = - // stopgap sorry - - if ( - AppBskyEmbedRecord.isViewRecord(embed.record) && - AppBskyFeedPost.isRecord(embed.record.value) // && - //AppBskyFeedPost.validateRecord(embed.record.value).success - ) { - const post: PostView = { - $type: "app.bsky.feed.defs#postView", // lmao lies - uri: embed.record.uri, - cid: embed.record.cid, - author: embed.record.author, - record: embed.record.value as { [key: string]: unknown }, - embed: embed.record.embeds ? embed.record.embeds?.[0] : undefined, // quotes handles embeds differently, its an array for some reason - replyCount: embed.record.replyCount, - repostCount: embed.record.repostCount, - likeCount: embed.record.likeCount, - quoteCount: embed.record.quoteCount, - indexedAt: embed.record.indexedAt, - // we dont have a viewer, so this is a best effort conversion, still requires full query later on - labels: embed.record.labels, - // neither do we have threadgate. remember to please fetch the full post later - }; - - return ( -
- { - e.stopPropagation(); - const parsed = new AtUri(post.uri); //parseAtUri(post.uri); - if (parsed) { - navigate({ - to: "/profile/$did/post/$rkey", - params: { did: parsed.host, rkey: parsed.rkey }, - }); - } - }} - depth={1} - /> -
- ); - } else { - console.log("what the hell is a ", embed); - return <>sorry; - } - //return ; - - //return
quote post placeholder
; - // return ( - // - // ) - } - - // image embed - // = - if (AppBskyEmbedImages.isView(embed)) { - const { images } = embed; - - const lightboxImages = images.map((img) => ({ - src: img.fullsize, - alt: img.alt, - })); - console.log("rendering images"); - if (lightboxCallback) { - lightboxCallback({ images: lightboxImages }); - console.log("rendering images"); - } - - if (nopics) return; - - if (images.length > 0) { - // const items = embed.images.map(img => ({ - // uri: img.fullsize, - // thumbUri: img.thumb, - // alt: img.alt, - // dimensions: img.aspectRatio ?? null, - // })) - - if (images.length === 1) { - const image = images[0]; - return ( -
-
{ - const { width, height } = image.aspectRatio; - const ratio = width / height; - return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`; - })() - : "1 / 1", // fallback to square - //backgroundColor: theme.background, // fallback letterboxing color - borderRadius: 12, - //border: `1px solid ${theme.border}`, - overflow: "hidden", - }} - className="border border-gray-200 dark:border-gray-800 was7 bg-gray-200 dark:bg-gray-900" - > - {/* {lightboxIndex !== null && ( - setLightboxIndex(null)} - onNavigate={(newIndex) => setLightboxIndex(newIndex)} - post={postid} - /> - )} */} - {image.alt} { - e.stopPropagation(); - setLightboxIndex(0); - }} - /> -
-
- ); - } - // 2 images: side by side, both 1:1, cropped - if (images.length === 2) { - return ( -
- {/* {lightboxIndex !== null && ( - setLightboxIndex(null)} - onNavigate={(newIndex) => setLightboxIndex(newIndex)} - post={postid} - /> - )} */} - {images.map((img, i) => ( -
- {img.alt} { - e.stopPropagation(); - setLightboxIndex(i); - }} - /> -
- ))} -
- ); - } - - // 3 images: left is 1:1, right is two stacked 2:1 - if (images.length === 3) { - return ( -
- {/* {lightboxIndex !== null && ( - setLightboxIndex(null)} - onNavigate={(newIndex) => setLightboxIndex(newIndex)} - post={postid} - /> - )} */} - {/* Left: 1:1 */} -
- {images[0].alt} { - e.stopPropagation(); - setLightboxIndex(0); - }} - /> -
- {/* Right: two stacked 2:1 */} -
- {[1, 2].map((i) => ( -
- {images[i].alt} { - e.stopPropagation(); - setLightboxIndex(i + 1); - }} - /> -
- ))} -
-
- ); - } - - // 4 images: 2x2 grid, all 3:2 - if (images.length === 4) { - return ( -
- {/* {lightboxIndex !== null && ( - setLightboxIndex(null)} - onNavigate={(newIndex) => setLightboxIndex(newIndex)} - post={postid} - /> - )} */} - {images.map((img, i) => ( -
- {img.alt} { - e.stopPropagation(); - setLightboxIndex(i); - }} - /> -
- ))} -
- ); - } - - // stopgap sorry - return
image count more than one placeholder
; - // return ( - //
- // - //
- // ) - } - } - - // external link embed - // = - if (AppBskyEmbedExternal.isView(embed)) { - // Check for poll embed record in constellation links - const pollLinks = constellationLinks?.links?.["app.reddwarf.embed.poll"]; - const hasPollLink = pollLinks && Object.keys(pollLinks).length > 0; - - if (hasPollLink && postid) { - // Return poll embed instead of external embed - return ; - } - - const link = embed.external; - return ( - - ); - } - - // video embed - // = - if (AppBskyEmbedVideo.isView(embed)) { - // hls playlist - if (nopics) return; - const playlist = embed.playlist; - return ( - - ); - // stopgap sorry - //return (
video
) - // return ( - // - // ) - } - - return
; -} - -function getDomain(url: string) { - try { - const { hostname } = new URL(url); - return hostname; - } catch (e) { - // In case it's a bare domain like "example.com" - if (!url.startsWith("http")) { - try { - const { hostname } = new URL("http://" + url); - return hostname; - } catch { - return null; - } - } - return null; - } -} -function getByteToCharMap(text: string): number[] { - const encoder = new TextEncoder(); - //const utf8 = encoder.encode(text); - - const map: number[] = []; - let byteIndex = 0; - let charIndex = 0; - - for (const char of text) { - const bytes = encoder.encode(char); - for (let i = 0; i < bytes.length; i++) { - map[byteIndex++] = charIndex; - } - charIndex += char.length; - } - - return map; -} - -function facetByteRangeToCharRange( - byteStart: number, - byteEnd: number, - byteToCharMap: number[], -): [number, number] { - return [ - byteToCharMap[byteStart] ?? 0, - byteToCharMap[byteEnd - 1]! + 1, // inclusive end -> exclusive char end - ]; -} - -interface FacetRange { - start: number; - end: number; - feature: Facet["features"][number]; -} - -function extractFacetRanges(text: string, facets: Facet[]): FacetRange[] { - const map = getByteToCharMap(text); - return facets.map((f) => { - const [start, end] = facetByteRangeToCharRange( - f.index.byteStart, - f.index.byteEnd, - map, - ); - return { start, end, feature: f.features[0] }; - }); -} -export function renderTextWithFacets({ - text, - facets, - navigate, -}: { - text: string; - facets: Facet[]; - navigate: (_: any) => void; -}) { - const ranges = extractFacetRanges(text, facets).sort( - (a: any, b: any) => a.start - b.start, - ); - - const result: React.ReactNode[] = []; - let current = 0; - - for (const { start, end, feature } of ranges) { - if (current < start) { - result.push({text.slice(current, start)}); - } - - const fragment = text.slice(start, end); - // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed - if (feature.$type === "app.bsky.richtext.facet#link" && feature.uri) { - result.push( - { - e.stopPropagation(); - }} - > - {fragment} - , - ); - } else if ( - feature.$type === "app.bsky.richtext.facet#mention" && - // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed - feature.did - ) { - result.push( - { - e.stopPropagation(); - navigate({ - to: "/profile/$did", - // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed - params: { did: feature.did }, - }); - }} - > - {fragment} - , - ); - } else if (feature.$type === "app.bsky.richtext.facet#tag") { - result.push( - { - e.stopPropagation(); - }} - > - {fragment} - , - ); - } else { - result.push({fragment}); - } - - current = end; - } - - if (current < text.length) { - result.push({text.slice(current)}); - } - - return result; -} -function ExternalLinkEmbed({ - link, - onOpen, - style, -}: { - link: AppBskyEmbedExternal.ViewExternal; - onOpen?: () => void; - style?: React.CSSProperties; -}) { - //const { theme } = useTheme(); - const { uri, title, description, thumb } = link; - const thumbAspectRatio = 1.91; - const titleStyle = { - fontSize: 16, - fontWeight: 700, - marginBottom: 4, - //color: theme.text, - wordBreak: "break-word", - textAlign: "left", - maxHeight: "4em", // 2 lines * 1.5em line-height - // stupid shit - display: "-webkit-box", - WebkitBoxOrient: "vertical", - overflow: "hidden", - WebkitLineClamp: 2, - }; - const descriptionStyle = { - fontSize: 14, - //color: theme.textSecondary, - marginBottom: 8, - wordBreak: "break-word", - textAlign: "left", - maxHeight: "5em", // 3 lines * 1.5em line-height - // stupid shit - display: "-webkit-box", - WebkitBoxOrient: "vertical", - overflow: "hidden", - WebkitLineClamp: 3, - }; - const linkStyle = { - textDecoration: "none", - //color: theme.textSecondary, - wordBreak: "break-all", - textAlign: "left", - }; - const containerStyle = { - display: "flex", - flexDirection: "column", - //backgroundColor: theme.background, - //background: '#eee', - borderRadius: 12, - //border: `1px solid ${theme.border}`, - //boxShadow: theme.cardShadow, - maxWidth: "100%", - overflow: "hidden", - ...style, - }; - return ( - { - e.stopPropagation(); - if (onOpen) onOpen(); - }} - /* @ts-expect-error css arent typed or something idk fuck you */ - style={linkStyle} - className="text-gray-500 dark:text-gray-400" - > -
- {thumb && ( -
- {description} -
- )} -
- {/* @ts-expect-error css */} -
- {title} -
-
- {description} -
- {/* small 1px divider here */} -
-
- - - {getDomain(uri)} - -
-
-
-
- ); -} - -const SmartHLSPlayer = ({ - url, - thumbnail, - aspect, -}: { - url: string; - thumbnail?: string; - aspect?: AppBskyEmbedDefs.AspectRatio; -}) => { - const [playing, setPlaying] = useState(false); - const containerRef = useRef(null); - - // pause the player if it goes out of viewport - useEffect(() => { - const observer = new IntersectionObserver( - ([entry]) => { - if (!entry.isIntersecting && playing) { - setPlaying(false); - } - }, - { - root: null, - threshold: 0.25, - }, - ); - - if (containerRef.current) { - observer.observe(containerRef.current); - } - - return () => { - if (containerRef.current) { - observer.unobserve(containerRef.current); - } - }; - }, [playing]); - - return ( -
- {!playing && ( - <> - Video thumbnail { - e.stopPropagation(); - setPlaying(true); - }} - /> -
{ - e.stopPropagation(); - setPlaying(true); - }} - style={{ - position: "absolute", - top: "50%", - left: "50%", - transform: "translate(-50%, -50%)", - //fontSize: 48, - color: "white", - //textShadow: theme.cardShadow, - pointerEvents: "none", - userSelect: "none", - }} - className="text-shadow-md" - > - {/*▶️*/} - -
- - )} - {playing && ( -
- - {/* setPlaying(false)} - onEnded={() => setPlaying(false)} - /> */} -
- )} -
- ); -}; diff --git a/src/components/UtilityFunctions.tsx b/src/components/UtilityFunctions.tsx new file mode 100644 index 0000000..fc3a13f --- /dev/null +++ b/src/components/UtilityFunctions.tsx @@ -0,0 +1,255 @@ +import type { $Typed,Facet } from "@atproto/api"; +import * as React from "react"; + +export const CACHE_TIMEOUT = 5 * 60 * 1000; +const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour + +export function asTyped(obj: T): $Typed { + return obj as $Typed; +} + +export const fullDateTimeFormat = (iso: string) => { + const date = new Date(iso); + return date.toLocaleString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); +}; + +export const shortTimeAgo = (iso: string) => { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "now"; + if (mins < 60) return `${mins}m`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h`; + const days = Math.floor(hrs / 24); + return `${days}d`; +}; + +export function getByteToCharMap(text: string): number[] { + const encoder = new TextEncoder(); + + const map: number[] = []; + let byteIndex = 0; + let charIndex = 0; + + for (const char of text) { + const bytes = encoder.encode(char); + for (let i = 0; i < bytes.length; i++) { + map[byteIndex++] = charIndex; + } + charIndex += char.length; + } + + return map; +} + +export function facetByteRangeToCharRange( + byteStart: number, + byteEnd: number, + byteToCharMap: number[], +): [number, number] { + return [ + byteToCharMap[byteStart] ?? 0, + byteToCharMap[byteEnd - 1]! + 1, // inclusive end -> exclusive char end + ]; +} + +interface FacetRange { + start: number; + end: number; + feature: Facet["features"][number]; +} + +export function extractFacetRanges( + text: string, + facets: Facet[], +): FacetRange[] { + const map = getByteToCharMap(text); + return facets.map((f) => { + const [start, end] = facetByteRangeToCharRange( + f.index.byteStart, + f.index.byteEnd, + map, + ); + return { start, end, feature: f.features[0] }; + }); +} + +export function renderTextWithFacets({ + text, + facets, + navigate, +}: { + text: string; + facets: Facet[]; + navigate: (_: any) => void; +}) { + const ranges = extractFacetRanges(text, facets).sort( + (a: any, b: any) => a.start - b.start, + ); + + const result: React.ReactNode[] = []; + let current = 0; + + for (const { start, end, feature } of ranges) { + if (current < start) { + result.push({text.slice(current, start)}); + } + + const fragment = text.slice(start, end); + // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed + if (feature.$type === "app.bsky.richtext.facet#link" && feature.uri) { + result.push( + { + e.stopPropagation(); + }} + > + {fragment} + , + ); + } else if ( + feature.$type === "app.bsky.richtext.facet#mention" && + // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed + feature.did + ) { + result.push( + { + e.stopPropagation(); + navigate({ + to: "/profile/$did", + // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed + params: { did: feature.did }, + }); + }} + > + {fragment} + , + ); + } else if (feature.$type === "app.bsky.richtext.facet#tag") { + result.push( + { + e.stopPropagation(); + }} + > + {fragment} + , + ); + } else { + result.push({fragment}); + } + + current = end; + } + + if (current < text.length) { + result.push({text.slice(current)}); + } + + return result; +} + +export function getDomain(url: string) { + try { + const { hostname } = new URL(url); + return hostname; + } catch (e) { + if (!url.startsWith("http")) { + try { + const { hostname } = new URL("http://" + url); + return hostname; + } catch { + return null; + } + } + return null; + } +} + +export function randomString(length = 8) { + const chars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + return Array.from( + { length }, + () => chars[Math.floor(Math.random() * chars.length)], + ).join(""); +} + +export function HitSlopButton({ + onClick, + children, + style = {}, + ...rest +}: React.HTMLAttributes & { + onClick?: (e: React.MouseEvent) => void; + children: React.ReactNode; + style?: React.CSSProperties; +}) { + return ( + + { + e.stopPropagation(); + onClick?.(e); + }} + /> + + {children} + + + ); +} + +export const btnstyle = { + display: "flex", + gap: 4, + cursor: "pointer", + alignItems: "center", + fontSize: 14, +}; \ No newline at end of file diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index bb31d39..cc12744 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -22,8 +22,8 @@ import { Composer } from "~/components/Composer"; import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; import { Import } from "~/components/Import"; import Login from "~/components/Login"; +import Logo from "~/components/LogoSvg"; import { NotFound } from "~/components/NotFound"; -import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider"; import { PollMutationQueueProvider } from "~/providers/PollMutationQueueProvider"; import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; @@ -249,7 +249,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {