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

major refactor (icon system and universal post renderer)

+1429 -2629
+11
src/auto-imports.d.ts
··· 19 const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default 20 const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default 21 const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default 22 const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default 23 const IconMdiCheckCircle: typeof import('~icons/mdi/check-circle.jsx').default 24 const IconMdiCheckboxMultipleMarked: typeof import('~icons/mdi/checkbox-multiple-marked.jsx').default 25 const IconMdiClock: typeof import('~icons/mdi/clock.jsx').default 26 const IconMdiClockOutline: typeof import('~icons/mdi/clock-outline.jsx').default 27 const IconMdiClose: typeof import('~icons/mdi/close.jsx').default 28 const IconMdiGlobe: typeof import('~icons/mdi/globe.jsx').default 29 const IconMdiLock: typeof import('~icons/mdi/lock.jsx').default 30 const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default 31 const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default 32 const IconMdiShield: typeof import('~icons/mdi/shield.jsx').default 33 const IconMdiShieldOutline: typeof import('~icons/mdi/shield-outline.jsx').default 34 }
··· 19 const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default 20 const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default 21 const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default 22 + const IconMdiCardsHeart: typeof import('~icons/mdi/cards-heart.jsx').default 23 + const IconMdiCardsHeartOutline: typeof import('~icons/mdi/cards-heart-outline.jsx').default 24 const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default 25 const IconMdiCheckCircle: typeof import('~icons/mdi/check-circle.jsx').default 26 const IconMdiCheckboxMultipleMarked: typeof import('~icons/mdi/checkbox-multiple-marked.jsx').default 27 const IconMdiClock: typeof import('~icons/mdi/clock.jsx').default 28 const IconMdiClockOutline: typeof import('~icons/mdi/clock-outline.jsx').default 29 const IconMdiClose: typeof import('~icons/mdi/close.jsx').default 30 + const IconMdiCommentOutline: typeof import('~icons/mdi/comment-outline.jsx').default 31 const IconMdiGlobe: typeof import('~icons/mdi/globe.jsx').default 32 const IconMdiLock: typeof import('~icons/mdi/lock.jsx').default 33 const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default 34 + const IconMdiMoreHoriz: typeof import('~icons/mdi/more-horiz.jsx').default 35 const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default 36 + const IconMdiPlayCircle: typeof import('~icons/mdi/play-circle.jsx').default 37 + const IconMdiRepeat: typeof import('~icons/mdi/repeat.jsx').default 38 + const IconMdiRepeatGreen: typeof import('~icons/mdi/repeat-green.jsx').default 39 + const IconMdiReply: typeof import('~icons/mdi/reply.jsx').default 40 + const IconMdiRepost: typeof import('~icons/mdi/repost.jsx').default 41 + const IconMdiShareVariant: typeof import('~icons/mdi/share-variant.jsx').default 42 const IconMdiShield: typeof import('~icons/mdi/shield.jsx').default 43 const IconMdiShieldOutline: typeof import('~icons/mdi/shield-outline.jsx').default 44 + const IconMdiVerified: typeof import('~icons/mdi/verified.jsx').default 45 }
+1 -1
src/components/DefaultCatchBoundary.tsx
··· 1 import { 2 ErrorComponent, 3 Link, ··· 5 useMatch, 6 useRouter, 7 } from "@tanstack/react-router"; 8 - import type { ErrorComponentProps } from "@tanstack/react-router"; 9 10 export function DefaultCatchBoundary({ error }: ErrorComponentProps) { 11 const router = useRouter();
··· 1 + import type { ErrorComponentProps } from "@tanstack/react-router"; 2 import { 3 ErrorComponent, 4 Link, ··· 6 useMatch, 7 useRouter, 8 } from "@tanstack/react-router"; 9 10 export function DefaultCatchBoundary({ error }: ErrorComponentProps) { 11 const router = useRouter();
src/components/IconComponents.tsx

This is a binary file and will not be displayed.

+29
src/components/LogoSvg.tsx
···
··· 1 + import type { SVGProps } from 'react'; 2 + import React from 'react'; 3 + 4 + // FluentEmojiHighContrastGlowingStar 5 + export default function FluentEmojiHighContrastGlowingStar(props: SVGProps<SVGSVGElement>) { 6 + return (<svg xmlns="http://www.w3.org/2000/svg" width={32} height={32} viewBox="0 0 32 32" {...props}><g fill="currentColor"><path d="m28.979 17.003l-3.108.214c-.834.06-1.178 1.079-.542 1.608l2.388 1.955c.521.428 1.314.204 1.523-.428l.709-2.127c.219-.632-.292-1.273-.97-1.222M21.75 2.691l-.72 2.9c-.2.78.66 1.41 1.34.98l2.54-1.58c.55-.34.58-1.14.05-1.52l-1.78-1.29a.912.912 0 0 0-1.43.51M6.43 4.995l2.53 1.58c.68.43 1.54-.19 1.35-.98l-.72-2.9a.92.92 0 0 0-1.43-.52l-1.78 1.29c-.53.4-.5 1.19.05 1.53M4.185 20.713l2.29-1.92c.62-.52.29-1.53-.51-1.58l-2.98-.21a.92.92 0 0 0-.94 1.2l.68 2.09c.2.62.97.84 1.46.42m13.61 7.292l-1.12-2.77c-.3-.75-1.36-.75-1.66 0l-1.12 2.77c-.24.6.2 1.26.85 1.26h2.2a.92.92 0 0 0 .85-1.26"></path><path d="m17.565 3.324l1.726 3.72c.326.694.967 1.18 1.717 1.29l4.056.624c1.835.278 2.575 2.53 1.293 3.859L23.268 16a2.28 2.28 0 0 0-.612 1.964l.71 4.374c.307 1.885-1.687 3.293-3.354 2.37l-3.405-1.894a2.25 2.25 0 0 0-2.21 0l-3.404 1.895c-1.668.922-3.661-.486-3.355-2.37l.71-4.375A2.28 2.28 0 0 0 7.736 16l-3.088-3.184c-1.293-1.34-.543-3.581 1.293-3.859l4.055-.625a2.3 2.3 0 0 0 1.717-1.29l1.727-3.719c.819-1.765 3.306-1.765 4.124 0"></path></g></svg>); 7 + } 8 + 9 + export function MaterialSymbolsAppBadgingOutline(props: SVGProps<SVGSVGElement>) { 10 + return (<svg xmlns="http://www.w3.org/2000/svg" width={32} height={32} viewBox="0 0 24 24" {...props}><path fill="currentColor" d="M2 12q0-2.2.85-4.075t2.325-3.25t3.4-2.087t4.125-.563q.425.05.675.363t.175.737t-.412.675t-.763.225q-1.725-.075-3.237.487T6.475 6.176T4.663 8.763T4 12q0 3.35 2.325 5.675T12 20q1.725 0 3.213-.663t2.587-1.812q1.15-1.2 1.7-2.725t.475-3.175q-.025-.425.225-.762t.675-.413t.738.175t.362.675q.15 2.175-.562 4.1t-2.063 3.4q-1.425 1.55-3.325 2.375T12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12m16-3q-1.25 0-2.125-.875T15 6t.875-2.125T18 3t2.125.875T21 6t-.875 2.125T18 9"></path></svg>); 11 + } 12 + 13 + export function MaterialSymbolsAppBadging(props: SVGProps<SVGSVGElement>) { 14 + return (<svg xmlns="http://www.w3.org/2000/svg" width={32} height={32} viewBox="0 0 24 24" {...props}><path fill="currentColor" d="M18 9q-1.25 0-2.125-.875T15 6t.875-2.125T18 3t2.125.875T21 6t-.875 2.125T18 9m-6 13q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175t3.163-2.137T11.975 2q.9 0 1.275.125t.5.475q.1.225.063.475t-.188.5Q13.3 4.1 13.15 4.7T13 6q0 2.075 1.463 3.538T18 11q.65 0 1.263-.15t1.162-.475q.25-.125.5-.162t.45.062q.35.125.488.5t.137 1.25q0 2.075-.788 3.888t-2.137 3.162t-3.175 2.138T12 22"></path></svg>); 15 + } 16 + 17 + export function MaterialSymbolsCircles(props: SVGProps<SVGSVGElement>) { 18 + return (<svg xmlns="http://www.w3.org/2000/svg" width={32} height={32} viewBox="0 0 24 24" {...props}><path fill="currentColor" d="M17 1q2.5 0 4.25 1.75T23 7t-1.75 4.25T17 13t-4.25-1.75T11 7t1.75-4.25T17 1m.1 14.025q.725 0 1.413-.15t1.337-.425q-.525 3.275-3.025 5.413T11 22q-1.875 0-3.512-.712t-2.85-1.925t-1.926-2.85T2 13q0-3.325 2.138-5.825T9.55 4.15q-.275.675-.413 1.4T9 7q.05 3.35 2.4 5.688t5.7 2.337"></path></svg>); 19 + } 20 + 21 + export function WheyMadeModernistMonogram(props: SVGProps<SVGSVGElement>) { 22 + return (<svg xmlns="http://www.w3.org/2000/svg" width={32} height={32} {...props} viewBox="0 0 400 400" > 23 + <path 24 + fill="currentColor" 25 + fillRule="evenodd" 26 + d="M33.203 199.99v166.808l85.889-.025c84.889-.025 89.049-.053 95.752-.663 67.022-6.1 124.432-52.192 144.134-115.719l1.212-3.907c2.601-8.369 4.994-21.38 5.926-32.226 6.672-77.619-41.911-150.344-115.725-173.236l-3.907-1.212c-8.349-2.595-21.481-5.012-32.129-5.913a162.45 162.45 0 0 0-6.25-.392c-1.611-.061-4.094-.159-5.517-.217L200 33.182l-.029 80.821c-.016 44.452-.1 79.415-.187 77.696-3.502-69.442-50.414-130.278-116.19-150.677l-3.906-1.212c-8.35-2.595-21.482-5.012-32.129-5.913a162.702 162.702 0 0 0-6.25-.392c-1.612-.061-4.095-.159-5.518-.217l-2.588-.106V199.99m166.768 80.821c-.016 44.445-.1 79.404-.187 77.685-3.502-69.442-50.414-130.278-116.19-150.677l-3.906-1.212c-10.893-3.385-27.072-5.955-40.333-6.405-2.255-.076 32.967-.153 78.272-.171L200 200l-.029 80.811m-.665 85.404c-.478.167-3.501.084-10.732-.296a60.66 60.66 0 0 1-2.441-.181c-.591-.06-1.728-.162-2.526-.227-.799-.065-1.593-.145-1.765-.178a28.867 28.867 0 0 0-1.38-.181c-2.541-.289-5.363-.661-6.048-.798-.268-.054-.796-.14-1.172-.192a22.365 22.365 0 0 1-1.074-.171 50.717 50.717 0 0 0-1.27-.221 39.378 39.378 0 0 1-1.269-.227c-.215-.045-1.05-.208-1.856-.361-2.548-.484-8.516-1.857-10.253-2.358l-4.004-1.149c-4.749-1.36-13.965-4.56-16.091-5.588-.429-.207-.855-.376-.946-.376-.092 0-.713-.254-1.38-.565a92.974 92.974 0 0 0-2.384-1.063c-.645-.273-1.433-.613-1.751-.753-.944-.417-10.291-5.137-11.14-5.625a109.85 109.85 0 0 0-1.855-1.038 28.409 28.409 0 0 1-1.891-1.143c-.449-.306-.867-.555-.93-.555-.303 0-7.815-4.862-10.693-6.92a460.152 460.152 0 0 0-2.218-1.576c-10.169-7.174-22.986-19.415-32.029-30.587-4.159-5.139-9.375-12.568-12.394-17.656a147.855 147.855 0 0 0-1.476-2.441 43.451 43.451 0 0 1-1.163-2.051c-.402-.752-.799-1.455-.882-1.562-.216-.281-2.067-3.8-3.082-5.86-1.171-2.376-2.969-6.158-2.969-6.247 0-.039-.297-.715-.66-1.502-2.124-4.606-4.715-11.631-7.122-19.311-.504-1.609-2.109-7.752-2.6-9.951-.629-2.816-1.494-7.084-1.623-8.008a13.718 13.718 0 0 0-.205-1.172c-.108-.393-.217-1.069-.58-3.613-.432-3.028-.453-3.184-.589-4.492a59.78 59.78 0 0 0-.284-2.247 20.113 20.113 0 0 1-.189-1.879c-.016-.443-.063-.988-.106-1.211-.043-.224-.13-1.267-.194-2.32a235.13 235.13 0 0 0-.217-3.236c-.168-2.195-.265-7.871-.142-8.339.099-.379 11.469 10.916 82.995 82.441 57.277 57.277 82.787 82.913 82.58 82.986" 27 + /> 28 + </svg>) 29 + }
+301
src/components/PollComponents.tsx
···
··· 1 + import { useAtom } from "jotai"; 2 + import * as React from "react"; 3 + 4 + import { 5 + usePollData, 6 + usePollMutationQueue, 7 + } from "~/providers/PollMutationQueueProvider"; 8 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 9 + import { renderSnack } from "~/routes/__root"; 10 + import { imgCDNAtom } from "~/utils/atoms"; 11 + import { useQueryArbitrary, useQueryConstellation, useQueryProfile } from "~/utils/useQuery"; 12 + 13 + export function PollEmbed({ did, rkey }: { did: string; rkey: string }) { 14 + const { agent } = useAuth(); 15 + const { refreshPollData } = usePollMutationQueue(); 16 + const pollUri = `at://${did}/app.reddwarf.embed.poll/${rkey}`; 17 + const { data: pollRecord, isLoading, error } = useQueryArbitrary(pollUri); 18 + 19 + const { data: voteCountsA } = useQueryConstellation({ 20 + method: "/links/count/distinct-dids", 21 + target: pollUri, 22 + collection: "app.reddwarf.poll.vote.a", 23 + path: ".subject.uri", 24 + customkey: "constellation-polls", 25 + }); 26 + 27 + const { data: voteCountsB } = useQueryConstellation({ 28 + method: "/links/count/distinct-dids", 29 + target: pollUri, 30 + collection: "app.reddwarf.poll.vote.b", 31 + path: ".subject.uri", 32 + customkey: "constellation-polls", 33 + }); 34 + 35 + const { data: voteCountsC } = useQueryConstellation({ 36 + method: "/links/count/distinct-dids", 37 + target: pollUri, 38 + collection: "app.reddwarf.poll.vote.c", 39 + path: ".subject.uri", 40 + customkey: "constellation-polls", 41 + }); 42 + 43 + const { data: voteCountsD } = useQueryConstellation({ 44 + method: "/links/count/distinct-dids", 45 + target: pollUri, 46 + collection: "app.reddwarf.poll.vote.d", 47 + path: ".subject.uri", 48 + customkey: "constellation-polls", 49 + }); 50 + 51 + const { data: votersA } = useQueryConstellation({ 52 + method: "/links", 53 + target: pollUri, 54 + collection: "app.reddwarf.poll.vote.a", 55 + path: ".subject.uri", 56 + customkey: "constellation-polls", 57 + }); 58 + const { data: votersB } = useQueryConstellation({ 59 + method: "/links", 60 + target: pollUri, 61 + collection: "app.reddwarf.poll.vote.b", 62 + path: ".subject.uri", 63 + customkey: "constellation-polls", 64 + }); 65 + const { data: votersC } = useQueryConstellation({ 66 + method: "/links", 67 + target: pollUri, 68 + collection: "app.reddwarf.poll.vote.c", 69 + path: ".subject.uri", 70 + customkey: "constellation-polls", 71 + }); 72 + const { data: votersD } = useQueryConstellation({ 73 + method: "/links", 74 + target: pollUri, 75 + collection: "app.reddwarf.poll.vote.d", 76 + path: ".subject.uri", 77 + customkey: "constellation-polls", 78 + }); 79 + 80 + const poll = { 81 + ...(pollRecord?.value ?? {}), 82 + multiple: true, 83 + } as { 84 + a: string; 85 + b: string; 86 + c?: string; 87 + d?: string; 88 + expiry?: string; 89 + multiple?: boolean; 90 + createdAt: string; 91 + }; 92 + 93 + const options = [poll.a, poll.b, poll.c, poll.d].filter(Boolean); 94 + 95 + const serverCounts = { 96 + a: parseInt((voteCountsA as any)?.total || "0"), 97 + b: parseInt((voteCountsB as any)?.total || "0"), 98 + c: parseInt((voteCountsC as any)?.total || "0"), 99 + d: parseInt((voteCountsD as any)?.total || "0"), 100 + }; 101 + 102 + const { results, totalVotes, handleVote } = usePollData( 103 + pollUri, 104 + pollRecord?.cid, 105 + !!poll.multiple, 106 + serverCounts, 107 + ); 108 + 109 + if (isLoading) { 110 + return ( 111 + <div className="animate-pulse"> 112 + <div className="flex items-center gap-2 mb-3"> 113 + <div className="h-6 w-20 bg-gray-300 dark:bg-gray-600 rounded"></div> 114 + <div className="h-6 w-32 bg-gray-300 dark:bg-gray-600 rounded"></div> 115 + </div> 116 + <div className="space-y-2"> 117 + <div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-lg"></div> 118 + <div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-lg w-3/4"></div> 119 + </div> 120 + </div> 121 + ); 122 + } 123 + 124 + if (error || !pollRecord?.value) { 125 + return <div className="text-red-500 text-sm p-2">Failed to load poll</div>; 126 + } 127 + const isExpired = false; 128 + 129 + return ( 130 + <> 131 + <div className="my-4"> 132 + <div className="mb-4 flex items-center gap-3"> 133 + <div className="flex items-center gap-1.5 rounded-lg border-gray-300 dark:border-gray-600 pl-2 pr-2.5 py-1 text-sm font-medium uppercase tracking-wide text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800"> 134 + <IconMdiGlobe /> 135 + <span>Public Poll</span> 136 + </div> 137 + 138 + <span className="text-sm font-normal text-gray-500 dark:text-gray-400 flex flex-row items-center gap-1"> 139 + {poll.multiple ? ( 140 + <IconMdiCheckboxMultipleMarked /> 141 + ) : ( 142 + <IconMdiCheckCircle /> 143 + )} 144 + <span className="md:flex hidden"> 145 + {poll.multiple 146 + ? "Select one or more options" 147 + : "Select one option"} 148 + </span> 149 + </span> 150 + 151 + <button 152 + onClick={(e) => { 153 + e.stopPropagation(); 154 + refreshPollData(pollUri); 155 + }} 156 + className="ml-auto rounded-full h-8 outline outline-gray-200 text-gray-700 dark:outline-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors px-3 py-1 text-[12px] flex items-center gap-1" 157 + title="Refresh poll data" 158 + > 159 + <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> 160 + <path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" /> 161 + </svg> 162 + Refresh 163 + </button> 164 + </div> 165 + 166 + <div className="space-y-3"> 167 + {options.map((optionText, index) => { 168 + const optionKey = ["a", "b", "c", "d"][index] as 169 + | "a" 170 + | "b" 171 + | "c" 172 + | "d"; 173 + const { topVoterDids } = results[optionKey]; 174 + const optionState = results[optionKey]; 175 + const hasVotedForOption = optionState.hasVoted; 176 + const votePercentage = 177 + totalVotes > 0 ? (optionState.count / totalVotes) * 100 : 0; 178 + 179 + const votersData = (() => { 180 + if (optionKey === "a") return votersA?.linking_records || []; 181 + if (optionKey === "b") return votersB?.linking_records || []; 182 + if (optionKey === "c") return votersC?.linking_records || []; 183 + if (optionKey === "d") return votersD?.linking_records || []; 184 + return []; 185 + })(); 186 + const topVoters = votersData 187 + .filter((v: any) => !!v.did) 188 + .slice(0, 5); 189 + 190 + return ( 191 + <div 192 + key={index} 193 + className={`group relative h-12 items-center justify-between rounded-lg border px-4 flex overflow-hidden ${ 194 + !isExpired 195 + ? hasVotedForOption 196 + ? "bg-gray-100 dark:bg-gray-950 border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-900 cursor-pointer outline-2 outline-gray-500 dark:outline-gray-400" 197 + : "bg-gray-100 dark:bg-gray-950 border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-900 cursor-pointer" 198 + : "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700" 199 + }`} 200 + onClick={(e) => { 201 + e.stopPropagation(); 202 + if (!isExpired) { 203 + handleVote(optionKey); 204 + } 205 + }} 206 + > 207 + <div 208 + className="absolute inset-y-0 left-0 bg-gray-300 dark:bg-gray-700 group-hover:bg-gray-400 dark:group-hover:bg-gray-600 transition-[width]" 209 + style={{ width: `${votePercentage}%` }} 210 + /> 211 + 212 + <span className="relative z-[2] text-sm font-medium text-gray-900 dark:text-gray-100 truncate"> 213 + {optionText} 214 + {hasVotedForOption && ( 215 + <span className="ml-2 text-gray-600 dark:text-gray-400"> 216 + {poll.multiple ? "✓" : "✓ (click to remove)"} 217 + </span> 218 + )} 219 + </span> 220 + 221 + <div className="relative z-[2] flex items-center gap-2"> 222 + {topVoterDids.length > 0 && ( 223 + <div className="flex -space-x-2"> 224 + {topVoterDids.map((did, idx) => ( 225 + <div 226 + key={did} 227 + className="w-5 h-5 rounded-full border-2 border-white dark:border-gray-900 overflow-hidden bg-gray-200" 228 + style={{ zIndex: 5 - idx }} 229 + > 230 + <PollOptionAvatar did={did} /> 231 + </div> 232 + ))} 233 + </div> 234 + )} 235 + 236 + <span className="text-sm font-medium text-gray-600 dark:text-gray-400"> 237 + {votePercentage.toFixed(0)}% 238 + </span> 239 + </div> 240 + </div> 241 + ); 242 + })} 243 + </div> 244 + 245 + <div className="mt-4 flex items-center justify-between text-sm text-gray-500 dark:text-gray-400"> 246 + <div className="flex items-center gap-2"> 247 + <IconMdiClockOutline /> 248 + <span>Never expires</span> 249 + </div> 250 + 251 + <button 252 + onClick={(e) => { 253 + e.stopPropagation(); 254 + renderSnack({ 255 + title: "Not implemented yet...", 256 + description: "Opening PDSLS", 257 + }); 258 + const pdslsUrl = `https://pdsls.dev/at://${did}/app.reddwarf.embed.poll/${rkey}#backlinks`; 259 + window.open(pdslsUrl, "_blank"); 260 + }} 261 + className="rounded-full h-10 bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors px-4 py-2 text-[14px]" 262 + > 263 + View all {totalVotes} votes 264 + </button> 265 + </div> 266 + </div> 267 + </> 268 + ); 269 + } 270 + 271 + export function PollOptionAvatar({ did }: { did: string }) { 272 + const [imgcdn] = useAtom(imgCDNAtom); 273 + const { data: profileRecord } = useQueryProfile( 274 + `at://${did}/app.bsky.actor.profile/self`, 275 + ); 276 + 277 + const avatarUrl = getAvatarUrl(profileRecord, did, imgcdn); 278 + 279 + if (!avatarUrl) { 280 + return <div className="w-full h-full bg-gray-500" />; 281 + } 282 + 283 + return ( 284 + <img 285 + src={avatarUrl} 286 + alt="voter" 287 + className="w-full h-full object-cover" 288 + onError={(e) => { 289 + const target = e.target as HTMLImageElement; 290 + target.style.display = "none"; 291 + target.parentElement!.style.backgroundColor = "#6b7280"; 292 + }} 293 + /> 294 + ); 295 + } 296 + 297 + function getAvatarUrl(opProfile: any, did: string, cdn: string) { 298 + const link = opProfile?.value?.avatar?.ref?.["$link"]; 299 + if (!link) return null; 300 + return `https://${cdn}/img/avatar/plain/${did}/${link}@jpeg`; 301 + }
+773
src/components/PostEmbeds.tsx
···
··· 1 + import { 2 + AppBskyEmbedDefs, 3 + AppBskyEmbedExternal, 4 + AppBskyEmbedImages, 5 + AppBskyEmbedRecord, 6 + AppBskyEmbedRecordWithMedia, 7 + AppBskyEmbedVideo, 8 + AppBskyFeedDefs, 9 + AppBskyFeedPost, 10 + AppBskyGraphDefs, 11 + AtUri, 12 + ModerationDecision, 13 + } from "@atproto/api"; 14 + import * as React from "react"; 15 + import { useEffect, useRef, useState } from "react"; 16 + import ReactPlayer from "react-player"; 17 + 18 + import { FeedItemRenderAturiLoader } from "~/routes/profile.$did"; 19 + import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 20 + 21 + import { PollEmbed } from "./PollComponents"; 22 + import { UniversalPostRenderer } from "./UniversalPostRenderer"; 23 + 24 + type Embed = 25 + | AppBskyEmbedRecord.View 26 + | AppBskyEmbedImages.View 27 + | AppBskyEmbedVideo.View 28 + | AppBskyEmbedExternal.View 29 + | AppBskyEmbedRecordWithMedia.View 30 + | { $type: string; [k: string]: unknown }; 31 + 32 + enum PostEmbedViewContext { 33 + ThreadHighlighted = "ThreadHighlighted", 34 + Feed = "Feed", 35 + FeedEmbedRecordWithMedia = "FeedEmbedRecordWithMedia", 36 + } 37 + 38 + const stopgap = { 39 + display: "flex", 40 + justifyContent: "center", 41 + padding: "32px 12px", 42 + borderRadius: 12, 43 + border: "1px solid rgba(161, 170, 174, 0.38)", 44 + }; 45 + 46 + export function PostEmbeds({ 47 + embed, 48 + moderation, 49 + onOpen, 50 + allowNestedQuotes, 51 + viewContext, 52 + salt, 53 + navigate, 54 + postid, 55 + nopics, 56 + lightboxCallback, 57 + constellationLinks, 58 + }: { 59 + embed?: Embed; 60 + moderation?: ModerationDecision; 61 + onOpen?: () => void; 62 + allowNestedQuotes?: boolean; 63 + viewContext?: PostEmbedViewContext; 64 + salt: string; 65 + navigate: (_: any) => void; 66 + postid?: { did: string; rkey: string }; 67 + nopics?: boolean; 68 + lightboxCallback?: (d: LightboxProps) => void; 69 + constellationLinks?: any; 70 + }) { 71 + function setLightboxIndex(number: number) { 72 + navigate({ 73 + to: "/profile/$did/post/$rkey/image/$i", 74 + params: { 75 + did: postid?.did, 76 + rkey: postid?.rkey, 77 + i: number.toString(), 78 + }, 79 + }); 80 + } 81 + 82 + if ( 83 + AppBskyEmbedRecordWithMedia.isView(embed) && 84 + AppBskyEmbedRecord.isViewRecord(embed.record.record) && 85 + AppBskyFeedPost.isRecord(embed.record.record.value) 86 + ) { 87 + const post: AppBskyFeedDefs.PostView = { 88 + $type: "app.bsky.feed.defs#postView", 89 + uri: embed.record.record.uri, 90 + cid: embed.record.record.cid, 91 + author: embed.record.record.author, 92 + record: embed.record.record.value as { [key: string]: unknown }, 93 + embed: embed.record.record.embeds 94 + ? embed.record.record.embeds?.[0] 95 + : undefined, 96 + replyCount: embed.record.record.replyCount, 97 + repostCount: embed.record.record.repostCount, 98 + likeCount: embed.record.record.likeCount, 99 + quoteCount: embed.record.record.quoteCount, 100 + indexedAt: embed.record.record.indexedAt, 101 + labels: embed.record.record.labels, 102 + }; 103 + 104 + return ( 105 + <div> 106 + <PostEmbeds 107 + embed={embed.media} 108 + moderation={moderation} 109 + onOpen={onOpen} 110 + viewContext={viewContext} 111 + salt={salt} 112 + navigate={navigate} 113 + postid={postid} 114 + nopics={nopics} 115 + lightboxCallback={lightboxCallback} 116 + constellationLinks={constellationLinks} 117 + /> 118 + <div style={{ height: 12 }} /> 119 + <div 120 + style={{ 121 + display: "flex", 122 + flexDirection: "column", 123 + borderRadius: 12, 124 + overflow: "hidden", 125 + }} 126 + className="shadow border border-gray-200 dark:border-gray-800 was7" 127 + > 128 + <UniversalPostRenderer 129 + post={post} 130 + isQuote 131 + salt={salt} 132 + onPostClick={(e) => { 133 + e.stopPropagation(); 134 + const parsed = new AtUri(post.uri); 135 + if (parsed) { 136 + navigate({ 137 + to: "/profile/$did/post/$rkey", 138 + params: { did: parsed.host, rkey: parsed.rkey }, 139 + }); 140 + } 141 + }} 142 + depth={1} 143 + /> 144 + </div> 145 + </div> 146 + ); 147 + } 148 + 149 + if (AppBskyEmbedRecord.isView(embed)) { 150 + const reallybaduri = (embed?.record as any)?.uri as string | undefined; 151 + const reallybadaturi = reallybaduri ? new AtUri(reallybaduri) : undefined; 152 + 153 + if (AppBskyFeedDefs.isGeneratorView(embed.record)) { 154 + return <div style={stopgap}>feedgen placeholder</div>; 155 + } else if ( 156 + !!reallybaduri && 157 + !!reallybadaturi && 158 + reallybadaturi.collection === "app.bsky.feed.generator" 159 + ) { 160 + return ( 161 + <div className="rounded-xl border"> 162 + <FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder /> 163 + </div> 164 + ); 165 + } 166 + 167 + if (AppBskyGraphDefs.isListView(embed.record)) { 168 + return <div style={stopgap}>list placeholder</div>; 169 + } else if ( 170 + !!reallybaduri && 171 + !!reallybadaturi && 172 + reallybadaturi.collection === "app.bsky.graph.list" 173 + ) { 174 + return ( 175 + <div className="rounded-xl border"> 176 + <FeedItemRenderAturiLoader 177 + aturi={reallybaduri} 178 + disableBottomBorder 179 + listmode 180 + disablePropagation 181 + /> 182 + </div> 183 + ); 184 + } 185 + 186 + if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) { 187 + return <div style={stopgap}>starter pack card placeholder</div>; 188 + } else if ( 189 + !!reallybaduri && 190 + !!reallybadaturi && 191 + reallybadaturi.collection === "app.bsky.graph.starterpack" 192 + ) { 193 + return ( 194 + <div className="rounded-xl border"> 195 + <FeedItemRenderAturiLoader 196 + aturi={reallybaduri} 197 + disableBottomBorder 198 + listmode 199 + disablePropagation 200 + /> 201 + </div> 202 + ); 203 + } 204 + 205 + if ( 206 + AppBskyEmbedRecord.isViewRecord(embed.record) && 207 + AppBskyFeedPost.isRecord(embed.record.value) 208 + ) { 209 + const post: AppBskyFeedDefs.PostView = { 210 + $type: "app.bsky.feed.defs#postView", 211 + uri: embed.record.uri, 212 + cid: embed.record.cid, 213 + author: embed.record.author, 214 + record: embed.record.value as { [key: string]: unknown }, 215 + embed: embed.record.embeds ? embed.record.embeds?.[0] : undefined, 216 + replyCount: embed.record.replyCount, 217 + repostCount: embed.record.repostCount, 218 + likeCount: embed.record.likeCount, 219 + quoteCount: embed.record.quoteCount, 220 + indexedAt: embed.record.indexedAt, 221 + labels: embed.record.labels, 222 + }; 223 + 224 + return ( 225 + <div 226 + style={{ 227 + display: "flex", 228 + flexDirection: "column", 229 + borderRadius: 12, 230 + overflow: "hidden", 231 + }} 232 + className="shadow border border-gray-200 dark:border-gray-800 was7" 233 + > 234 + <UniversalPostRenderer 235 + post={post} 236 + isQuote 237 + salt={salt} 238 + onPostClick={(e) => { 239 + e.stopPropagation(); 240 + const parsed = new AtUri(post.uri); 241 + if (parsed) { 242 + navigate({ 243 + to: "/profile/$did/post/$rkey", 244 + params: { did: parsed.host, rkey: parsed.rkey }, 245 + }); 246 + } 247 + }} 248 + depth={1} 249 + /> 250 + </div> 251 + ); 252 + } else { 253 + console.log("what the hell is a ", embed); 254 + return <>sorry</>; 255 + } 256 + } 257 + 258 + if (AppBskyEmbedImages.isView(embed)) { 259 + const { images } = embed; 260 + 261 + const lightboxImages = images.map((img) => ({ 262 + src: img.fullsize, 263 + alt: img.alt, 264 + })); 265 + 266 + if (lightboxCallback) { 267 + lightboxCallback({ images: lightboxImages }); 268 + } 269 + 270 + if (nopics) return; 271 + 272 + if (images.length > 0) { 273 + if (images.length === 1) { 274 + const image = images[0]; 275 + return ( 276 + <div style={{ marginTop: 0 }}> 277 + <div 278 + style={{ 279 + position: "relative", 280 + width: "100%", 281 + aspectRatio: image.aspectRatio 282 + ? (() => { 283 + const { width, height } = image.aspectRatio; 284 + const ratio = width / height; 285 + return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`; 286 + })() 287 + : "1 / 1", 288 + borderRadius: 12, 289 + overflow: "hidden", 290 + }} 291 + className="border border-gray-200 dark:border-gray-800 was7 bg-gray-200 dark:bg-gray-900" 292 + > 293 + <img 294 + src={image.fullsize} 295 + alt={image.alt} 296 + style={{ 297 + width: "100%", 298 + height: "100%", 299 + objectFit: "contain", 300 + }} 301 + onClick={(e) => { 302 + e.stopPropagation(); 303 + setLightboxIndex(0); 304 + }} 305 + /> 306 + </div> 307 + </div> 308 + ); 309 + } 310 + 311 + if (images.length === 2) { 312 + return ( 313 + <div 314 + style={{ 315 + display: "flex", 316 + gap: 4, 317 + marginTop: 0, 318 + width: "100%", 319 + borderRadius: 12, 320 + overflow: "hidden", 321 + }} 322 + className="border border-gray-200 dark:border-gray-800 was7" 323 + > 324 + {images.map((img, i) => ( 325 + <div 326 + key={i} 327 + style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }} 328 + > 329 + <img 330 + src={img.fullsize} 331 + alt={img.alt} 332 + style={{ 333 + width: "100%", 334 + height: "100%", 335 + objectFit: "cover", 336 + borderRadius: i === 0 ? "12px 0 0 12px" : "0 12px 12px 0", 337 + }} 338 + onClick={(e) => { 339 + e.stopPropagation(); 340 + setLightboxIndex(i); 341 + }} 342 + /> 343 + </div> 344 + ))} 345 + </div> 346 + ); 347 + } 348 + 349 + if (images.length === 3) { 350 + return ( 351 + <div 352 + style={{ 353 + display: "flex", 354 + gap: 4, 355 + marginTop: 0, 356 + width: "100%", 357 + borderRadius: 12, 358 + overflow: "hidden", 359 + }} 360 + className="border border-gray-200 dark:border-gray-800 was7" 361 + > 362 + <div 363 + style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }} 364 + > 365 + <img 366 + src={images[0].fullsize} 367 + alt={images[0].alt} 368 + style={{ 369 + width: "100%", 370 + height: "100%", 371 + objectFit: "cover", 372 + borderRadius: "12px 0 0 12px", 373 + }} 374 + onClick={(e) => { 375 + e.stopPropagation(); 376 + setLightboxIndex(0); 377 + }} 378 + /> 379 + </div> 380 + <div 381 + style={{ 382 + flex: 1, 383 + display: "flex", 384 + flexDirection: "column", 385 + gap: 4, 386 + }} 387 + > 388 + {[1, 2].map((i) => ( 389 + <div 390 + key={i} 391 + style={{ 392 + flex: 1, 393 + aspectRatio: "2 / 1", 394 + position: "relative", 395 + }} 396 + > 397 + <img 398 + src={images[i].fullsize} 399 + alt={images[i].alt} 400 + style={{ 401 + width: "100%", 402 + height: "100%", 403 + objectFit: "cover", 404 + borderRadius: i === 1 ? "0 12px 0 0" : "0 0 12px 0", 405 + }} 406 + onClick={(e) => { 407 + e.stopPropagation(); 408 + setLightboxIndex(i + 1); 409 + }} 410 + /> 411 + </div> 412 + ))} 413 + </div> 414 + </div> 415 + ); 416 + } 417 + 418 + if (images.length === 4) { 419 + return ( 420 + <div 421 + style={{ 422 + display: "grid", 423 + gridTemplateColumns: "1fr 1fr", 424 + gridTemplateRows: "1fr 1fr", 425 + gap: 4, 426 + marginTop: 0, 427 + width: "100%", 428 + borderRadius: 12, 429 + overflow: "hidden", 430 + }} 431 + className="border border-gray-200 dark:border-gray-800 was7" 432 + > 433 + {images.map((img, i) => ( 434 + <div 435 + key={i} 436 + style={{ 437 + width: "100%", 438 + height: "100%", 439 + aspectRatio: "3 / 2", 440 + position: "relative", 441 + }} 442 + > 443 + <img 444 + src={img.fullsize} 445 + alt={img.alt} 446 + style={{ 447 + width: "100%", 448 + height: "100%", 449 + objectFit: "cover", 450 + borderRadius: 451 + i === 0 452 + ? "12px 0 0 0" 453 + : i === 1 454 + ? "0 12px 0 0" 455 + : i === 2 456 + ? "0 0 0 12px" 457 + : "0 0 12px 0", 458 + }} 459 + onClick={(e) => { 460 + e.stopPropagation(); 461 + setLightboxIndex(i); 462 + }} 463 + /> 464 + </div> 465 + ))} 466 + </div> 467 + ); 468 + } 469 + 470 + return <div style={stopgap}>image count more than one placeholder</div>; 471 + } 472 + } 473 + 474 + if (AppBskyEmbedExternal.isView(embed)) { 475 + const pollLinks = constellationLinks?.links?.["app.reddwarf.embed.poll"]; 476 + const hasPollLink = pollLinks && Object.keys(pollLinks).length > 0; 477 + 478 + if (hasPollLink && postid) { 479 + return <PollEmbed did={postid.did} rkey={postid.rkey} />; 480 + } 481 + 482 + const link = embed.external; 483 + return ( 484 + <ExternalLinkEmbed link={link} onOpen={onOpen} style={{ marginTop: 0 }} /> 485 + ); 486 + } 487 + 488 + if (AppBskyEmbedVideo.isView(embed)) { 489 + if (nopics) return; 490 + const playlist = embed.playlist; 491 + return ( 492 + <SmartHLSPlayer 493 + url={playlist} 494 + thumbnail={embed.thumbnail} 495 + aspect={embed.aspectRatio} 496 + /> 497 + ); 498 + } 499 + 500 + return <div />; 501 + } 502 + 503 + export function ExternalLinkEmbed({ 504 + link, 505 + onOpen, 506 + style, 507 + }: { 508 + link: AppBskyEmbedExternal.ViewExternal; 509 + onOpen?: () => void; 510 + style?: React.CSSProperties; 511 + }) { 512 + const { uri, title, description, thumb } = link; 513 + const thumbAspectRatio = 1.91; 514 + 515 + const titleStyle = { 516 + fontSize: 16, 517 + fontWeight: 700, 518 + marginBottom: 4, 519 + wordBreak: "break-word", 520 + textAlign: "left", 521 + maxHeight: "4em", 522 + display: "-webkit-box", 523 + WebkitBoxOrient: "vertical", 524 + overflow: "hidden", 525 + WebkitLineClamp: 2, 526 + }; 527 + 528 + const descriptionStyle = { 529 + fontSize: 14, 530 + marginBottom: 8, 531 + wordBreak: "break-word", 532 + textAlign: "left", 533 + maxHeight: "5em", 534 + display: "-webkit-box", 535 + WebkitBoxOrient: "vertical", 536 + overflow: "hidden", 537 + WebkitLineClamp: 3, 538 + }; 539 + 540 + const linkStyle = { 541 + textDecoration: "none", 542 + wordBreak: "break-all", 543 + textAlign: "left", 544 + }; 545 + 546 + const containerStyle = { 547 + display: "flex", 548 + flexDirection: "column", 549 + borderRadius: 12, 550 + maxWidth: "100%", 551 + overflow: "hidden", 552 + ...style, 553 + }; 554 + 555 + return ( 556 + <a 557 + href={uri} 558 + target="_blank" 559 + rel="noopener noreferrer" 560 + onClick={(e) => { 561 + e.stopPropagation(); 562 + if (onOpen) onOpen(); 563 + }} 564 + style={linkStyle as React.CSSProperties} 565 + className="text-gray-500 dark:text-gray-400" 566 + > 567 + <div 568 + style={containerStyle as React.CSSProperties} 569 + className="border border-gray-200 dark:border-gray-800 was7" 570 + > 571 + {thumb && ( 572 + <div 573 + style={{ 574 + position: "relative", 575 + width: "100%", 576 + aspectRatio: thumbAspectRatio, 577 + overflow: "hidden", 578 + borderTopLeftRadius: 12, 579 + borderTopRightRadius: 12, 580 + marginBottom: 8, 581 + }} 582 + className="border-b border-gray-200 dark:border-gray-800 was7" 583 + > 584 + <img 585 + src={thumb} 586 + alt={description} 587 + style={{ 588 + position: "absolute", 589 + top: 0, 590 + left: 0, 591 + width: "100%", 592 + height: "100%", 593 + objectFit: "cover", 594 + }} 595 + /> 596 + </div> 597 + )} 598 + <div 599 + style={{ 600 + paddingBottom: 12, 601 + paddingLeft: 12, 602 + paddingRight: 12, 603 + paddingTop: thumb ? 0 : 12, 604 + }} 605 + > 606 + <div 607 + style={titleStyle as React.CSSProperties} 608 + className="text-gray-900 dark:text-gray-100" 609 + > 610 + {title} 611 + </div> 612 + <div 613 + style={descriptionStyle as React.CSSProperties} 614 + className="text-gray-500 dark:text-gray-400" 615 + > 616 + {description} 617 + </div> 618 + <div 619 + style={{ 620 + height: 1, 621 + marginBottom: 8, 622 + }} 623 + className="bg-gray-200 dark:bg-gray-700" 624 + /> 625 + <div 626 + style={{ 627 + display: "flex", 628 + alignItems: "center", 629 + gap: 4, 630 + }} 631 + > 632 + <IconMdiGlobe /> 633 + <span 634 + style={{ 635 + fontSize: 12, 636 + }} 637 + className="text-gray-500 dark:text-gray-400" 638 + > 639 + {getDomain(uri)} 640 + </span> 641 + </div> 642 + </div> 643 + </div> 644 + </a> 645 + ); 646 + } 647 + 648 + export const SmartHLSPlayer = ({ 649 + url, 650 + thumbnail, 651 + aspect, 652 + }: { 653 + url: string; 654 + thumbnail?: string; 655 + aspect?: AppBskyEmbedDefs.AspectRatio; 656 + }) => { 657 + const [playing, setPlaying] = useState(false); 658 + const containerRef = useRef(null); 659 + 660 + useEffect(() => { 661 + const observer = new IntersectionObserver( 662 + ([entry]) => { 663 + if (!entry.isIntersecting && playing) { 664 + setPlaying(false); 665 + } 666 + }, 667 + { 668 + root: null, 669 + threshold: 0.25, 670 + }, 671 + ); 672 + 673 + if (containerRef.current) { 674 + observer.observe(containerRef.current); 675 + } 676 + 677 + return () => { 678 + if (containerRef.current) { 679 + observer.unobserve(containerRef.current); 680 + } 681 + }; 682 + }, [playing]); 683 + 684 + return ( 685 + <div 686 + ref={containerRef} 687 + style={{ 688 + position: "relative", 689 + width: "100%", 690 + maxWidth: 640, 691 + cursor: "pointer", 692 + }} 693 + > 694 + {!playing && ( 695 + <> 696 + <img 697 + src={thumbnail} 698 + alt="Video thumbnail" 699 + style={{ 700 + width: "100%", 701 + display: "block", 702 + aspectRatio: aspect ? aspect?.width / aspect?.height : 16 / 9, 703 + borderRadius: 12, 704 + }} 705 + className="border border-gray-200 dark:border-gray-800 was7" 706 + onClick={async (e) => { 707 + e.stopPropagation(); 708 + setPlaying(true); 709 + }} 710 + /> 711 + <div 712 + onClick={async (e) => { 713 + e.stopPropagation(); 714 + setPlaying(true); 715 + }} 716 + style={{ 717 + position: "absolute", 718 + top: "50%", 719 + left: "50%", 720 + transform: "translate(-50%, -50%)", 721 + color: "white", 722 + pointerEvents: "none", 723 + userSelect: "none", 724 + }} 725 + className="text-shadow-md" 726 + > 727 + <IconMdiPlayCircle /> 728 + </div> 729 + </> 730 + )} 731 + {playing && ( 732 + <div 733 + style={{ 734 + position: "relative", 735 + width: "100%", 736 + borderRadius: 12, 737 + overflow: "hidden", 738 + paddingTop: `${ 739 + 100 / (aspect ? aspect.width / aspect.height : 16 / 9) 740 + }%`, 741 + }} 742 + className="border border-gray-200 dark:border-gray-800 was7" 743 + > 744 + <ReactPlayer 745 + src={url} 746 + playing={true} 747 + controls={true} 748 + width="100%" 749 + height="100%" 750 + style={{ position: "absolute", top: 0, left: 0 }} 751 + /> 752 + </div> 753 + )} 754 + </div> 755 + ); 756 + }; 757 + 758 + function getDomain(url: string) { 759 + try { 760 + const { hostname } = new URL(url); 761 + return hostname; 762 + } catch (e) { 763 + if (!url.startsWith("http")) { 764 + try { 765 + const { hostname } = new URL("http://" + url); 766 + return hostname; 767 + } catch { 768 + return null; 769 + } 770 + } 771 + return null; 772 + } 773 + }
-6
src/components/Star.tsx
··· 1 - import type { SVGProps } from 'react'; 2 - import React from 'react'; 3 - 4 - export function FluentEmojiHighContrastGlowingStar(props: SVGProps<SVGSVGElement>) { 5 - return (<svg xmlns="http://www.w3.org/2000/svg" width={32} height={32} viewBox="0 0 32 32" {...props}><g fill="currentColor"><path d="m28.979 17.003l-3.108.214c-.834.06-1.178 1.079-.542 1.608l2.388 1.955c.521.428 1.314.204 1.523-.428l.709-2.127c.219-.632-.292-1.273-.97-1.222M21.75 2.691l-.72 2.9c-.2.78.66 1.41 1.34.98l2.54-1.58c.55-.34.58-1.14.05-1.52l-1.78-1.29a.912.912 0 0 0-1.43.51M6.43 4.995l2.53 1.58c.68.43 1.54-.19 1.35-.98l-.72-2.9a.92.92 0 0 0-1.43-.52l-1.78 1.29c-.53.4-.5 1.19.05 1.53M4.185 20.713l2.29-1.92c.62-.52.29-1.53-.51-1.58l-2.98-.21a.92.92 0 0 0-.94 1.2l.68 2.09c.2.62.97.84 1.46.42m13.61 7.292l-1.12-2.77c-.3-.75-1.36-.75-1.66 0l-1.12 2.77c-.24.6.2 1.26.85 1.26h2.2a.92.92 0 0 0 .85-1.26"></path><path d="m17.565 3.324l1.726 3.72c.326.694.967 1.18 1.717 1.29l4.056.624c1.835.278 2.575 2.53 1.293 3.859L23.268 16a2.28 2.28 0 0 0-.612 1.964l.71 4.374c.307 1.885-1.687 3.293-3.354 2.37l-3.405-1.894a2.25 2.25 0 0 0-2.21 0l-3.404 1.895c-1.668.922-3.661-.486-3.355-2.37l.71-4.375A2.28 2.28 0 0 0 7.736 16l-3.088-3.184c-1.293-1.34-.543-3.581 1.293-3.859l4.055-.625a2.3 2.3 0 0 0 1.717-1.29l1.727-3.719c.819-1.765 3.306-1.765 4.124 0"></path></g></svg>); 6 - }
···
+51 -2545
src/components/UniversalPostRenderer.tsx
··· 1 import * as ATPAPI from "@atproto/api"; 2 import { useNavigate } from "@tanstack/react-router"; 3 import DOMPurify from "dompurify"; 4 import { useAtom } from "jotai"; 5 import { DropdownMenu } from "radix-ui"; 6 import { HoverCard } from "radix-ui"; 7 import * as React from "react"; 8 - import { type SVGProps } from "react"; 9 10 import { 11 composerAtom, 12 constellationURLAtom, ··· 14 enableWafrnTextAtom, 15 imgCDNAtom, 16 } from "~/utils/atoms"; 17 import { useHydratedEmbed } from "~/utils/useHydrated"; 18 import { 19 - useQueryArbitrary, 20 useQueryConstellation, 21 useQueryIdentity, 22 useQueryPost, ··· 24 yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks, 25 } from "~/utils/useQuery"; 26 27 - function asTyped<T extends { $type: string }>(obj: T): $Typed<T> { 28 - return obj as $Typed<T>; 29 - } 30 - 31 - export const CACHE_TIMEOUT = 5 * 60 * 1000; 32 - const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour 33 34 export interface UniversalPostRendererATURILoaderProps { 35 atUri: string; ··· 53 filterMustBeReply?: boolean; 54 } 55 56 - // export async function cachedGetRecord({ 57 - // atUri, 58 - // cacheTimeout = CACHE_TIMEOUT, 59 - // get, 60 - // set, 61 - // }: { 62 - // atUri: string; 63 - // //resolved: { pdsUrl: string; did: string } | null | undefined; 64 - // cacheTimeout?: number; 65 - // get: (key: string) => any; 66 - // set: (key: string, value: string) => void; 67 - // }): Promise<any> { 68 - // const cacheKey = `record:${atUri}`; 69 - // const cached = get(cacheKey); 70 - // const now = Date.now(); 71 - // if ( 72 - // cached && 73 - // cached.value && 74 - // cached.time && 75 - // now - cached.time < cacheTimeout 76 - // ) { 77 - // try { 78 - // return JSON.parse(cached.value); 79 - // } catch { 80 - // // fall through to fetch 81 - // } 82 - // } 83 - // const parsed = parseAtUri(atUri); 84 - // if (!parsed) return null; 85 - // const resolved = await cachedResolveIdentity({ 86 - // didOrHandle: parsed.did, 87 - // get, 88 - // set, 89 - // }); 90 - // if (!resolved?.pdsUrl || !resolved?.did) 91 - // throw new Error("Missing resolved PDS info"); 92 - 93 - // if (!parsed) throw new Error("Invalid atUri"); 94 - // const { collection, rkey } = parsed; 95 - // const url = `${ 96 - // resolved.pdsUrl 97 - // }/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent( 98 - // resolved.did, 99 - // )}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent( 100 - // rkey, 101 - // )}`; 102 - // const res = await fetch(url); 103 - // if (!res.ok) throw new Error("Failed to fetch base record"); 104 - // const data = await res.json(); 105 - // set(cacheKey, JSON.stringify(data)); 106 - // return data; 107 - // } 108 - 109 - // export async function cachedResolveIdentity({ 110 - // didOrHandle, 111 - // cacheTimeout = HANDLE_DID_CACHE_TIMEOUT, 112 - // get, 113 - // set, 114 - // }: { 115 - // didOrHandle: string; 116 - // cacheTimeout?: number; 117 - // get: (key: string) => any; 118 - // set: (key: string, value: string) => void; 119 - // }): Promise<any> { 120 - // const isDidInput = didOrHandle.startsWith("did:"); 121 - // const cacheKey = `handleDid:${didOrHandle}`; 122 - // const now = Date.now(); 123 - // const cached = get(cacheKey); 124 - // if ( 125 - // cached && 126 - // cached.value && 127 - // cached.time && 128 - // now - cached.time < cacheTimeout 129 - // ) { 130 - // try { 131 - // return JSON.parse(cached.value); 132 - // } catch {} 133 - // } 134 - // const url = `https://free-fly-24.deno.dev/?${ 135 - // isDidInput 136 - // ? `did=${encodeURIComponent(didOrHandle)}` 137 - // : `handle=${encodeURIComponent(didOrHandle)}` 138 - // }`; 139 - // const res = await fetch(url); 140 - // if (!res.ok) throw new Error("Failed to resolve handle/did"); 141 - // const data = await res.json(); 142 - // set(cacheKey, JSON.stringify(data)); 143 - // if (!isDidInput && data.did) { 144 - // set(`handleDid:${data.did}`, JSON.stringify(data)); 145 - // } 146 - // return data; 147 - // } 148 - 149 export function UniversalPostRendererATURILoader({ 150 atUri, 151 onConstellation, ··· 167 filterMustHaveMedia, 168 filterMustBeReply, 169 }: UniversalPostRendererATURILoaderProps) { 170 - // todo remove this once tree rendering is implemented, use a prop like isTree 171 const TEMPLINEAR = true; 172 - // /*mass comment*/ console.log("atUri", atUri); 173 - //const { get, set } = usePersistentStore(); 174 - //const [record, setRecord] = React.useState<any>(null); 175 - //const [links, setLinks] = React.useState<any>(null); 176 - //const [error, setError] = React.useState<string | null>(null); 177 - //const [cacheTime, setCacheTime] = React.useState<number | null>(null); 178 - //const [resolved, setResolved] = React.useState<any>(null); // { did, pdsUrl, bskyPds, handle } 179 - //const [opProfile, setOpProfile] = React.useState<any>(null); 180 - // const [opProfileCacheTime, setOpProfileCacheTime] = React.useState< 181 - // number | null 182 - // >(null); 183 - //const router = useRouter(); 184 - 185 - //const parsed = React.useMemo(() => parseAtUri(atUri), [atUri]); 186 const parsed = new AtUri(atUri); 187 const did = parsed?.host; 188 const rkey = parsed?.rkey; 189 - // /*mass comment*/ console.log("did", did); 190 - // /*mass comment*/ console.log("rkey", rkey); 191 - 192 - // React.useEffect(() => { 193 - // const checkCache = async () => { 194 - // const postUri = atUri; 195 - // const cacheKey = `record:${postUri}`; 196 - // const cached = await get(cacheKey); 197 - // const now = Date.now(); 198 - // // /*mass comment*/ console.log( 199 - // "UniversalPostRenderer checking cache for", 200 - // cacheKey, 201 - // "cached:", 202 - // !!cached, 203 - // ); 204 - // if ( 205 - // cached && 206 - // cached.value && 207 - // cached.time && 208 - // now - cached.time < CACHE_TIMEOUT 209 - // ) { 210 - // try { 211 - // // /*mass comment*/ console.log("UniversalPostRenderer found cached data for", cacheKey); 212 - // setRecord(JSON.parse(cached.value)); 213 - // } catch { 214 - // setRecord(null); 215 - // } 216 - // } 217 - // }; 218 - // checkCache(); 219 - // }, [atUri, get]); 220 221 const { 222 data: postQuery, 223 isLoading: isPostLoading, 224 isError: isPostError, 225 } = useQueryPost(atUri); 226 - //const record = postQuery?.value; 227 - 228 - // React.useEffect(() => { 229 - // if (!did || record) return; 230 - // (async () => { 231 - // try { 232 - // const resolvedData = await cachedResolveIdentity({ 233 - // didOrHandle: did, 234 - // get, 235 - // set, 236 - // }); 237 - // setResolved(resolvedData); 238 - // } catch (e: any) { 239 - // //setError("Failed to resolve handle/did: " + e?.message); 240 - // } 241 - // })(); 242 - // }, [did, get, set, record]); 243 244 const { data: resolved } = useQueryIdentity(did || ""); 245 246 - // React.useEffect(() => { 247 - // if (!resolved || !resolved.pdsUrl || !resolved.did || !rkey || record) 248 - // return; 249 - // let ignore = false; 250 - // (async () => { 251 - // try { 252 - // const data = await cachedGetRecord({ 253 - // atUri, 254 - // get, 255 - // set, 256 - // }); 257 - // if (!ignore) setRecord(data); 258 - // } catch (e: any) { 259 - // //if (!ignore) setError("Failed to fetch base record: " + e?.message); 260 - // } 261 - // })(); 262 - // return () => { 263 - // ignore = true; 264 - // }; 265 - // }, [resolved, rkey, atUri, record]); 266 - 267 - // React.useEffect(() => { 268 - // if (!resolved || !resolved.did || !rkey) return; 269 - // const fetchLinks = async () => { 270 - // const postUri = atUri; 271 - // const cacheKey = `constellation:${postUri}`; 272 - // const cached = await get(cacheKey); 273 - // const now = Date.now(); 274 - // if ( 275 - // cached && 276 - // cached.value && 277 - // cached.time && 278 - // now - cached.time < CACHE_TIMEOUT 279 - // ) { 280 - // try { 281 - // const data = JSON.parse(cached.value); 282 - // setLinks(data); 283 - // if (onConstellation) onConstellation(data); 284 - // } catch { 285 - // setLinks(null); 286 - // } 287 - // //setCacheTime(cached.time); 288 - // return; 289 - // } 290 - // try { 291 - // const url = `https://constellation.microcosm.blue/links/all?target=${encodeURIComponent( 292 - // atUri, 293 - // )}`; 294 - // const res = await fetch(url); 295 - // if (!res.ok) throw new Error("Failed to fetch constellation links"); 296 - // const data = await res.json(); 297 - // setLinks(data); 298 - // //setCacheTime(now); 299 - // set(cacheKey, JSON.stringify(data)); 300 - // if (onConstellation) onConstellation(data); 301 - // } catch (e: any) { 302 - // //setError("Failed to fetch constellation links: " + e?.message); 303 - // } 304 - // }; 305 - // fetchLinks(); 306 - // }, [resolved, rkey, get, set, atUri, onConstellation]); 307 - 308 const { data: links } = useQueryConstellation({ 309 method: "/links/all", 310 target: atUri, 311 }); 312 313 - // React.useEffect(() => { 314 - // if (!record || !resolved || !resolved.did) return; 315 - // const fetchOpProfile = async () => { 316 - // const opDid = resolved.did; 317 - // const postUri = atUri; 318 - // const cacheKey = `profile:${postUri}`; 319 - // const cached = await get(cacheKey); 320 - // const now = Date.now(); 321 - // if ( 322 - // cached && 323 - // cached.value && 324 - // cached.time && 325 - // now - cached.time < CACHE_TIMEOUT 326 - // ) { 327 - // try { 328 - // setOpProfile(JSON.parse(cached.value)); 329 - // } catch { 330 - // setOpProfile(null); 331 - // } 332 - // //setOpProfileCacheTime(cached.time); 333 - // return; 334 - // } 335 - // try { 336 - // let opResolvedRaw = await get(`handleDid:${opDid}`); 337 - // let opResolved: any = null; 338 - // if ( 339 - // opResolvedRaw && 340 - // opResolvedRaw.value && 341 - // opResolvedRaw.time && 342 - // now - opResolvedRaw.time < HANDLE_DID_CACHE_TIMEOUT 343 - // ) { 344 - // try { 345 - // opResolved = JSON.parse(opResolvedRaw.value); 346 - // } catch { 347 - // opResolved = null; 348 - // } 349 - // } else { 350 - // const url = `https://free-fly-24.deno.dev/?did=${encodeURIComponent( 351 - // opDid, 352 - // )}`; 353 - // const res = await fetch(url); 354 - // if (!res.ok) throw new Error("Failed to resolve OP did"); 355 - // opResolved = await res.json(); 356 - // set(`handleDid:${opDid}`, JSON.stringify(opResolved)); 357 - // } 358 - // if (!opResolved || !opResolved.pdsUrl) 359 - // throw new Error("OP did resolution failed or missing pdsUrl"); 360 - // const profileUrl = `${ 361 - // opResolved.pdsUrl 362 - // }/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent( 363 - // opDid, 364 - // )}&collection=app.bsky.actor.profile&rkey=self`; 365 - // const profileRes = await fetch(profileUrl); 366 - // if (!profileRes.ok) throw new Error("Failed to fetch OP profile"); 367 - // const profileData = await profileRes.json(); 368 - // setOpProfile(profileData); 369 - // //setOpProfileCacheTime(now); 370 - // set(cacheKey, JSON.stringify(profileData)); 371 - // } catch (e: any) { 372 - // //setError("Failed to fetch OP profile: " + e?.message); 373 - // } 374 - // }; 375 - // fetchOpProfile(); 376 - // }, [record, get, set, rkey, resolved, atUri]); 377 - 378 const { data: opProfile } = useQueryProfile( 379 resolved ? `at://${resolved?.did}/app.bsky.actor.profile/self` : undefined, 380 ); 381 382 - // const displayName = 383 - // opProfile?.value?.displayName || resolved?.handle || resolved?.did; 384 - // const handle = resolved?.handle ? `@${resolved.handle}` : resolved?.did; 385 - 386 - // const postText = record?.value?.text || ""; 387 - // const createdAt = record?.value?.createdAt 388 - // ? new Date(record.value.createdAt) 389 - // : null; 390 - // const langTags = record?.value?.langs || []; 391 - 392 const [likes, setLikes] = React.useState<number | null>(null); 393 const [reposts, setReposts] = React.useState<number | null>(null); 394 const [replies, setReplies] = React.useState<number | null>(null); 395 396 React.useEffect(() => { 397 - // /*mass comment*/ console.log(JSON.stringify(links, null, 2)); 398 setLikes( 399 links 400 ? links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0 ··· 412 : null, 413 ); 414 }, [links]); 415 - 416 - // const { data: repliesData } = useQueryConstellation({ 417 - // method: "/links", 418 - // target: atUri, 419 - // collection: "app.bsky.feed.post", 420 - // path: ".reply.parent.uri", 421 - // }); 422 423 const [constellationurl] = useAtom(constellationURLAtom); 424 ··· 435 enabled: !!atUri && !!maxReplies && !isQuote, 436 }); 437 438 - const { 439 - data: repliesData, 440 - // fetchNextPage, 441 - // hasNextPage, 442 - // isFetchingNextPage, 443 - } = infinitequeryresults; 444 445 - // auto-fetch all pages 446 useEffect(() => { 447 if (!maxReplies || isQuote || TEMPLINEAR) return; 448 if ( ··· 464 : [], 465 ) 466 : []; 467 - 468 - //const [oldestOpsReply, setOldestOpsReply] = useState<string | undefined>(undefined); 469 470 const { oldestOpsReply, oldestOpsReplyElseNewestNonOpsReply } = (() => { 471 if (isQuote || !replyAturis || replyAturis.length === 0 || !maxReplies) ··· 474 oldestOpsReplyElseNewestNonOpsReply: undefined, 475 }; 476 477 - const opdid = new AtUri( 478 - //postQuery?.value.reply?.root.uri ?? postQuery?.uri ?? atUri 479 - atUri, 480 - ).host; 481 482 const opReplies = replyAturis.filter( 483 (aturi) => new AtUri(aturi).host === opdid, ··· 485 486 if (opReplies.length > 0) { 487 const opreply = opReplies[opReplies.length - 1]; 488 - //setOldestOpsReply(opreply); 489 return { 490 oldestOpsReply: opreply, 491 oldestOpsReplyElseNewestNonOpsReply: opreply, ··· 498 } 499 })(); 500 501 - // const navigateToProfile = (e: React.MouseEvent) => { 502 - // e.stopPropagation(); 503 - // if (resolved?.did) { 504 - // router.navigate({ 505 - // to: "/profile/$did", 506 - // params: { did: resolved.did }, 507 - // }); 508 - // } 509 - // }; 510 if (!postQuery?.value) { 511 - // deleted post more often than a non-resolvable post 512 return <></>; 513 } 514 515 return ( 516 <> 517 - {/* <span>uprrs {maxReplies} {!!maxReplies&&!!oldestOpsReplyElseNewestNonOpsReply ? "true" : "false"}</span> */} 518 <UniversalPostRendererRawRecordShim 519 detailed={detailed} 520 postRecord={postQuery} ··· 535 : bottomReplyLine 536 } 537 topReplyLine={topReplyLine} 538 - //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} 539 bottomBorder={ 540 maxReplies && oldestOpsReplyElseNewestNonOpsReply 541 ? false ··· 545 } 546 feedviewpost={feedviewpost} 547 repostedby={repostedby} 548 - //style={{...style, background: oldestOpsReply === atUri ? "Red" : undefined}} 549 style={style} 550 ref={ref} 551 dataIndexPropPass={dataIndexPropPass} ··· 561 <> 562 {maxReplies && maxReplies === 0 && replies && replies > 0 ? ( 563 <> 564 - {/* <div>hello</div> */} 565 <MoreReplies atUri={atUri} /> 566 </> 567 ) : ( ··· 570 </> 571 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 572 <> 573 - {/* <span>hello {maxReplies}</span> */} 574 <UniversalPostRendererATURILoader 575 - //detailed={detailed} 576 atUri={oldestOpsReplyElseNewestNonOpsReply} 577 bottomReplyLine={(maxReplies ?? 0) > 0} 578 topReplyLine={ ··· 622 opacity: 0.5, 623 }} 624 className="dark:bg-[repeating-linear-gradient(to_bottom,var(--color-gray-500)_0,var(--color-gray-400)_4px,transparent_4px,transparent_8px)]" 625 - //className="border-gray-400 dark:border-gray-500" 626 /> 627 </div> 628 ··· 692 filterMustHaveMedia?: boolean; 693 filterMustBeReply?: boolean; 694 }) { 695 - // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`); 696 const navigate = useNavigate(); 697 698 - //const { get, set } = usePersistentStore(); 699 - // const [hydratedEmbed, setHydratedEmbed] = useState<any>(undefined); 700 - 701 - // useEffect(() => { 702 - // const run = async () => { 703 - // if (!postRecord?.value?.embed) return; 704 - // const embed = postRecord?.value?.embed; 705 - // if (!embed || !embed.$type) { 706 - // setHydratedEmbed(undefined); 707 - // return; 708 - // } 709 - 710 - // try { 711 - // let result: any; 712 - 713 - // if (embed?.$type === "app.bsky.embed.recordWithMedia") { 714 - // const mediaEmbed = embed.media; 715 - 716 - // let hydratedMedia; 717 - // if (mediaEmbed?.$type === "app.bsky.embed.images") { 718 - // hydratedMedia = hydrateEmbedImages(mediaEmbed, resolved?.did); 719 - // } else if (mediaEmbed?.$type === "app.bsky.embed.external") { 720 - // hydratedMedia = hydrateEmbedExternal(mediaEmbed, resolved?.did); 721 - // } else if (mediaEmbed?.$type === "app.bsky.embed.video") { 722 - // hydratedMedia = hydrateEmbedVideo(mediaEmbed, resolved?.did); 723 - // } else { 724 - // throw new Error("idiot"); 725 - // } 726 - // if (!hydratedMedia) throw new Error("idiot"); 727 - 728 - // // hydrate the outer recordWithMedia now using the hydrated media 729 - // result = await hydrateEmbedRecordWithMedia( 730 - // embed, 731 - // resolved?.did, 732 - // hydratedMedia, 733 - // get, 734 - // set, 735 - // ); 736 - // } else { 737 - // const hydrated = 738 - // embed?.$type === "app.bsky.embed.images" 739 - // ? hydrateEmbedImages(embed, resolved?.did) 740 - // : embed?.$type === "app.bsky.embed.external" 741 - // ? hydrateEmbedExternal(embed, resolved?.did) 742 - // : embed?.$type === "app.bsky.embed.video" 743 - // ? hydrateEmbedVideo(embed, resolved?.did) 744 - // : embed?.$type === "app.bsky.embed.record" 745 - // ? hydrateEmbedRecord(embed, resolved?.did, get, set) 746 - // : undefined; 747 - 748 - // result = hydrated instanceof Promise ? await hydrated : hydrated; 749 - // } 750 - 751 - // // /*mass comment*/ console.log( 752 - // String(result) + " hydrateEmbedRecordWithMedia hey hyeh ye", 753 - // ); 754 - // setHydratedEmbed(result); 755 - // } catch (e) { 756 - // console.error("Error hydrating embed", e); 757 - // setHydratedEmbed(undefined); 758 - // } 759 - // }; 760 - 761 - // run(); 762 - // }, [postRecord, resolved?.did]); 763 - 764 const hasEmbed = (postRecord?.value as ATPAPI.AppBskyFeedPost.Record)?.embed; 765 const hasImages = hasEmbed?.$type === "app.bsky.embed.images"; 766 const hasVideo = hasEmbed?.$type === "app.bsky.embed.video"; ··· 786 787 const [imgcdn] = useAtom(imgCDNAtom); 788 789 - const parsedaturi = new AtUri(aturi); //parseAtUri(aturi); 790 791 const fakeprofileviewbasic = React.useMemo<AppBskyActorDefs.ProfileViewBasic>( 792 () => ({ ··· 841 ], 842 ); 843 844 - //const [feedviewpostreplyhandle, setFeedviewpostreplyhandle] = useState<string | undefined>(undefined); 845 - 846 - // useEffect(() => { 847 - // if(!feedviewpost) return; 848 - // let cancelled = false; 849 - 850 - // const run = async () => { 851 - // const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent?.uri; 852 - // const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined; 853 - 854 - // if (feedviewpostreplydid) { 855 - // const opi = await cachedResolveIdentity({ 856 - // didOrHandle: feedviewpostreplydid, 857 - // get, 858 - // set, 859 - // }); 860 - 861 - // if (!cancelled) { 862 - // setFeedviewpostreplyhandle(opi?.handle); 863 - // } 864 - // } 865 - // }; 866 - 867 - // run(); 868 - 869 - // return () => { 870 - // cancelled = true; 871 - // }; 872 - // }, [fakepost, get, set]); 873 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent 874 ?.uri; 875 const feedviewpostreplydid = ··· 893 894 return ( 895 <> 896 - {/* <p> 897 - {postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)} 898 - </p> */} 899 - {/* <span>filtermustbereply is {filterMustBeReply ? "true" : "false"}</span> 900 - <span>thereply is {thereply ? "true" : "false"}</span> */} 901 <UniversalPostRenderer 902 expanded={detailed} 903 onPostClick={() => ··· 907 params: { did: parsedaturi.host, rkey: parsedaturi.rkey }, 908 }) 909 } 910 - // onProfileClick={() => parsedaturi && navigate({to: "/profile/$did", 911 - // params: {did: parsedaturi.did} 912 - // })} 913 onProfileClick={(e) => { 914 e.stopPropagation(); 915 if (parsedaturi) { ··· 925 bottomReplyLine={bottomReplyLine} 926 topReplyLine={topReplyLine} 927 bottomBorder={bottomBorder} 928 - //extraOptionalItemInfo={{reply: postRecord?.value?.reply as AppBskyFeedDefs.ReplyRef, post: fakepost}} 929 feedviewpostreplyhandle={feedviewpostreplyhandle} 930 repostedby={feedviewpostrepostedbyhandle} 931 style={style} ··· 942 ); 943 } 944 945 - // export function parseAtUri( 946 - // atUri: string 947 - // ): { did: string; collection: string; rkey: string } | null { 948 - // const PREFIX = "at://"; 949 - // if (!atUri.startsWith(PREFIX)) { 950 - // return null; 951 - // } 952 - 953 - // const parts = atUri.slice(PREFIX.length).split("/"); 954 - 955 - // if (parts.length !== 3) { 956 - // return null; 957 - // } 958 - 959 - // const [did, collection, rkey] = parts; 960 - 961 - // if (!did || !collection || !rkey) { 962 - // return null; 963 - // } 964 - 965 - // return { did, collection, rkey }; 966 - // } 967 - 968 - export function MdiCommentOutline(props: SVGProps<SVGSVGElement>) { 969 - return ( 970 - <svg 971 - xmlns="http://www.w3.org/2000/svg" 972 - width={16} 973 - height={16} 974 - viewBox="0 0 24 24" 975 - {...props} 976 - > 977 - <path 978 - fill="var(--color-gray-400)" 979 - 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" 980 - ></path> 981 - </svg> 982 - ); 983 - } 984 - 985 - export function MdiRepeat(props: SVGProps<SVGSVGElement>) { 986 - return ( 987 - <svg 988 - xmlns="http://www.w3.org/2000/svg" 989 - width={16} 990 - height={16} 991 - viewBox="0 0 24 24" 992 - {...props} 993 - > 994 - <path 995 - fill="var(--color-gray-400)" 996 - d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z" 997 - ></path> 998 - </svg> 999 - ); 1000 - } 1001 - 1002 - export function MdiRepeatGreen(props: SVGProps<SVGSVGElement>) { 1003 - return ( 1004 - <svg 1005 - xmlns="http://www.w3.org/2000/svg" 1006 - width={16} 1007 - height={16} 1008 - viewBox="0 0 24 24" 1009 - {...props} 1010 - > 1011 - <path 1012 - fill="#5CEFAA" 1013 - d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z" 1014 - ></path> 1015 - </svg> 1016 - ); 1017 - } 1018 - 1019 - export function MdiCardsHeart(props: SVGProps<SVGSVGElement>) { 1020 - return ( 1021 - <svg 1022 - xmlns="http://www.w3.org/2000/svg" 1023 - width={16} 1024 - height={16} 1025 - viewBox="0 0 24 24" 1026 - {...props} 1027 - > 1028 - <path 1029 - fill="#EC4899" 1030 - 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" 1031 - ></path> 1032 - </svg> 1033 - ); 1034 - } 1035 - 1036 - export function MdiCardsHeartOutline(props: SVGProps<SVGSVGElement>) { 1037 - return ( 1038 - <svg 1039 - xmlns="http://www.w3.org/2000/svg" 1040 - width={16} 1041 - height={16} 1042 - viewBox="0 0 24 24" 1043 - {...props} 1044 - > 1045 - <path 1046 - fill="var(--color-gray-400)" 1047 - 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" 1048 - ></path> 1049 - </svg> 1050 - ); 1051 - } 1052 - 1053 - export function MdiShareVariant(props: SVGProps<SVGSVGElement>) { 1054 - return ( 1055 - <svg 1056 - xmlns="http://www.w3.org/2000/svg" 1057 - width={16} 1058 - height={16} 1059 - viewBox="0 0 24 24" 1060 - {...props} 1061 - > 1062 - <path 1063 - fill="var(--color-gray-400)" 1064 - 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" 1065 - ></path> 1066 - </svg> 1067 - ); 1068 - } 1069 - 1070 - export function MdiMoreHoriz(props: SVGProps<SVGSVGElement>) { 1071 - return ( 1072 - <svg 1073 - xmlns="http://www.w3.org/2000/svg" 1074 - width={16} 1075 - height={16} 1076 - viewBox="0 0 24 24" 1077 - {...props} 1078 - > 1079 - <path 1080 - fill="var(--color-gray-400)" 1081 - 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" 1082 - ></path> 1083 - </svg> 1084 - ); 1085 - } 1086 - 1087 - export function MdiGlobe(props: SVGProps<SVGSVGElement>) { 1088 - return ( 1089 - <svg 1090 - xmlns="http://www.w3.org/2000/svg" 1091 - width={12} 1092 - height={12} 1093 - viewBox="0 0 24 24" 1094 - {...props} 1095 - > 1096 - <path 1097 - fill="var(--color-gray-400)" 1098 - 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" 1099 - ></path> 1100 - </svg> 1101 - ); 1102 - } 1103 - 1104 - export function MdiVerified(props: SVGProps<SVGSVGElement>) { 1105 - return ( 1106 - <svg 1107 - xmlns="http://www.w3.org/2000/svg" 1108 - width={16} 1109 - height={16} 1110 - viewBox="0 0 24 24" 1111 - {...props} 1112 - > 1113 - <path 1114 - fill="#1297ff" 1115 - 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" 1116 - ></path> 1117 - </svg> 1118 - ); 1119 - } 1120 - 1121 - export function MdiReply(props: SVGProps<SVGSVGElement>) { 1122 - return ( 1123 - <svg 1124 - xmlns="http://www.w3.org/2000/svg" 1125 - width={14} 1126 - height={14} 1127 - viewBox="0 0 24 24" 1128 - {...props} 1129 - > 1130 - <path 1131 - fill="var(--color-gray-400)" 1132 - d="M10 9V5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11" 1133 - ></path> 1134 - </svg> 1135 - ); 1136 - } 1137 - 1138 - export function LineMdLoadingLoop(props: SVGProps<SVGSVGElement>) { 1139 - return ( 1140 - <svg 1141 - xmlns="http://www.w3.org/2000/svg" 1142 - width={24} 1143 - height={24} 1144 - viewBox="0 0 24 24" 1145 - {...props} 1146 - > 1147 - <path 1148 - fill="none" 1149 - stroke="#1297ff" 1150 - strokeDasharray={16} 1151 - strokeDashoffset={16} 1152 - strokeLinecap="round" 1153 - strokeLinejoin="round" 1154 - strokeWidth={2} 1155 - d="M12 3c4.97 0 9 4.03 9 9" 1156 - > 1157 - <animate 1158 - fill="freeze" 1159 - attributeName="stroke-dashoffset" 1160 - dur="0.2s" 1161 - values="16;0" 1162 - ></animate> 1163 - <animateTransform 1164 - attributeName="transform" 1165 - dur="1.5s" 1166 - repeatCount="indefinite" 1167 - type="rotate" 1168 - values="0 12 12;360 12 12" 1169 - ></animateTransform> 1170 - </path> 1171 - </svg> 1172 - ); 1173 - } 1174 - 1175 - export function MdiRepost(props: SVGProps<SVGSVGElement>) { 1176 - return ( 1177 - <svg 1178 - xmlns="http://www.w3.org/2000/svg" 1179 - width={14} 1180 - height={14} 1181 - viewBox="0 0 24 24" 1182 - {...props} 1183 - > 1184 - <path 1185 - fill="var(--color-gray-400)" 1186 - d="M17 17H7v-3l-4 4l4 4v-3h12v-6h-2M7 7h10v3l4-4l-4-4v3H5v6h2z" 1187 - ></path> 1188 - </svg> 1189 - ); 1190 - } 1191 - 1192 - export function MdiRepeatVariant(props: SVGProps<SVGSVGElement>) { 1193 - return ( 1194 - <svg 1195 - xmlns="http://www.w3.org/2000/svg" 1196 - width={14} 1197 - height={14} 1198 - viewBox="0 0 24 24" 1199 - {...props} 1200 - > 1201 - <path 1202 - fill="var(--color-gray-400)" 1203 - 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" 1204 - ></path> 1205 - </svg> 1206 - ); 1207 - } 1208 - 1209 - export function MdiPlayCircle(props: SVGProps<SVGSVGElement>) { 1210 - return ( 1211 - <svg 1212 - xmlns="http://www.w3.org/2000/svg" 1213 - width={64} 1214 - height={64} 1215 - viewBox="0 0 24 24" 1216 - {...props} 1217 - > 1218 - <path 1219 - fill="#edf2f5" 1220 - 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" 1221 - ></path> 1222 - </svg> 1223 - ); 1224 - } 1225 - 1226 - /* what imported from testfront */ 1227 - //import Masonry from "@mui/lab/Masonry"; 1228 - import { 1229 - type $Typed, 1230 - AppBskyActorDefs, 1231 - AppBskyEmbedDefs, 1232 - AppBskyEmbedExternal, 1233 - AppBskyEmbedImages, 1234 - AppBskyEmbedRecord, 1235 - AppBskyEmbedRecordWithMedia, 1236 - AppBskyEmbedVideo, 1237 - AppBskyFeedDefs, 1238 - AppBskyFeedPost, 1239 - AppBskyGraphDefs, 1240 - AtUri, 1241 - type Facet, 1242 - //AppBskyLabelerDefs, 1243 - //AtUri, 1244 - //ComAtprotoRepoStrongRef, 1245 - ModerationDecision, 1246 - } from "@atproto/api"; 1247 - import type { 1248 - //BlockedPost, 1249 - FeedViewPost, 1250 - //NotFoundPost, 1251 - PostView, 1252 - //ThreadViewPost, 1253 - } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 1254 - import { useInfiniteQuery } from "@tanstack/react-query"; 1255 - import { useEffect, useRef, useState } from "react"; 1256 - import ReactPlayer from "react-player"; 1257 - 1258 - import defaultpfp from "~/../public/favicon.png"; 1259 - import { 1260 - usePollData, 1261 - usePollMutationQueue, 1262 - } from "~/providers/PollMutationQueueProvider"; 1263 - import { useAuth } from "~/providers/UnifiedAuthProvider"; 1264 - import { renderSnack } from "~/routes/__root"; 1265 - import { 1266 - FeedItemRenderAturiLoader, 1267 - FollowButton, 1268 - Mutual, 1269 - } from "~/routes/profile.$did"; 1270 - import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 1271 - import { useFastLike } from "~/utils/likeMutationQueue"; 1272 - 1273 - // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; 1274 - // import type { 1275 - // ViewRecord, 1276 - // ViewNotFound, 1277 - // ViewBlocked, 1278 - // ViewDetached, 1279 - // } from "@atproto/api/dist/client/types/app/bsky/embed/record"; 1280 - //import type { MasonryItemData } from "./onemason/masonry.types"; 1281 - //import { MasonryLayout } from "./onemason/MasonryLayout"; 1282 - // const agent = new AtpAgent({ 1283 - // service: 'https://public.api.bsky.app' 1284 - // }) 1285 - type HitSlopButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & { 1286 - hitSlop?: number; 1287 - }; 1288 - 1289 - const HitSlopButtonCustom: React.FC<HitSlopButtonProps> = ({ 1290 - children, 1291 - hitSlop = 8, 1292 - style, 1293 - ...rest 1294 - }) => ( 1295 - <button 1296 - {...rest} 1297 - style={{ 1298 - position: "relative", 1299 - background: "none", 1300 - border: "none", 1301 - padding: 0, 1302 - cursor: "pointer", 1303 - ...style, 1304 - }} 1305 - > 1306 - {/* Invisible hit slop area */} 1307 - <span 1308 - style={{ 1309 - position: "absolute", 1310 - top: -hitSlop, 1311 - left: -hitSlop, 1312 - right: -hitSlop, 1313 - bottom: -hitSlop, 1314 - }} 1315 - /> 1316 - {/* Actual button content stays positioned normally */} 1317 - <span style={{ position: "relative", zIndex: 1 }}>{children}</span> 1318 - </button> 1319 - ); 1320 - 1321 - const HitSlopButton = ({ 1322 - onClick, 1323 - children, 1324 - style = {}, 1325 - ...rest 1326 - }: React.HTMLAttributes<HTMLSpanElement> & { 1327 - onClick?: (e: React.MouseEvent) => void; 1328 - children: React.ReactNode; 1329 - style?: React.CSSProperties; 1330 - }) => ( 1331 - <span 1332 - style={{ position: "relative", display: "inline-block", cursor: "pointer" }} 1333 - > 1334 - <span 1335 - style={{ 1336 - position: "absolute", 1337 - top: -8, 1338 - left: -8, 1339 - right: -8, 1340 - bottom: -8, 1341 - zIndex: 0, 1342 - }} 1343 - onClick={(e) => { 1344 - e.stopPropagation(); 1345 - onClick?.(e); 1346 - }} 1347 - /> 1348 - <span 1349 - style={{ 1350 - ...style, 1351 - position: "relative", 1352 - zIndex: 1, 1353 - pointerEvents: "none", 1354 - }} 1355 - {...rest} 1356 - > 1357 - {children} 1358 - </span> 1359 - </span> 1360 - ); 1361 - 1362 - const btnstyle = { 1363 - display: "flex", 1364 - gap: 4, 1365 - cursor: "pointer", 1366 - alignItems: "center", 1367 - fontSize: 14, 1368 - }; 1369 - function randomString(length = 8) { 1370 - const chars = 1371 - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 1372 - return Array.from( 1373 - { length }, 1374 - () => chars[Math.floor(Math.random() * chars.length)], 1375 - ).join(""); 1376 - } 1377 - 1378 - function UniversalPostRenderer({ 1379 post, 1380 uprrrsauthor, 1381 - //setMainItem, 1382 - //isMainItem, 1383 onPostClick, 1384 onProfileClick, 1385 expanded, 1386 - //expanded, 1387 isQuote, 1388 - //isQuote, 1389 extraOptionalItemInfo, 1390 bottomReplyLine, 1391 topReplyLine, ··· 1403 maxReplies, 1404 constellationLinks, 1405 }: { 1406 - post: PostView; 1407 uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed; 1408 - // optional for now because i havent ported every use to this yet 1409 - // setMainItem?: React.Dispatch< 1410 - // React.SetStateAction<AppBskyFeedDefs.FeedViewPost> 1411 - // >; 1412 - //isMainItem?: boolean; 1413 onPostClick?: (e: React.MouseEvent) => void; 1414 onProfileClick?: (e: React.MouseEvent) => void; 1415 expanded?: boolean; 1416 isQuote?: boolean; 1417 - extraOptionalItemInfo?: FeedViewPost; 1418 bottomReplyLine?: boolean; 1419 topReplyLine?: boolean; 1420 salt: string; ··· 1442 post.viewer?.repost, 1443 ); 1444 const { liked, toggle, backfill } = useFastLike(post.uri, post.cid); 1445 - // const bovref = useBackfillOnView(post.uri, post.cid); 1446 - // React.useLayoutEffect(()=>{ 1447 - // if (expanded && !isQuote) { 1448 - // backfill(); 1449 - // } 1450 - // },[backfill, expanded, isQuote]) 1451 1452 const repostOrUnrepostPost = async () => { 1453 if (!agent) { ··· 1517 (showBridgyText ? unfedibridgy : undefined) ?? 1518 (showWafrnText ? unfediwafrn : undefined); 1519 1520 - /* fuck you */ 1521 const isMainItem = false; 1522 const setMainItem = (any: any) => {}; 1523 - // eslint-disable-next-line react-hooks/refs 1524 - //console.log("Received ref in UniversalPostRenderer:", usedref); 1525 return ( 1526 <div ref={ref} style={style} data-index={dataIndexPropPass}> 1527 <div 1528 - //ref={ref} 1529 key={salt + "-" + (post.uri || emergencySalt)} 1530 onClick={ 1531 isMainItem ··· 1542 : undefined 1543 } 1544 style={{ 1545 - //...style, 1546 - //border: "1px solid #e1e8ed", 1547 - //borderRadius: 12, 1548 opacity: "1 !important", 1549 background: "transparent", 1550 paddingLeft: isQuote ? 12 : 16, 1551 paddingRight: isQuote ? 12 : 16, 1552 - //paddingTop: 16, 1553 paddingTop: isRepost ? 10 : isQuote ? 12 : topReplyLine ? 8 : 16, 1554 - //paddingBottom: bottomReplyLine ? 0 : 16, 1555 paddingBottom: 0, 1556 fontFamily: "system-ui, sans-serif", 1557 - //boxShadow: "0 2px 8px rgba(0,0,0,0.04)", 1558 position: "relative", 1559 - // dont cursor: "pointer", 1560 borderBottomWidth: bottomBorder ? (isQuote ? 0 : 1) : 0, 1561 }} 1562 className="border-gray-300 dark:border-gray-800" ··· 1571 fontSize: 14, 1572 maxHeight: "1rem", 1573 justifyContent: "flex-start", 1574 - //color: theme.textSecondary, 1575 gap: 4, 1576 alignItems: "center", 1577 }} 1578 className="text-gray-500 dark:text-gray-400" 1579 > 1580 - <MdiRepost /> Reposted by @{isRepost}{" "} 1581 </div> 1582 )} 1583 {!isQuote && ( 1584 <div 1585 style={{ 1586 - opacity: 1587 - topReplyLine || isReply /*&& (true || expanded)*/ ? 0.5 : 0, 1588 position: "absolute", 1589 top: 0, 1590 - left: 36, // why 36 ??? 1591 - //left: 16 + (42 / 2), 1592 width: 2, 1593 - //height: "100%", 1594 height: isRepost 1595 ? "calc(16px + 1rem - 6px)" 1596 : topReplyLine 1597 ? 8 - 6 1598 : 16 - 6, 1599 - // background: theme.textSecondary, 1600 - //opacity: 0.5, 1601 - // no flex here 1602 }} 1603 className="bg-gray-500 dark:bg-gray-400" 1604 /> ··· 1651 <div className="flex flex-col gap-3"> 1652 <div> 1653 <div className="text-gray-900 dark:text-gray-100 font-medium text-md"> 1654 - {post.author.displayName || post.author.handle}{" "} 1655 </div> 1656 <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1"> 1657 <Mutual targetdidorhandle={post.author.did} />@ 1658 - {post.author.handle}{" "} 1659 </div> 1660 </div> 1661 {uprrrsauthor?.description && ( ··· 1663 {uprrrsauthor.description} 1664 </div> 1665 )} 1666 - {/* <div className="flex gap-4"> 1667 - <div className="flex gap-1"> 1668 - <div className="font-medium text-gray-900 dark:text-gray-100"> 1669 - 0 1670 - </div> 1671 - <div className="text-gray-500 dark:text-gray-400"> 1672 - Following 1673 - </div> 1674 - </div> 1675 - <div className="flex gap-1"> 1676 - <div className="font-medium text-gray-900 dark:text-gray-100"> 1677 - 2,900 1678 - </div> 1679 - <div className="text-gray-500 dark:text-gray-400"> 1680 - Followers 1681 - </div> 1682 - </div> 1683 - </div> */} 1684 </div> 1685 </div> 1686 - 1687 - {/* <HoverCard.Arrow className="fill-gray-50 dark:fill-gray-900" /> */} 1688 </HoverCard.Content> 1689 </HoverCard.Portal> 1690 </HoverCard.Root> ··· 1701 marginRight: expanded || isQuote ? 0 : 12, 1702 }} 1703 > 1704 - {/* dummy for later use */} 1705 <div style={{ width: 42, height: 42 + 6, minHeight: 42 + 6 }} /> 1706 - {/* reply line !!!! bottomReplyLine */} 1707 {bottomReplyLine && ( 1708 <div 1709 style={{ 1710 width: 2, 1711 height: "100%", 1712 - //background: theme.textSecondary, 1713 opacity: 0.5, 1714 - // no flex here 1715 - //color: "Red", 1716 - //zIndex: 99 1717 }} 1718 className="bg-gray-500 dark:bg-gray-400" 1719 /> 1720 )} 1721 - {/* <div 1722 - layout 1723 - transition={{ duration: 0.2 }} 1724 - animate={{ height: expanded ? 0 : '100%' }} 1725 - style={{ 1726 - width: 2.4, 1727 - background: theme.border, 1728 - // no flex here 1729 - }} 1730 - /> */} 1731 </div> 1732 <div style={{ flex: 1, maxWidth: "100%" }}> 1733 <div ··· 1745 <div 1746 style={{ 1747 display: "flex", 1748 - //overflow: "hidden", // hey why is overflow hidden unapplied 1749 overflow: "hidden", 1750 textOverflow: "ellipsis", 1751 flexShrink: 1, ··· 1770 minWidth: 0, 1771 gap: 4, 1772 alignItems: "center", 1773 - //color: theme.text, 1774 }} 1775 className="text-gray-900 dark:text-gray-100" 1776 > 1777 - {/* verified checkmark */} 1778 - {post.author.displayName || post.author.handle}{" "} 1779 {post.author.verification?.verifiedStatus == "valid" && ( 1780 - <MdiVerified /> 1781 )} 1782 </span> 1783 1784 <span 1785 style={{ 1786 - //color: theme.textSecondary, 1787 fontSize: 16, 1788 overflowX: "hidden", 1789 textOverflow: "ellipsis", ··· 1806 > 1807 <span 1808 style={{ 1809 - //color: theme.textSecondary, 1810 fontSize: 16, 1811 marginLeft: 8, 1812 whiteSpace: "nowrap", ··· 1815 }} 1816 className="text-gray-500 dark:text-gray-400" 1817 > 1818 - · {/* time placeholder */} 1819 - {shortTimeAgo(post.indexedAt)} 1820 </span> 1821 </div> 1822 </div> 1823 - {/* reply indicator */} 1824 {!!feedviewpostreplyhandle && ( 1825 <div 1826 style={{ ··· 1829 paddingBottom: 2, 1830 fontSize: 14, 1831 justifyContent: "flex-start", 1832 - //color: theme.textSecondary, 1833 gap: 4, 1834 alignItems: "center", 1835 - //marginLeft: 36, 1836 height: 1837 !(expanded || isQuote) && !!feedviewpostreplyhandle 1838 ? "1rem" ··· 1842 }} 1843 className="text-gray-500 dark:text-gray-400" 1844 > 1845 - <MdiReply /> Reply to @{feedviewpostreplyhandle} 1846 </div> 1847 )} 1848 <div ··· 1884 {post.embed && depth < 1 && !concise ? ( 1885 <PostEmbeds 1886 embed={post.embed} 1887 - //moderation={moderation} 1888 viewContext={PostEmbedViewContext.Feed} 1889 salt={salt} 1890 navigate={navigate} ··· 1895 /> 1896 ) : null} 1897 {post.embed && depth > 0 && ( 1898 - /* pretty bad hack imo. its trying to sync up with how the embed shim doesnt 1899 - hydrate embeds this deep but the connection here is implicit 1900 - todo: idk make this a real part of the embed shim so its not implicit */ 1901 <> 1902 <div className="border-gray-300 dark:border-gray-800 p-3 rounded-xl border italic text-gray-400 text-[14px]"> 1903 (there is an embed here thats too deep to render) ··· 1914 <div 1915 style={{ 1916 overflow: "hidden", 1917 - //color: theme.textSecondary, 1918 fontSize: 14, 1919 display: "flex", 1920 borderBottomStyle: "solid", 1921 - //borderBottomColor: theme.border, 1922 - //background: "#f00", 1923 - // height: "1rem", 1924 paddingTop: 4, 1925 paddingBottom: 8, 1926 borderBottomWidth: 1, 1927 marginBottom: 8, 1928 - }} // important for height animation 1929 className="text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-800 was7" 1930 > 1931 {fullDateTimeFormat(post.indexedAt)} ··· 1938 display: "flex", 1939 gap: 32, 1940 paddingTop: 8, 1941 - //color: theme.textSecondary, 1942 fontSize: 15, 1943 justifyContent: "space-between", 1944 - //background: "#0f0", 1945 }} 1946 className="text-gray-500 dark:text-gray-400" 1947 > ··· 1953 ...btnstyle, 1954 }} 1955 > 1956 - <MdiCommentOutline /> 1957 {post.replyCount} 1958 </HitSlopButton> 1959 <DropdownMenu.Root modal={false}> ··· 1965 }} 1966 aria-label="Repost or quote post" 1967 > 1968 - {hasRetweeted ? <MdiRepeatGreen /> : <MdiRepeat />} 1969 {post.repostCount ?? 0} 1970 </div> 1971 </DropdownMenu.Trigger> ··· 1980 onSelect={repostOrUnrepostPost} 1981 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" 1982 > 1983 - <MdiRepeat 1984 className={hasRetweeted ? "text-green-400" : ""} 1985 /> 1986 <span>{hasRetweeted ? "Undo Repost" : "Repost"}</span> ··· 1995 }} 1996 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" 1997 > 1998 - {/* You might want a specific quote icon here */} 1999 - <MdiCommentOutline /> 2000 <span>Quote</span> 2001 </DropdownMenu.Item> 2002 </DropdownMenu.Content> ··· 2011 ...(liked ? { color: "#EC4899" } : {}), 2012 }} 2013 > 2014 - {liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />} 2015 {(post.likeCount || 0) + (liked ? 1 : 0)} 2016 </HitSlopButton> 2017 <div style={{ display: "flex", gap: 8 }}> ··· 2030 title: "Copied to clipboard!", 2031 }); 2032 } catch (_e) { 2033 - // idk 2034 renderSnack({ 2035 title: "Failed to copy link", 2036 }); ··· 2040 ...btnstyle, 2041 }} 2042 > 2043 - <MdiShareVariant /> 2044 </HitSlopButton> 2045 <HitSlopButton 2046 onClick={() => { ··· 2050 }} 2051 > 2052 <span style={btnstyle}> 2053 - <MdiMoreHoriz /> 2054 </span> 2055 </HitSlopButton> 2056 </div> ··· 2059 </div> 2060 <div 2061 style={{ 2062 - //height: bottomReplyLine ? 16 : 0 2063 height: isQuote ? 12 : 16, 2064 }} 2065 /> ··· 2070 ); 2071 } 2072 2073 - const fullDateTimeFormat = (iso: string) => { 2074 - const date = new Date(iso); 2075 - return date.toLocaleString("en-US", { 2076 - month: "long", 2077 - day: "numeric", 2078 - year: "numeric", 2079 - hour: "numeric", 2080 - minute: "2-digit", 2081 - hour12: true, 2082 - }); 2083 - }; 2084 - const shortTimeAgo = (iso: string) => { 2085 - const diff = Date.now() - new Date(iso).getTime(); 2086 - const mins = Math.floor(diff / 60000); 2087 - if (mins < 1) return "now"; 2088 - if (mins < 60) return `${mins}m`; 2089 - const hrs = Math.floor(mins / 60); 2090 - if (hrs < 24) return `${hrs}h`; 2091 - const days = Math.floor(hrs / 24); 2092 - return `${days}d`; 2093 - }; 2094 - 2095 - // const toAtUri = (url: string) => 2096 - // url 2097 - // .replace("https://bsky.app/profile/", "at://") 2098 - // .replace("/feed/", "/app.bsky.feed.generator/"); 2099 - 2100 - // function PostSizedElipsis() { 2101 - // return ( 2102 - // <div 2103 - // style={{ display: "flex", flexDirection: "row", alignItems: "center" }} 2104 - // > 2105 - // <div 2106 - // style={{ 2107 - // width: 2, 2108 - // height: 40, 2109 - // //background: theme.textSecondary, 2110 - // background: `repeating-linear-gradient(to bottom, var(--color-gray-400) 0px, var(--color-gray-400) 6px, transparent 6px, transparent 10px)`, 2111 - // backgroundSize: "100% 10px", 2112 - // opacity: 0.5, 2113 - // marginLeft: 36, // why 36 ??? 2114 - // }} 2115 - // /> 2116 - // <span 2117 - // style={{ 2118 - // //color: theme.textSecondary, 2119 - // marginLeft: 34, 2120 - // }} 2121 - // className="text-gray-500 dark:text-gray-400" 2122 - // > 2123 - // more posts 2124 - // </span> 2125 - // </div> 2126 - // ); 2127 - // } 2128 - 2129 - type Embed = 2130 - | AppBskyEmbedRecord.View 2131 - | AppBskyEmbedImages.View 2132 - | AppBskyEmbedVideo.View 2133 - | AppBskyEmbedExternal.View 2134 - | AppBskyEmbedRecordWithMedia.View 2135 - | { $type: string; [k: string]: unknown }; 2136 - 2137 enum PostEmbedViewContext { 2138 ThreadHighlighted = "ThreadHighlighted", 2139 Feed = "Feed", 2140 FeedEmbedRecordWithMedia = "FeedEmbedRecordWithMedia", 2141 } 2142 - const stopgap = { 2143 - display: "flex", 2144 - justifyContent: "center", 2145 - padding: "32px 12px", 2146 - borderRadius: 12, 2147 - border: "1px solid rgba(161, 170, 174, 0.38)", 2148 - }; 2149 - 2150 - function PollEmbed({ did, rkey }: { did: string; rkey: string }) { 2151 - const { agent } = useAuth(); 2152 - const { refreshPollData } = usePollMutationQueue(); 2153 - const pollUri = `at://${did}/app.reddwarf.embed.poll/${rkey}`; 2154 - const { data: pollRecord, isLoading, error } = useQueryArbitrary(pollUri); 2155 - 2156 - // --- 1. Fetch Aggregate Counts & Avatars (Public Data) --- 2157 - // (We still fetch these here as they are View-specific data dependencies) 2158 - 2159 - const { data: voteCountsA } = useQueryConstellation({ 2160 - method: "/links/count/distinct-dids", 2161 - target: pollUri, 2162 - collection: "app.reddwarf.poll.vote.a", 2163 - path: ".subject.uri", 2164 - customkey: "constellation-polls", 2165 - }); 2166 - 2167 - const { data: voteCountsB } = useQueryConstellation({ 2168 - method: "/links/count/distinct-dids", 2169 - target: pollUri, 2170 - collection: "app.reddwarf.poll.vote.b", 2171 - path: ".subject.uri", 2172 - customkey: "constellation-polls", 2173 - }); 2174 - 2175 - const { data: voteCountsC } = useQueryConstellation({ 2176 - method: "/links/count/distinct-dids", 2177 - target: pollUri, 2178 - collection: "app.reddwarf.poll.vote.c", 2179 - path: ".subject.uri", 2180 - customkey: "constellation-polls", 2181 - }); 2182 - 2183 - const { data: voteCountsD } = useQueryConstellation({ 2184 - method: "/links/count/distinct-dids", 2185 - target: pollUri, 2186 - collection: "app.reddwarf.poll.vote.d", 2187 - path: ".subject.uri", 2188 - customkey: "constellation-polls", 2189 - }); 2190 - 2191 - // Query first page of voters for Avatars 2192 - const { data: votersA } = useQueryConstellation({ 2193 - method: "/links", 2194 - target: pollUri, 2195 - collection: "app.reddwarf.poll.vote.a", 2196 - path: ".subject.uri", 2197 - customkey: "constellation-polls", 2198 - }); 2199 - const { data: votersB } = useQueryConstellation({ 2200 - method: "/links", 2201 - target: pollUri, 2202 - collection: "app.reddwarf.poll.vote.b", 2203 - path: ".subject.uri", 2204 - customkey: "constellation-polls", 2205 - }); 2206 - const { data: votersC } = useQueryConstellation({ 2207 - method: "/links", 2208 - target: pollUri, 2209 - collection: "app.reddwarf.poll.vote.c", 2210 - path: ".subject.uri", 2211 - customkey: "constellation-polls", 2212 - }); 2213 - const { data: votersD } = useQueryConstellation({ 2214 - method: "/links", 2215 - target: pollUri, 2216 - collection: "app.reddwarf.poll.vote.d", 2217 - path: ".subject.uri", 2218 - customkey: "constellation-polls", 2219 - }); 2220 - 2221 - // --- 2. Prepare Data --- 2222 - // todo: hardcoded to multiple for all public polls 2223 - const poll = { 2224 - ...(pollRecord?.value ?? {}), 2225 - multiple: true, 2226 - } as { 2227 - a: string; 2228 - b: string; 2229 - c?: string; 2230 - d?: string; 2231 - expiry?: string; 2232 - multiple?: boolean; 2233 - createdAt: string; 2234 - }; 2235 - 2236 - const options = [poll.a, poll.b, poll.c, poll.d].filter(Boolean); 2237 - 2238 - const serverCounts = { 2239 - a: parseInt((voteCountsA as any)?.total || "0"), 2240 - b: parseInt((voteCountsB as any)?.total || "0"), 2241 - c: parseInt((voteCountsC as any)?.total || "0"), 2242 - d: parseInt((voteCountsD as any)?.total || "0"), 2243 - }; 2244 - 2245 - // --- 3. THE MAGIC HOOK (Now centralized) --- 2246 - // This hook now fetches self-votes internally and merges them with the serverCounts we passed in 2247 - const { results, totalVotes, handleVote } = usePollData( 2248 - pollUri, 2249 - pollRecord?.cid, 2250 - !!poll.multiple, 2251 - serverCounts, 2252 - ); 2253 - 2254 - // --- 4. Render --- 2255 - 2256 - if (isLoading) { 2257 - return ( 2258 - <div className="animate-pulse"> 2259 - <div className="flex items-center gap-2 mb-3"> 2260 - <div className="h-6 w-20 bg-gray-300 dark:bg-gray-600 rounded"></div> 2261 - <div className="h-6 w-32 bg-gray-300 dark:bg-gray-600 rounded"></div> 2262 - </div> 2263 - <div className="space-y-2"> 2264 - <div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-lg"></div> 2265 - <div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-lg w-3/4"></div> 2266 - </div> 2267 - </div> 2268 - ); 2269 - } 2270 - 2271 - if (error || !pollRecord?.value) { 2272 - return <div className="text-red-500 text-sm p-2">Failed to load poll</div>; 2273 - } 2274 - const isExpired = false; //poll.expiry ? new Date(poll.expiry) < new Date() : false; 2275 - 2276 - // todo unused waiting for private polls 2277 - // undefined for public polls which equals never expires 2278 - const formattedDate = undefined; 2279 - // const formattedDate = poll.expiry 2280 - // ? new Date(poll.expiry).toLocaleDateString("en-US", { 2281 - // month: "short", 2282 - // day: "numeric", 2283 - // hour: "numeric", 2284 - // minute: "2-digit", 2285 - // }) 2286 - // : null; 2287 - 2288 - // const totalVotes = voteData.reduce((sum, item) => sum + item.count, 0); 2289 - 2290 - // const handleVote = async (option: string) => { 2291 - // if (!agent || isExpired) return; 2292 - 2293 - // try { 2294 - // // Get existing votes for this option 2295 - // const existingVotes = (() => { 2296 - // switch (option) { 2297 - // case "a": 2298 - // return userVotesA; 2299 - // case "b": 2300 - // return userVotesB; 2301 - // case "c": 2302 - // return userVotesC; 2303 - // case "d": 2304 - // return userVotesD; 2305 - // default: 2306 - // return []; 2307 - // } 2308 - // })(); 2309 - 2310 - // // If user has already voted for this option, delete all votes (unvote) 2311 - // if (existingVotes && existingVotes.length > 0) { 2312 - // for (const voteUri of existingVotes) { 2313 - // const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); 2314 - // if (match) { 2315 - // const [, did, collection, rkey] = match; 2316 - // await agent.com.atproto.repo.deleteRecord({ 2317 - // repo: did, 2318 - // collection, 2319 - // rkey, 2320 - // }); 2321 - // } 2322 - // } 2323 - // } else { 2324 - // // If not voted for this option, create new vote 2325 - // // First, delete votes from other options if poll doesn't allow multiple votes 2326 - // if (!poll.multiple) { 2327 - // const otherVotes = [ 2328 - // ...(userVotesA || []), 2329 - // ...(userVotesB || []), 2330 - // ...(userVotesC || []), 2331 - // ...(userVotesD || []), 2332 - // ].filter((vote) => { 2333 - // // Filter out votes for the current option 2334 - // return !vote.includes(`app.reddwarf.poll.vote.${option}`); 2335 - // }); 2336 - 2337 - // for (const voteUri of otherVotes) { 2338 - // const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); 2339 - // if (match) { 2340 - // const [, did, collection, rkey] = match; 2341 - // await agent.com.atproto.repo.deleteRecord({ 2342 - // repo: did, 2343 - // collection, 2344 - // rkey, 2345 - // }); 2346 - // } 2347 - // } 2348 - // } 2349 - 2350 - // // Create new vote 2351 - // await agent.com.atproto.repo.createRecord({ 2352 - // collection: `app.reddwarf.poll.vote.${option}`, 2353 - // repo: agent.assertDid, 2354 - // record: { 2355 - // $type: `app.reddwarf.poll.vote.${option}`, 2356 - // subject: { 2357 - // $type: "com.atproto.repo.strongRef", 2358 - // uri: pollUri, 2359 - // cid: pollRecord.cid, 2360 - // }, 2361 - // createdAt: new Date().toISOString(), 2362 - // }, 2363 - // // Let PDS generate rkey automatically 2364 - // }); 2365 - // } 2366 - // } catch (error) { 2367 - // console.error("Failed to vote:", error); 2368 - // } 2369 - // }; 2370 - 2371 - return ( 2372 - <> 2373 - <div className="my-4"> 2374 - {/* Header */} 2375 - <div className="mb-4 flex items-center gap-3"> 2376 - {/* Type Pill */} 2377 - <div className="flex items-center gap-1.5 rounded-lg border-gray-300 dark:border-gray-600 pl-2 pr-2.5 py-1 text-sm font-medium uppercase tracking-wide text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800"> 2378 - <IconMdiGlobe /> 2379 - <span>Public Poll</span> 2380 - </div> 2381 - 2382 - {/* Multiplicity */} 2383 - <span className="text-sm font-normal text-gray-500 dark:text-gray-400 flex flex-row items-center gap-1"> 2384 - {poll.multiple ? ( 2385 - <IconMdiCheckboxMultipleMarked /> 2386 - ) : ( 2387 - <IconMdiCheckCircle /> 2388 - )} 2389 - <span className="md:flex hidden"> 2390 - {poll.multiple 2391 - ? "Select one or more options" 2392 - : "Select one option"} 2393 - </span> 2394 - </span> 2395 - 2396 - {/* Refresh Button */} 2397 - <button 2398 - onClick={(e) => { 2399 - e.stopPropagation(); 2400 - refreshPollData(pollUri); 2401 - }} 2402 - className="ml-auto rounded-full h-8 outline outline-gray-200 text-gray-700 dark:outline-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors px-3 py-1 text-[12px] flex items-center gap-1" 2403 - title="Refresh poll data" 2404 - > 2405 - <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> 2406 - <path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" /> 2407 - </svg> 2408 - Refresh 2409 - </button> 2410 - </div> 2411 - 2412 - {/* Options List with Results */} 2413 - <div className="space-y-3"> 2414 - {options.map((optionText, index) => { 2415 - const optionKey = ["a", "b", "c", "d"][index] as 2416 - | "a" 2417 - | "b" 2418 - | "c" 2419 - | "d"; 2420 - const { topVoterDids } = results[optionKey]; 2421 - const optionState = results[optionKey]; 2422 - const hasVotedForOption = optionState.hasVoted; 2423 - const votePercentage = 2424 - totalVotes > 0 ? (optionState.count / totalVotes) * 100 : 0; 2425 - 2426 - // Helper to get voters for avatars 2427 - const votersData = (() => { 2428 - if (optionKey === "a") return votersA?.linking_records || []; 2429 - if (optionKey === "b") return votersB?.linking_records || []; 2430 - if (optionKey === "c") return votersC?.linking_records || []; 2431 - if (optionKey === "d") return votersD?.linking_records || []; 2432 - return []; 2433 - })(); 2434 - const topVoters = votersData 2435 - .filter((v: any) => !!v.did) 2436 - .slice(0, 5); 2437 - 2438 - return ( 2439 - <div 2440 - key={index} 2441 - className={`group relative h-12 items-center justify-between rounded-lg border px-4 flex overflow-hidden ${ 2442 - !isExpired 2443 - ? hasVotedForOption 2444 - ? "bg-gray-100 dark:bg-gray-950 border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-900 cursor-pointer outline-2 outline-gray-500 dark:outline-gray-400" 2445 - : "bg-gray-100 dark:bg-gray-950 border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-900 cursor-pointer" 2446 - : "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700" 2447 - }`} 2448 - onClick={(e) => { 2449 - e.stopPropagation(); 2450 - if (!isExpired) { 2451 - handleVote(optionKey); 2452 - } 2453 - }} 2454 - > 2455 - {/* Vote percentage bar - always show */} 2456 - <div 2457 - className="absolute inset-y-0 left-0 bg-gray-300 dark:bg-gray-700 group-hover:bg-gray-400 dark:group-hover:bg-gray-600 transition-[width]" 2458 - style={{ width: `${votePercentage}%` }} 2459 - /> 2460 - 2461 - {/* Option text */} 2462 - <span className="relative z-[2] text-sm font-medium text-gray-900 dark:text-gray-100 truncate"> 2463 - {optionText} 2464 - {hasVotedForOption && ( 2465 - <span className="ml-2 text-gray-600 dark:text-gray-400"> 2466 - {poll.multiple ? "✓" : "✓ (click to remove)"} 2467 - </span> 2468 - )} 2469 - </span> 2470 - 2471 - {/* Avatar circles and vote count */} 2472 - <div className="relative z-[2] flex items-center gap-2"> 2473 - {/* Avatar circles - semi overlapping */} 2474 - {topVoterDids.length > 0 && ( 2475 - <div className="flex -space-x-2"> 2476 - {topVoterDids.map((did, idx) => ( 2477 - <div 2478 - key={did} 2479 - className="w-5 h-5 rounded-full border-2 border-white dark:border-gray-900 overflow-hidden bg-gray-200" 2480 - style={{ zIndex: 5 - idx }} 2481 - > 2482 - <PollOptionAvatar did={did} /> 2483 - </div> 2484 - ))} 2485 - </div> 2486 - )} 2487 - 2488 - {/* Vote count */} 2489 - <span className="text-sm font-medium text-gray-600 dark:text-gray-400"> 2490 - {votePercentage.toFixed(0)}% 2491 - </span> 2492 - </div> 2493 - </div> 2494 - ); 2495 - })} 2496 - </div> 2497 - 2498 - {/* Footer */} 2499 - <div className="mt-4 flex items-center justify-between text-sm text-gray-500 dark:text-gray-400"> 2500 - {/* Expiry */} 2501 - <div className="flex items-center gap-2"> 2502 - <IconMdiClockOutline /> 2503 - {/* <span>Expires {formattedDate}</span> */} 2504 - {formattedDate ? ( 2505 - !isExpired ? ( 2506 - <span>Expires {formattedDate}</span> 2507 - ) : ( 2508 - <span>Expired at {formattedDate}</span> 2509 - ) 2510 - ) : ( 2511 - <span>Never expires</span> 2512 - )} 2513 - </div> 2514 - 2515 - {/* Status */} 2516 - {/* <div className="flex items-center gap-2"> 2517 - {isExpired ? ( 2518 - <span className="text-red-500 dark:text-red-400 font-medium"> 2519 - Poll ended 2520 - </span> 2521 - ) : ( 2522 - <span className="text-gray-500 dark:text-gray-400"> 2523 - All votes are public 2524 - </span> 2525 - )} 2526 - </div> */} 2527 - <button 2528 - onClick={(e) => { 2529 - e.stopPropagation(); 2530 - // todo: implement the proper votes page here thanks 2531 - renderSnack({ 2532 - title: "Not implemented yet...", 2533 - description: "Opening PDSLS", 2534 - }); 2535 - const pdslsUrl = `https://pdsls.dev/at://${did}/app.reddwarf.embed.poll/${rkey}#backlinks`; 2536 - window.open(pdslsUrl, "_blank"); 2537 - }} 2538 - className="rounded-full h-10 bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors px-4 py-2 text-[14px]" 2539 - > 2540 - View all {totalVotes} votes 2541 - </button> 2542 - </div> 2543 - </div> 2544 - {/* <div className=" scale-[56%] -translate-x-[120px] -translate-y-[80px]"> 2545 - <RawOGC 2546 - multiple 2547 - a={poll.a || ""} 2548 - b={poll.b || ""} 2549 - c={poll.c} 2550 - d={poll.d} /> 2551 - </div> */} 2552 - </> 2553 - ); 2554 - } 2555 - 2556 - function PollOptionAvatar({ did }: { did: string }) { 2557 - const [imgcdn] = useAtom(imgCDNAtom); 2558 - // Each avatar handles its own data fetching 2559 - // If this specific DID is already in cache, it loads instantly 2560 - const { data: profileRecord } = useQueryProfile( 2561 - `at://${did}/app.bsky.actor.profile/self`, 2562 - ); 2563 - 2564 - //const profile = profileRecord?.value as ATPAPI.AppBskyActorProfile.Record; 2565 - const avatarUrl = getAvatarUrl(profileRecord, did, imgcdn); 2566 - 2567 - if (!avatarUrl) { 2568 - // Fallback grey circle 2569 - return <div className="w-full h-full bg-gray-500" />; 2570 - } 2571 - 2572 - return ( 2573 - <img 2574 - src={avatarUrl} 2575 - alt="voter" 2576 - className="w-full h-full object-cover" 2577 - onError={(e) => { 2578 - const target = e.target as HTMLImageElement; 2579 - target.style.display = "none"; 2580 - target.parentElement!.style.backgroundColor = "#6b7280"; 2581 - }} 2582 - /> 2583 - ); 2584 - } 2585 - 2586 - function PostEmbeds({ 2587 - embed, 2588 - moderation, 2589 - onOpen, 2590 - allowNestedQuotes, 2591 - viewContext, 2592 - salt, 2593 - navigate, 2594 - postid, 2595 - nopics, 2596 - lightboxCallback, 2597 - constellationLinks, 2598 - }: { 2599 - embed?: Embed; 2600 - moderation?: ModerationDecision; 2601 - onOpen?: () => void; 2602 - allowNestedQuotes?: boolean; 2603 - viewContext?: PostEmbedViewContext; 2604 - salt: string; 2605 - navigate: (_: any) => void; 2606 - postid?: { did: string; rkey: string }; 2607 - nopics?: boolean; 2608 - lightboxCallback?: (d: LightboxProps) => void; 2609 - constellationLinks?: any; 2610 - }) { 2611 - //const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); 2612 - function setLightboxIndex(number: number) { 2613 - navigate({ 2614 - to: "/profile/$did/post/$rkey/image/$i", 2615 - params: { 2616 - did: postid?.did, 2617 - rkey: postid?.rkey, 2618 - i: number.toString(), 2619 - }, 2620 - }); 2621 - } 2622 - if ( 2623 - AppBskyEmbedRecordWithMedia.isView(embed) && 2624 - AppBskyEmbedRecord.isViewRecord(embed.record.record) && 2625 - AppBskyFeedPost.isRecord(embed.record.record.value) //&& 2626 - //AppBskyFeedPost.validateRecord(embed.record.record.value).success 2627 - ) { 2628 - const post: PostView = { 2629 - $type: "app.bsky.feed.defs#postView", // lmao lies 2630 - uri: embed.record.record.uri, 2631 - cid: embed.record.record.cid, 2632 - author: embed.record.record.author, 2633 - record: embed.record.record.value as { [key: string]: unknown }, 2634 - embed: embed.record.record.embeds 2635 - ? embed.record.record.embeds?.[0] 2636 - : undefined, // quotes handles embeds differently, its an array for some reason 2637 - replyCount: embed.record.record.replyCount, 2638 - repostCount: embed.record.record.repostCount, 2639 - likeCount: embed.record.record.likeCount, 2640 - quoteCount: embed.record.record.quoteCount, 2641 - indexedAt: embed.record.record.indexedAt, 2642 - // we dont have a viewer, so this is a best effort conversion, still requires full query later on 2643 - labels: embed.record.record.labels, 2644 - // neither do we have threadgate. remember to please fetch the full post later 2645 - }; 2646 - return ( 2647 - <div> 2648 - <PostEmbeds 2649 - embed={embed.media} 2650 - moderation={moderation} 2651 - onOpen={onOpen} 2652 - viewContext={viewContext} 2653 - salt={salt} 2654 - navigate={navigate} 2655 - postid={postid} 2656 - nopics={nopics} 2657 - lightboxCallback={lightboxCallback} 2658 - constellationLinks={constellationLinks} 2659 - /> 2660 - {/* padding empty div of 8px height */} 2661 - <div style={{ height: 12 }} /> 2662 - {/* stopgap sorry*/} 2663 - <div 2664 - style={{ 2665 - display: "flex", 2666 - flexDirection: "column", 2667 - borderRadius: 12, 2668 - //border: `1px solid ${theme.border}`, 2669 - //boxShadow: theme.cardShadow, 2670 - overflow: "hidden", 2671 - }} 2672 - className="shadow border border-gray-200 dark:border-gray-800 was7" 2673 - > 2674 - <UniversalPostRenderer 2675 - post={post} 2676 - isQuote 2677 - salt={salt} 2678 - onPostClick={(e) => { 2679 - e.stopPropagation(); 2680 - const parsed = new AtUri(post.uri); //parseAtUri(post.uri); 2681 - if (parsed) { 2682 - navigate({ 2683 - to: "/profile/$did/post/$rkey", 2684 - params: { did: parsed.host, rkey: parsed.rkey }, 2685 - }); 2686 - } 2687 - }} 2688 - depth={1} 2689 - /> 2690 - </div> 2691 - {/* <QuotePostRenderer 2692 - record={embed.record.record} 2693 - moderation={moderation} 2694 - /> */} 2695 - {/* stopgap sorry */} 2696 - {/* <div style={stopgap}>quote post placeholder</div> */} 2697 - {/* {<MaybeQuoteEmbed 2698 - embed={embed.record} 2699 - onOpen={onOpen} 2700 - viewContext={ 2701 - viewContext === PostEmbedViewContext.Feed 2702 - ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia 2703 - : undefined 2704 - } 2705 - {/* <div style={stopgap}>quote post placeholder</div> */} 2706 - {/* {<MaybeQuoteEmbed 2707 - embed={embed.record} 2708 - onOpen={onOpen} 2709 - viewContext={ 2710 - viewContext === PostEmbedViewContext.Feed 2711 - ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia 2712 - : undefined 2713 - } 2714 - />} */} 2715 - </div> 2716 - ); 2717 - } 2718 - 2719 - if (AppBskyEmbedRecord.isView(embed)) { 2720 - // hey im really lazy and im gonna do it the bad way 2721 - const reallybaduri = (embed?.record as any)?.uri as string | undefined; 2722 - const reallybadaturi = reallybaduri ? new AtUri(reallybaduri) : undefined; 2723 - 2724 - // custom feed embed (i.e. generator view) 2725 - if (AppBskyFeedDefs.isGeneratorView(embed.record)) { 2726 - // stopgap sorry 2727 - return <div style={stopgap}>feedgen placeholder</div>; 2728 - // return ( 2729 - // <div style={{ marginTop: '1rem' }}> 2730 - // <MaybeFeedCard view={embed.record} /> 2731 - // </div> 2732 - // ) 2733 - } else if ( 2734 - !!reallybaduri && 2735 - !!reallybadaturi && 2736 - reallybadaturi.collection === "app.bsky.feed.generator" 2737 - ) { 2738 - return ( 2739 - <div className="rounded-xl border"> 2740 - <FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder /> 2741 - </div> 2742 - ); 2743 - } 2744 - 2745 - // list embed 2746 - if (AppBskyGraphDefs.isListView(embed.record)) { 2747 - // stopgap sorry 2748 - return <div style={stopgap}>list placeholder</div>; 2749 - // return ( 2750 - // <div style={{ marginTop: '1rem' }}> 2751 - // <MaybeListCard view={embed.record} /> 2752 - // </div> 2753 - // ) 2754 - } else if ( 2755 - !!reallybaduri && 2756 - !!reallybadaturi && 2757 - reallybadaturi.collection === "app.bsky.graph.list" 2758 - ) { 2759 - return ( 2760 - <div className="rounded-xl border"> 2761 - <FeedItemRenderAturiLoader 2762 - aturi={reallybaduri} 2763 - disableBottomBorder 2764 - listmode 2765 - disablePropagation 2766 - /> 2767 - </div> 2768 - ); 2769 - } 2770 - 2771 - // starter pack embed 2772 - if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) { 2773 - // stopgap sorry 2774 - return <div style={stopgap}>starter pack card placeholder</div>; 2775 - // return ( 2776 - // <div style={{ marginTop: '1rem' }}> 2777 - // <StarterPackCard starterPack={embed.record} /> 2778 - // </div> 2779 - // ) 2780 - } else if ( 2781 - !!reallybaduri && 2782 - !!reallybadaturi && 2783 - reallybadaturi.collection === "app.bsky.graph.starterpack" 2784 - ) { 2785 - return ( 2786 - <div className="rounded-xl border"> 2787 - <FeedItemRenderAturiLoader 2788 - aturi={reallybaduri} 2789 - disableBottomBorder 2790 - listmode 2791 - disablePropagation 2792 - /> 2793 - </div> 2794 - ); 2795 - } 2796 - 2797 - // quote post 2798 - // = 2799 - // stopgap sorry 2800 - 2801 - if ( 2802 - AppBskyEmbedRecord.isViewRecord(embed.record) && 2803 - AppBskyFeedPost.isRecord(embed.record.value) // && 2804 - //AppBskyFeedPost.validateRecord(embed.record.value).success 2805 - ) { 2806 - const post: PostView = { 2807 - $type: "app.bsky.feed.defs#postView", // lmao lies 2808 - uri: embed.record.uri, 2809 - cid: embed.record.cid, 2810 - author: embed.record.author, 2811 - record: embed.record.value as { [key: string]: unknown }, 2812 - embed: embed.record.embeds ? embed.record.embeds?.[0] : undefined, // quotes handles embeds differently, its an array for some reason 2813 - replyCount: embed.record.replyCount, 2814 - repostCount: embed.record.repostCount, 2815 - likeCount: embed.record.likeCount, 2816 - quoteCount: embed.record.quoteCount, 2817 - indexedAt: embed.record.indexedAt, 2818 - // we dont have a viewer, so this is a best effort conversion, still requires full query later on 2819 - labels: embed.record.labels, 2820 - // neither do we have threadgate. remember to please fetch the full post later 2821 - }; 2822 - 2823 - return ( 2824 - <div 2825 - style={{ 2826 - display: "flex", 2827 - flexDirection: "column", 2828 - borderRadius: 12, 2829 - //border: `1px solid ${theme.border}`, 2830 - //boxShadow: theme.cardShadow, 2831 - overflow: "hidden", 2832 - }} 2833 - className="shadow border border-gray-200 dark:border-gray-800 was7" 2834 - > 2835 - <UniversalPostRenderer 2836 - post={post} 2837 - isQuote 2838 - salt={salt} 2839 - onPostClick={(e) => { 2840 - e.stopPropagation(); 2841 - const parsed = new AtUri(post.uri); //parseAtUri(post.uri); 2842 - if (parsed) { 2843 - navigate({ 2844 - to: "/profile/$did/post/$rkey", 2845 - params: { did: parsed.host, rkey: parsed.rkey }, 2846 - }); 2847 - } 2848 - }} 2849 - depth={1} 2850 - /> 2851 - </div> 2852 - ); 2853 - } else { 2854 - console.log("what the hell is a ", embed); 2855 - return <>sorry</>; 2856 - } 2857 - //return <QuotePostRenderer record={embed.record} moderation={moderation} />; 2858 - 2859 - //return <div style={stopgap}>quote post placeholder</div>; 2860 - // return ( 2861 - // <MaybeQuoteEmbed 2862 - // embed={embed} 2863 - // onOpen={onOpen} 2864 - // allowNestedQuotes={allowNestedQuotes} 2865 - // /> 2866 - // ) 2867 - } 2868 - 2869 - // image embed 2870 - // = 2871 - if (AppBskyEmbedImages.isView(embed)) { 2872 - const { images } = embed; 2873 - 2874 - const lightboxImages = images.map((img) => ({ 2875 - src: img.fullsize, 2876 - alt: img.alt, 2877 - })); 2878 - console.log("rendering images"); 2879 - if (lightboxCallback) { 2880 - lightboxCallback({ images: lightboxImages }); 2881 - console.log("rendering images"); 2882 - } 2883 - 2884 - if (nopics) return; 2885 - 2886 - if (images.length > 0) { 2887 - // const items = embed.images.map(img => ({ 2888 - // uri: img.fullsize, 2889 - // thumbUri: img.thumb, 2890 - // alt: img.alt, 2891 - // dimensions: img.aspectRatio ?? null, 2892 - // })) 2893 - 2894 - if (images.length === 1) { 2895 - const image = images[0]; 2896 - return ( 2897 - <div style={{ marginTop: 0 }}> 2898 - <div 2899 - style={{ 2900 - position: "relative", 2901 - width: "100%", 2902 - aspectRatio: image.aspectRatio 2903 - ? (() => { 2904 - const { width, height } = image.aspectRatio; 2905 - const ratio = width / height; 2906 - return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`; 2907 - })() 2908 - : "1 / 1", // fallback to square 2909 - //backgroundColor: theme.background, // fallback letterboxing color 2910 - borderRadius: 12, 2911 - //border: `1px solid ${theme.border}`, 2912 - overflow: "hidden", 2913 - }} 2914 - className="border border-gray-200 dark:border-gray-800 was7 bg-gray-200 dark:bg-gray-900" 2915 - > 2916 - {/* {lightboxIndex !== null && ( 2917 - <Lightbox 2918 - images={lightboxImages} 2919 - index={lightboxIndex} 2920 - onClose={() => setLightboxIndex(null)} 2921 - onNavigate={(newIndex) => setLightboxIndex(newIndex)} 2922 - post={postid} 2923 - /> 2924 - )} */} 2925 - <img 2926 - src={image.fullsize} 2927 - alt={image.alt} 2928 - style={{ 2929 - width: "100%", 2930 - height: "100%", 2931 - objectFit: "contain", // letterbox or scale to fit 2932 - }} 2933 - onClick={(e) => { 2934 - e.stopPropagation(); 2935 - setLightboxIndex(0); 2936 - }} 2937 - /> 2938 - </div> 2939 - </div> 2940 - ); 2941 - } 2942 - // 2 images: side by side, both 1:1, cropped 2943 - if (images.length === 2) { 2944 - return ( 2945 - <div 2946 - style={{ 2947 - display: "flex", 2948 - gap: 4, 2949 - marginTop: 0, 2950 - width: "100%", 2951 - borderRadius: 12, 2952 - overflow: "hidden", 2953 - //border: `1px solid ${theme.border}`, 2954 - }} 2955 - className="border border-gray-200 dark:border-gray-800 was7" 2956 - > 2957 - {/* {lightboxIndex !== null && ( 2958 - <Lightbox 2959 - images={lightboxImages} 2960 - index={lightboxIndex} 2961 - onClose={() => setLightboxIndex(null)} 2962 - onNavigate={(newIndex) => setLightboxIndex(newIndex)} 2963 - post={postid} 2964 - /> 2965 - )} */} 2966 - {images.map((img, i) => ( 2967 - <div 2968 - key={i} 2969 - style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }} 2970 - > 2971 - <img 2972 - src={img.fullsize} 2973 - alt={img.alt} 2974 - style={{ 2975 - width: "100%", 2976 - height: "100%", 2977 - objectFit: "cover", 2978 - borderRadius: i === 0 ? "12px 0 0 12px" : "0 12px 12px 0", 2979 - }} 2980 - onClick={(e) => { 2981 - e.stopPropagation(); 2982 - setLightboxIndex(i); 2983 - }} 2984 - /> 2985 - </div> 2986 - ))} 2987 - </div> 2988 - ); 2989 - } 2990 - 2991 - // 3 images: left is 1:1, right is two stacked 2:1 2992 - if (images.length === 3) { 2993 - return ( 2994 - <div 2995 - style={{ 2996 - display: "flex", 2997 - gap: 4, 2998 - marginTop: 0, 2999 - width: "100%", 3000 - borderRadius: 12, 3001 - overflow: "hidden", 3002 - //border: `1px solid ${theme.border}`, 3003 - // height: 240, // fixed height for cropping 3004 - }} 3005 - className="border border-gray-200 dark:border-gray-800 was7" 3006 - > 3007 - {/* {lightboxIndex !== null && ( 3008 - <Lightbox 3009 - images={lightboxImages} 3010 - index={lightboxIndex} 3011 - onClose={() => setLightboxIndex(null)} 3012 - onNavigate={(newIndex) => setLightboxIndex(newIndex)} 3013 - post={postid} 3014 - /> 3015 - )} */} 3016 - {/* Left: 1:1 */} 3017 - <div 3018 - style={{ flex: 1, aspectRatio: "1 / 1", position: "relative" }} 3019 - > 3020 - <img 3021 - src={images[0].fullsize} 3022 - alt={images[0].alt} 3023 - style={{ 3024 - width: "100%", 3025 - height: "100%", 3026 - objectFit: "cover", 3027 - borderRadius: "12px 0 0 12px", 3028 - }} 3029 - onClick={(e) => { 3030 - e.stopPropagation(); 3031 - setLightboxIndex(0); 3032 - }} 3033 - /> 3034 - </div> 3035 - {/* Right: two stacked 2:1 */} 3036 - <div 3037 - style={{ 3038 - flex: 1, 3039 - display: "flex", 3040 - flexDirection: "column", 3041 - gap: 4, 3042 - }} 3043 - > 3044 - {[1, 2].map((i) => ( 3045 - <div 3046 - key={i} 3047 - style={{ 3048 - flex: 1, 3049 - aspectRatio: "2 / 1", 3050 - position: "relative", 3051 - }} 3052 - > 3053 - <img 3054 - src={images[i].fullsize} 3055 - alt={images[i].alt} 3056 - style={{ 3057 - width: "100%", 3058 - height: "100%", 3059 - objectFit: "cover", 3060 - borderRadius: i === 1 ? "0 12px 0 0" : "0 0 12px 0", 3061 - }} 3062 - onClick={(e) => { 3063 - e.stopPropagation(); 3064 - setLightboxIndex(i + 1); 3065 - }} 3066 - /> 3067 - </div> 3068 - ))} 3069 - </div> 3070 - </div> 3071 - ); 3072 - } 3073 - 3074 - // 4 images: 2x2 grid, all 3:2 3075 - if (images.length === 4) { 3076 - return ( 3077 - <div 3078 - style={{ 3079 - display: "grid", 3080 - gridTemplateColumns: "1fr 1fr", 3081 - gridTemplateRows: "1fr 1fr", 3082 - gap: 4, 3083 - marginTop: 0, 3084 - width: "100%", 3085 - borderRadius: 12, 3086 - overflow: "hidden", 3087 - //border: `1px solid ${theme.border}`, 3088 - //aspectRatio: "3 / 2", // overall grid aspect 3089 - }} 3090 - className="border border-gray-200 dark:border-gray-800 was7" 3091 - > 3092 - {/* {lightboxIndex !== null && ( 3093 - <Lightbox 3094 - images={lightboxImages} 3095 - index={lightboxIndex} 3096 - onClose={() => setLightboxIndex(null)} 3097 - onNavigate={(newIndex) => setLightboxIndex(newIndex)} 3098 - post={postid} 3099 - /> 3100 - )} */} 3101 - {images.map((img, i) => ( 3102 - <div 3103 - key={i} 3104 - style={{ 3105 - width: "100%", 3106 - height: "100%", 3107 - aspectRatio: "3 / 2", 3108 - position: "relative", 3109 - }} 3110 - > 3111 - <img 3112 - src={img.fullsize} 3113 - alt={img.alt} 3114 - style={{ 3115 - width: "100%", 3116 - height: "100%", 3117 - objectFit: "cover", 3118 - borderRadius: 3119 - i === 0 3120 - ? "12px 0 0 0" 3121 - : i === 1 3122 - ? "0 12px 0 0" 3123 - : i === 2 3124 - ? "0 0 0 12px" 3125 - : "0 0 12px 0", 3126 - }} 3127 - onClick={(e) => { 3128 - e.stopPropagation(); 3129 - setLightboxIndex(i); 3130 - }} 3131 - /> 3132 - </div> 3133 - ))} 3134 - </div> 3135 - ); 3136 - } 3137 - 3138 - // stopgap sorry 3139 - return <div style={stopgap}>image count more than one placeholder</div>; 3140 - // return ( 3141 - // <div style={{ marginTop: '1rem' }}> 3142 - // <ImageLayoutGrid 3143 - // images={images} 3144 - // viewContext={viewContext} 3145 - // /> 3146 - // </div> 3147 - // ) 3148 - } 3149 - } 3150 - 3151 - // external link embed 3152 - // = 3153 - if (AppBskyEmbedExternal.isView(embed)) { 3154 - // Check for poll embed record in constellation links 3155 - const pollLinks = constellationLinks?.links?.["app.reddwarf.embed.poll"]; 3156 - const hasPollLink = pollLinks && Object.keys(pollLinks).length > 0; 3157 - 3158 - if (hasPollLink && postid) { 3159 - // Return poll embed instead of external embed 3160 - return <PollEmbed did={postid.did} rkey={postid.rkey} />; 3161 - } 3162 - 3163 - const link = embed.external; 3164 - return ( 3165 - <ExternalLinkEmbed link={link} onOpen={onOpen} style={{ marginTop: 0 }} /> 3166 - ); 3167 - } 3168 - 3169 - // video embed 3170 - // = 3171 - if (AppBskyEmbedVideo.isView(embed)) { 3172 - // hls playlist 3173 - if (nopics) return; 3174 - const playlist = embed.playlist; 3175 - return ( 3176 - <SmartHLSPlayer 3177 - url={playlist} 3178 - thumbnail={embed.thumbnail} 3179 - aspect={embed.aspectRatio} 3180 - /> 3181 - ); 3182 - // stopgap sorry 3183 - //return (<div>video</div>) 3184 - // return ( 3185 - // <VideoEmbed 3186 - // embed={embed} 3187 - // crop={ 3188 - // viewContext === PostEmbedViewContext.ThreadHighlighted 3189 - // ? 'none' 3190 - // : viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia 3191 - // ? 'square' 3192 - // : 'constrained' 3193 - // } 3194 - // /> 3195 - // ) 3196 - } 3197 - 3198 - return <div />; 3199 - } 3200 - 3201 - function getDomain(url: string) { 3202 - try { 3203 - const { hostname } = new URL(url); 3204 - return hostname; 3205 - } catch (e) { 3206 - // In case it's a bare domain like "example.com" 3207 - if (!url.startsWith("http")) { 3208 - try { 3209 - const { hostname } = new URL("http://" + url); 3210 - return hostname; 3211 - } catch { 3212 - return null; 3213 - } 3214 - } 3215 - return null; 3216 - } 3217 - } 3218 - function getByteToCharMap(text: string): number[] { 3219 - const encoder = new TextEncoder(); 3220 - //const utf8 = encoder.encode(text); 3221 - 3222 - const map: number[] = []; 3223 - let byteIndex = 0; 3224 - let charIndex = 0; 3225 - 3226 - for (const char of text) { 3227 - const bytes = encoder.encode(char); 3228 - for (let i = 0; i < bytes.length; i++) { 3229 - map[byteIndex++] = charIndex; 3230 - } 3231 - charIndex += char.length; 3232 - } 3233 - 3234 - return map; 3235 - } 3236 - 3237 - function facetByteRangeToCharRange( 3238 - byteStart: number, 3239 - byteEnd: number, 3240 - byteToCharMap: number[], 3241 - ): [number, number] { 3242 - return [ 3243 - byteToCharMap[byteStart] ?? 0, 3244 - byteToCharMap[byteEnd - 1]! + 1, // inclusive end -> exclusive char end 3245 - ]; 3246 - } 3247 - 3248 - interface FacetRange { 3249 - start: number; 3250 - end: number; 3251 - feature: Facet["features"][number]; 3252 - } 3253 - 3254 - function extractFacetRanges(text: string, facets: Facet[]): FacetRange[] { 3255 - const map = getByteToCharMap(text); 3256 - return facets.map((f) => { 3257 - const [start, end] = facetByteRangeToCharRange( 3258 - f.index.byteStart, 3259 - f.index.byteEnd, 3260 - map, 3261 - ); 3262 - return { start, end, feature: f.features[0] }; 3263 - }); 3264 - } 3265 - export function renderTextWithFacets({ 3266 - text, 3267 - facets, 3268 - navigate, 3269 - }: { 3270 - text: string; 3271 - facets: Facet[]; 3272 - navigate: (_: any) => void; 3273 - }) { 3274 - const ranges = extractFacetRanges(text, facets).sort( 3275 - (a: any, b: any) => a.start - b.start, 3276 - ); 3277 - 3278 - const result: React.ReactNode[] = []; 3279 - let current = 0; 3280 - 3281 - for (const { start, end, feature } of ranges) { 3282 - if (current < start) { 3283 - result.push(<span key={current}>{text.slice(current, start)}</span>); 3284 - } 3285 - 3286 - const fragment = text.slice(start, end); 3287 - // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 3288 - if (feature.$type === "app.bsky.richtext.facet#link" && feature.uri) { 3289 - result.push( 3290 - <a 3291 - // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 3292 - href={feature.uri} 3293 - key={start} 3294 - className="link" 3295 - style={{ 3296 - textDecoration: "none", 3297 - color: "var(--link-text-color)", 3298 - wordBreak: "break-all", 3299 - }} 3300 - target="_blank" 3301 - rel="noreferrer" 3302 - onClick={(e) => { 3303 - e.stopPropagation(); 3304 - }} 3305 - > 3306 - {fragment} 3307 - </a>, 3308 - ); 3309 - } else if ( 3310 - feature.$type === "app.bsky.richtext.facet#mention" && 3311 - // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 3312 - feature.did 3313 - ) { 3314 - result.push( 3315 - <span 3316 - key={start} 3317 - style={{ color: "var(--link-text-color)" }} 3318 - className=" cursor-pointer" 3319 - onClick={(e) => { 3320 - e.stopPropagation(); 3321 - navigate({ 3322 - to: "/profile/$did", 3323 - // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 3324 - params: { did: feature.did }, 3325 - }); 3326 - }} 3327 - > 3328 - {fragment} 3329 - </span>, 3330 - ); 3331 - } else if (feature.$type === "app.bsky.richtext.facet#tag") { 3332 - result.push( 3333 - <span 3334 - key={start} 3335 - style={{ color: "var(--link-text-color)" }} 3336 - onClick={(e) => { 3337 - e.stopPropagation(); 3338 - }} 3339 - > 3340 - {fragment} 3341 - </span>, 3342 - ); 3343 - } else { 3344 - result.push(<span key={start}>{fragment}</span>); 3345 - } 3346 - 3347 - current = end; 3348 - } 3349 - 3350 - if (current < text.length) { 3351 - result.push(<span key={current}>{text.slice(current)}</span>); 3352 - } 3353 - 3354 - return result; 3355 - } 3356 - function ExternalLinkEmbed({ 3357 - link, 3358 - onOpen, 3359 - style, 3360 - }: { 3361 - link: AppBskyEmbedExternal.ViewExternal; 3362 - onOpen?: () => void; 3363 - style?: React.CSSProperties; 3364 - }) { 3365 - //const { theme } = useTheme(); 3366 - const { uri, title, description, thumb } = link; 3367 - const thumbAspectRatio = 1.91; 3368 - const titleStyle = { 3369 - fontSize: 16, 3370 - fontWeight: 700, 3371 - marginBottom: 4, 3372 - //color: theme.text, 3373 - wordBreak: "break-word", 3374 - textAlign: "left", 3375 - maxHeight: "4em", // 2 lines * 1.5em line-height 3376 - // stupid shit 3377 - display: "-webkit-box", 3378 - WebkitBoxOrient: "vertical", 3379 - overflow: "hidden", 3380 - WebkitLineClamp: 2, 3381 - }; 3382 - const descriptionStyle = { 3383 - fontSize: 14, 3384 - //color: theme.textSecondary, 3385 - marginBottom: 8, 3386 - wordBreak: "break-word", 3387 - textAlign: "left", 3388 - maxHeight: "5em", // 3 lines * 1.5em line-height 3389 - // stupid shit 3390 - display: "-webkit-box", 3391 - WebkitBoxOrient: "vertical", 3392 - overflow: "hidden", 3393 - WebkitLineClamp: 3, 3394 - }; 3395 - const linkStyle = { 3396 - textDecoration: "none", 3397 - //color: theme.textSecondary, 3398 - wordBreak: "break-all", 3399 - textAlign: "left", 3400 - }; 3401 - const containerStyle = { 3402 - display: "flex", 3403 - flexDirection: "column", 3404 - //backgroundColor: theme.background, 3405 - //background: '#eee', 3406 - borderRadius: 12, 3407 - //border: `1px solid ${theme.border}`, 3408 - //boxShadow: theme.cardShadow, 3409 - maxWidth: "100%", 3410 - overflow: "hidden", 3411 - ...style, 3412 - }; 3413 - return ( 3414 - <a 3415 - href={uri} 3416 - target="_blank" 3417 - rel="noopener noreferrer" 3418 - onClick={(e) => { 3419 - e.stopPropagation(); 3420 - if (onOpen) onOpen(); 3421 - }} 3422 - /* @ts-expect-error css arent typed or something idk fuck you */ 3423 - style={linkStyle} 3424 - className="text-gray-500 dark:text-gray-400" 3425 - > 3426 - <div 3427 - style={containerStyle as React.CSSProperties} 3428 - className="border border-gray-200 dark:border-gray-800 was7" 3429 - > 3430 - {thumb && ( 3431 - <div 3432 - style={{ 3433 - position: "relative", 3434 - width: "100%", 3435 - aspectRatio: thumbAspectRatio, 3436 - overflow: "hidden", 3437 - borderTopLeftRadius: 12, 3438 - borderTopRightRadius: 12, 3439 - marginBottom: 8, 3440 - //borderBottom: `1px solid ${theme.border}`, 3441 - }} 3442 - className="border-b border-gray-200 dark:border-gray-800 was7" 3443 - > 3444 - <img 3445 - src={thumb} 3446 - alt={description} 3447 - style={{ 3448 - position: "absolute", 3449 - top: 0, 3450 - left: 0, 3451 - width: "100%", 3452 - height: "100%", 3453 - objectFit: "cover", 3454 - }} 3455 - /> 3456 - </div> 3457 - )} 3458 - <div 3459 - style={{ 3460 - paddingBottom: 12, 3461 - paddingLeft: 12, 3462 - paddingRight: 12, 3463 - paddingTop: thumb ? 0 : 12, 3464 - }} 3465 - > 3466 - {/* @ts-expect-error css */} 3467 - <div style={titleStyle} className="text-gray-900 dark:text-gray-100"> 3468 - {title} 3469 - </div> 3470 - <div 3471 - style={descriptionStyle as React.CSSProperties} 3472 - className="text-gray-500 dark:text-gray-400" 3473 - > 3474 - {description} 3475 - </div> 3476 - {/* small 1px divider here */} 3477 - <div 3478 - style={{ 3479 - height: 1, 3480 - //backgroundColor: theme.border, 3481 - marginBottom: 8, 3482 - }} 3483 - className="bg-gray-200 dark:bg-gray-700" 3484 - /> 3485 - <div 3486 - style={{ 3487 - display: "flex", 3488 - alignItems: "center", 3489 - gap: 4, 3490 - }} 3491 - > 3492 - <MdiGlobe /> 3493 - <span 3494 - style={{ 3495 - fontSize: 12, 3496 - //color: theme.textSecondary 3497 - }} 3498 - className="text-gray-500 dark:text-gray-400" 3499 - > 3500 - {getDomain(uri)} 3501 - </span> 3502 - </div> 3503 - </div> 3504 - </div> 3505 - </a> 3506 - ); 3507 - } 3508 - 3509 - const SmartHLSPlayer = ({ 3510 - url, 3511 - thumbnail, 3512 - aspect, 3513 - }: { 3514 - url: string; 3515 - thumbnail?: string; 3516 - aspect?: AppBskyEmbedDefs.AspectRatio; 3517 - }) => { 3518 - const [playing, setPlaying] = useState(false); 3519 - const containerRef = useRef(null); 3520 - 3521 - // pause the player if it goes out of viewport 3522 - useEffect(() => { 3523 - const observer = new IntersectionObserver( 3524 - ([entry]) => { 3525 - if (!entry.isIntersecting && playing) { 3526 - setPlaying(false); 3527 - } 3528 - }, 3529 - { 3530 - root: null, 3531 - threshold: 0.25, 3532 - }, 3533 - ); 3534 - 3535 - if (containerRef.current) { 3536 - observer.observe(containerRef.current); 3537 - } 3538 - 3539 - return () => { 3540 - if (containerRef.current) { 3541 - observer.unobserve(containerRef.current); 3542 - } 3543 - }; 3544 - }, [playing]); 3545 - 3546 - return ( 3547 - <div 3548 - ref={containerRef} 3549 - style={{ 3550 - position: "relative", 3551 - width: "100%", 3552 - maxWidth: 640, 3553 - cursor: "pointer", 3554 - }} 3555 - > 3556 - {!playing && ( 3557 - <> 3558 - <img 3559 - src={thumbnail} 3560 - alt="Video thumbnail" 3561 - style={{ 3562 - width: "100%", 3563 - display: "block", 3564 - aspectRatio: aspect ? aspect?.width / aspect?.height : 16 / 9, 3565 - borderRadius: 12, 3566 - //border: `1px solid ${theme.border}`, 3567 - }} 3568 - className="border border-gray-200 dark:border-gray-800 was7" 3569 - onClick={async (e) => { 3570 - e.stopPropagation(); 3571 - setPlaying(true); 3572 - }} 3573 - /> 3574 - <div 3575 - onClick={async (e) => { 3576 - e.stopPropagation(); 3577 - setPlaying(true); 3578 - }} 3579 - style={{ 3580 - position: "absolute", 3581 - top: "50%", 3582 - left: "50%", 3583 - transform: "translate(-50%, -50%)", 3584 - //fontSize: 48, 3585 - color: "white", 3586 - //textShadow: theme.cardShadow, 3587 - pointerEvents: "none", 3588 - userSelect: "none", 3589 - }} 3590 - className="text-shadow-md" 3591 - > 3592 - {/*▶️*/} 3593 - <MdiPlayCircle /> 3594 - </div> 3595 - </> 3596 - )} 3597 - {playing && ( 3598 - <div 3599 - style={{ 3600 - position: "relative", 3601 - width: "100%", 3602 - borderRadius: 12, 3603 - overflow: "hidden", 3604 - //border: `1px solid ${theme.border}`, 3605 - paddingTop: `${ 3606 - 100 / (aspect ? aspect.width / aspect.height : 16 / 9) 3607 - }%`, // 16:9 = 56.25%, 4:3 = 75% 3608 - }} 3609 - className="border border-gray-200 dark:border-gray-800 was7" 3610 - > 3611 - <ReactPlayer 3612 - src={url} 3613 - playing={true} 3614 - controls={true} 3615 - width="100%" 3616 - height="100%" 3617 - style={{ position: "absolute", top: 0, left: 0 }} 3618 - /> 3619 - {/* <ReactPlayer 3620 - url={url} 3621 - playing={true} 3622 - controls={true} 3623 - width="100%" 3624 - style={{width: "100% !important", aspectRatio: aspect ? aspect?.width/aspect?.height : 16/9}} 3625 - onPause={() => setPlaying(false)} 3626 - onEnded={() => setPlaying(false)} 3627 - /> */} 3628 - </div> 3629 - )} 3630 - </div> 3631 - ); 3632 - };
··· 1 import * as ATPAPI from "@atproto/api"; 2 + import { 3 + AppBskyActorDefs, 4 + AppBskyFeedDefs, 5 + AppBskyFeedPost, 6 + AtUri, 7 + type Facet, 8 + } from "@atproto/api"; 9 + import { useInfiniteQuery } from "@tanstack/react-query"; 10 import { useNavigate } from "@tanstack/react-router"; 11 import DOMPurify from "dompurify"; 12 import { useAtom } from "jotai"; 13 import { DropdownMenu } from "radix-ui"; 14 import { HoverCard } from "radix-ui"; 15 import * as React from "react"; 16 + import { useEffect, useState } from "react"; 17 18 + import defaultpfp from "~/../public/favicon.png"; 19 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 20 + import { renderSnack } from "~/routes/__root"; 21 + import { 22 + FollowButton, 23 + Mutual, 24 + } from "~/routes/profile.$did"; 25 + import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 26 import { 27 composerAtom, 28 constellationURLAtom, ··· 30 enableWafrnTextAtom, 31 imgCDNAtom, 32 } from "~/utils/atoms"; 33 + import { useFastLike } from "~/utils/likeMutationQueue"; 34 import { useHydratedEmbed } from "~/utils/useHydrated"; 35 import { 36 useQueryConstellation, 37 useQueryIdentity, 38 useQueryPost, ··· 40 yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks, 41 } from "~/utils/useQuery"; 42 43 + import { PostEmbeds } from "./PostEmbeds"; 44 + import { 45 + btnstyle, 46 + fullDateTimeFormat, 47 + HitSlopButton, 48 + randomString, 49 + renderTextWithFacets, 50 + shortTimeAgo, 51 + } from "./UtilityFunctions"; 52 53 export interface UniversalPostRendererATURILoaderProps { 54 atUri: string; ··· 72 filterMustBeReply?: boolean; 73 } 74 75 export function UniversalPostRendererATURILoader({ 76 atUri, 77 onConstellation, ··· 93 filterMustHaveMedia, 94 filterMustBeReply, 95 }: UniversalPostRendererATURILoaderProps) { 96 const TEMPLINEAR = true; 97 const parsed = new AtUri(atUri); 98 const did = parsed?.host; 99 const rkey = parsed?.rkey; 100 101 const { 102 data: postQuery, 103 isLoading: isPostLoading, 104 isError: isPostError, 105 } = useQueryPost(atUri); 106 107 const { data: resolved } = useQueryIdentity(did || ""); 108 109 const { data: links } = useQueryConstellation({ 110 method: "/links/all", 111 target: atUri, 112 }); 113 114 const { data: opProfile } = useQueryProfile( 115 resolved ? `at://${resolved?.did}/app.bsky.actor.profile/self` : undefined, 116 ); 117 118 const [likes, setLikes] = React.useState<number | null>(null); 119 const [reposts, setReposts] = React.useState<number | null>(null); 120 const [replies, setReplies] = React.useState<number | null>(null); 121 122 React.useEffect(() => { 123 setLikes( 124 links 125 ? links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0 ··· 137 : null, 138 ); 139 }, [links]); 140 141 const [constellationurl] = useAtom(constellationURLAtom); 142 ··· 153 enabled: !!atUri && !!maxReplies && !isQuote, 154 }); 155 156 + const { data: repliesData } = infinitequeryresults; 157 158 useEffect(() => { 159 if (!maxReplies || isQuote || TEMPLINEAR) return; 160 if ( ··· 176 : [], 177 ) 178 : []; 179 180 const { oldestOpsReply, oldestOpsReplyElseNewestNonOpsReply } = (() => { 181 if (isQuote || !replyAturis || replyAturis.length === 0 || !maxReplies) ··· 184 oldestOpsReplyElseNewestNonOpsReply: undefined, 185 }; 186 187 + const opdid = new AtUri(atUri).host; 188 189 const opReplies = replyAturis.filter( 190 (aturi) => new AtUri(aturi).host === opdid, ··· 192 193 if (opReplies.length > 0) { 194 const opreply = opReplies[opReplies.length - 1]; 195 return { 196 oldestOpsReply: opreply, 197 oldestOpsReplyElseNewestNonOpsReply: opreply, ··· 204 } 205 })(); 206 207 if (!postQuery?.value) { 208 return <></>; 209 } 210 211 return ( 212 <> 213 <UniversalPostRendererRawRecordShim 214 detailed={detailed} 215 postRecord={postQuery} ··· 230 : bottomReplyLine 231 } 232 topReplyLine={topReplyLine} 233 bottomBorder={ 234 maxReplies && oldestOpsReplyElseNewestNonOpsReply 235 ? false ··· 239 } 240 feedviewpost={feedviewpost} 241 repostedby={repostedby} 242 style={style} 243 ref={ref} 244 dataIndexPropPass={dataIndexPropPass} ··· 254 <> 255 {maxReplies && maxReplies === 0 && replies && replies > 0 ? ( 256 <> 257 <MoreReplies atUri={atUri} /> 258 </> 259 ) : ( ··· 262 </> 263 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 264 <> 265 <UniversalPostRendererATURILoader 266 atUri={oldestOpsReplyElseNewestNonOpsReply} 267 bottomReplyLine={(maxReplies ?? 0) > 0} 268 topReplyLine={ ··· 312 opacity: 0.5, 313 }} 314 className="dark:bg-[repeating-linear-gradient(to_bottom,var(--color-gray-500)_0,var(--color-gray-400)_4px,transparent_4px,transparent_8px)]" 315 /> 316 </div> 317 ··· 381 filterMustHaveMedia?: boolean; 382 filterMustBeReply?: boolean; 383 }) { 384 const navigate = useNavigate(); 385 386 const hasEmbed = (postRecord?.value as ATPAPI.AppBskyFeedPost.Record)?.embed; 387 const hasImages = hasEmbed?.$type === "app.bsky.embed.images"; 388 const hasVideo = hasEmbed?.$type === "app.bsky.embed.video"; ··· 408 409 const [imgcdn] = useAtom(imgCDNAtom); 410 411 + const parsedaturi = new AtUri(aturi); 412 413 const fakeprofileviewbasic = React.useMemo<AppBskyActorDefs.ProfileViewBasic>( 414 () => ({ ··· 463 ], 464 ); 465 466 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent 467 ?.uri; 468 const feedviewpostreplydid = ··· 486 487 return ( 488 <> 489 <UniversalPostRenderer 490 expanded={detailed} 491 onPostClick={() => ··· 495 params: { did: parsedaturi.host, rkey: parsedaturi.rkey }, 496 }) 497 } 498 onProfileClick={(e) => { 499 e.stopPropagation(); 500 if (parsedaturi) { ··· 510 bottomReplyLine={bottomReplyLine} 511 topReplyLine={topReplyLine} 512 bottomBorder={bottomBorder} 513 feedviewpostreplyhandle={feedviewpostreplyhandle} 514 repostedby={feedviewpostrepostedbyhandle} 515 style={style} ··· 526 ); 527 } 528 529 + export function UniversalPostRenderer({ 530 post, 531 uprrrsauthor, 532 onPostClick, 533 onProfileClick, 534 expanded, 535 isQuote, 536 extraOptionalItemInfo, 537 bottomReplyLine, 538 topReplyLine, ··· 550 maxReplies, 551 constellationLinks, 552 }: { 553 + post: AppBskyFeedDefs.PostView; 554 uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed; 555 onPostClick?: (e: React.MouseEvent) => void; 556 onProfileClick?: (e: React.MouseEvent) => void; 557 expanded?: boolean; 558 isQuote?: boolean; 559 + extraOptionalItemInfo?: AppBskyFeedDefs.FeedViewPost; 560 bottomReplyLine?: boolean; 561 topReplyLine?: boolean; 562 salt: string; ··· 584 post.viewer?.repost, 585 ); 586 const { liked, toggle, backfill } = useFastLike(post.uri, post.cid); 587 588 const repostOrUnrepostPost = async () => { 589 if (!agent) { ··· 653 (showBridgyText ? unfedibridgy : undefined) ?? 654 (showWafrnText ? unfediwafrn : undefined); 655 656 const isMainItem = false; 657 const setMainItem = (any: any) => {}; 658 + 659 return ( 660 <div ref={ref} style={style} data-index={dataIndexPropPass}> 661 <div 662 key={salt + "-" + (post.uri || emergencySalt)} 663 onClick={ 664 isMainItem ··· 675 : undefined 676 } 677 style={{ 678 opacity: "1 !important", 679 background: "transparent", 680 paddingLeft: isQuote ? 12 : 16, 681 paddingRight: isQuote ? 12 : 16, 682 paddingTop: isRepost ? 10 : isQuote ? 12 : topReplyLine ? 8 : 16, 683 paddingBottom: 0, 684 fontFamily: "system-ui, sans-serif", 685 position: "relative", 686 borderBottomWidth: bottomBorder ? (isQuote ? 0 : 1) : 0, 687 }} 688 className="border-gray-300 dark:border-gray-800" ··· 697 fontSize: 14, 698 maxHeight: "1rem", 699 justifyContent: "flex-start", 700 gap: 4, 701 alignItems: "center", 702 }} 703 className="text-gray-500 dark:text-gray-400" 704 > 705 + <IconMdiRepost /> Reposted by @{isRepost} 706 </div> 707 )} 708 {!isQuote && ( 709 <div 710 style={{ 711 + opacity: topReplyLine || isReply ? 0.5 : 0, 712 position: "absolute", 713 top: 0, 714 + left: 36, 715 width: 2, 716 height: isRepost 717 ? "calc(16px + 1rem - 6px)" 718 : topReplyLine 719 ? 8 - 6 720 : 16 - 6, 721 }} 722 className="bg-gray-500 dark:bg-gray-400" 723 /> ··· 770 <div className="flex flex-col gap-3"> 771 <div> 772 <div className="text-gray-900 dark:text-gray-100 font-medium text-md"> 773 + {post.author.displayName || post.author.handle} 774 </div> 775 <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1"> 776 <Mutual targetdidorhandle={post.author.did} />@ 777 + {post.author.handle} 778 </div> 779 </div> 780 {uprrrsauthor?.description && ( ··· 782 {uprrrsauthor.description} 783 </div> 784 )} 785 </div> 786 </div> 787 </HoverCard.Content> 788 </HoverCard.Portal> 789 </HoverCard.Root> ··· 800 marginRight: expanded || isQuote ? 0 : 12, 801 }} 802 > 803 <div style={{ width: 42, height: 42 + 6, minHeight: 42 + 6 }} /> 804 {bottomReplyLine && ( 805 <div 806 style={{ 807 width: 2, 808 height: "100%", 809 opacity: 0.5, 810 }} 811 className="bg-gray-500 dark:bg-gray-400" 812 /> 813 )} 814 </div> 815 <div style={{ flex: 1, maxWidth: "100%" }}> 816 <div ··· 828 <div 829 style={{ 830 display: "flex", 831 overflow: "hidden", 832 textOverflow: "ellipsis", 833 flexShrink: 1, ··· 852 minWidth: 0, 853 gap: 4, 854 alignItems: "center", 855 }} 856 className="text-gray-900 dark:text-gray-100" 857 > 858 + {post.author.displayName || post.author.handle} 859 {post.author.verification?.verifiedStatus == "valid" && ( 860 + <IconMdiVerified /> 861 )} 862 </span> 863 864 <span 865 style={{ 866 fontSize: 16, 867 overflowX: "hidden", 868 textOverflow: "ellipsis", ··· 885 > 886 <span 887 style={{ 888 fontSize: 16, 889 marginLeft: 8, 890 whiteSpace: "nowrap", ··· 893 }} 894 className="text-gray-500 dark:text-gray-400" 895 > 896 + · {shortTimeAgo(post.indexedAt)} 897 </span> 898 </div> 899 </div> 900 {!!feedviewpostreplyhandle && ( 901 <div 902 style={{ ··· 905 paddingBottom: 2, 906 fontSize: 14, 907 justifyContent: "flex-start", 908 gap: 4, 909 alignItems: "center", 910 height: 911 !(expanded || isQuote) && !!feedviewpostreplyhandle 912 ? "1rem" ··· 916 }} 917 className="text-gray-500 dark:text-gray-400" 918 > 919 + <IconMdiReply /> Reply to @{feedviewpostreplyhandle} 920 </div> 921 )} 922 <div ··· 958 {post.embed && depth < 1 && !concise ? ( 959 <PostEmbeds 960 embed={post.embed} 961 viewContext={PostEmbedViewContext.Feed} 962 salt={salt} 963 navigate={navigate} ··· 968 /> 969 ) : null} 970 {post.embed && depth > 0 && ( 971 <> 972 <div className="border-gray-300 dark:border-gray-800 p-3 rounded-xl border italic text-gray-400 text-[14px]"> 973 (there is an embed here thats too deep to render) ··· 984 <div 985 style={{ 986 overflow: "hidden", 987 fontSize: 14, 988 display: "flex", 989 borderBottomStyle: "solid", 990 paddingTop: 4, 991 paddingBottom: 8, 992 borderBottomWidth: 1, 993 marginBottom: 8, 994 + }} 995 className="text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-800 was7" 996 > 997 {fullDateTimeFormat(post.indexedAt)} ··· 1004 display: "flex", 1005 gap: 32, 1006 paddingTop: 8, 1007 fontSize: 15, 1008 justifyContent: "space-between", 1009 }} 1010 className="text-gray-500 dark:text-gray-400" 1011 > ··· 1017 ...btnstyle, 1018 }} 1019 > 1020 + <IconMdiCommentOutline /> 1021 {post.replyCount} 1022 </HitSlopButton> 1023 <DropdownMenu.Root modal={false}> ··· 1029 }} 1030 aria-label="Repost or quote post" 1031 > 1032 + {hasRetweeted ? <IconMdiRepeat color="#5CEFAA" /> : <IconMdiRepeat />} 1033 {post.repostCount ?? 0} 1034 </div> 1035 </DropdownMenu.Trigger> ··· 1044 onSelect={repostOrUnrepostPost} 1045 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" 1046 > 1047 + <IconMdiRepeat 1048 className={hasRetweeted ? "text-green-400" : ""} 1049 /> 1050 <span>{hasRetweeted ? "Undo Repost" : "Repost"}</span> ··· 1059 }} 1060 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" 1061 > 1062 + <IconMdiCommentOutline /> 1063 <span>Quote</span> 1064 </DropdownMenu.Item> 1065 </DropdownMenu.Content> ··· 1074 ...(liked ? { color: "#EC4899" } : {}), 1075 }} 1076 > 1077 + {liked ? <IconMdiCardsHeart /> : <IconMdiCardsHeartOutline />} 1078 {(post.likeCount || 0) + (liked ? 1 : 0)} 1079 </HitSlopButton> 1080 <div style={{ display: "flex", gap: 8 }}> ··· 1093 title: "Copied to clipboard!", 1094 }); 1095 } catch (_e) { 1096 renderSnack({ 1097 title: "Failed to copy link", 1098 }); ··· 1102 ...btnstyle, 1103 }} 1104 > 1105 + <IconMdiShareVariant /> 1106 </HitSlopButton> 1107 <HitSlopButton 1108 onClick={() => { ··· 1112 }} 1113 > 1114 <span style={btnstyle}> 1115 + <IconMdiMoreHoriz /> 1116 </span> 1117 </HitSlopButton> 1118 </div> ··· 1121 </div> 1122 <div 1123 style={{ 1124 height: isQuote ? 12 : 16, 1125 }} 1126 /> ··· 1131 ); 1132 } 1133 1134 enum PostEmbedViewContext { 1135 ThreadHighlighted = "ThreadHighlighted", 1136 Feed = "Feed", 1137 FeedEmbedRecordWithMedia = "FeedEmbedRecordWithMedia", 1138 }
+255
src/components/UtilityFunctions.tsx
···
··· 1 + import type { $Typed,Facet } from "@atproto/api"; 2 + import * as React from "react"; 3 + 4 + export const CACHE_TIMEOUT = 5 * 60 * 1000; 5 + const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour 6 + 7 + export function asTyped<T extends { $type: string }>(obj: T): $Typed<T> { 8 + return obj as $Typed<T>; 9 + } 10 + 11 + export const fullDateTimeFormat = (iso: string) => { 12 + const date = new Date(iso); 13 + return date.toLocaleString("en-US", { 14 + month: "long", 15 + day: "numeric", 16 + year: "numeric", 17 + hour: "numeric", 18 + minute: "2-digit", 19 + hour12: true, 20 + }); 21 + }; 22 + 23 + export const shortTimeAgo = (iso: string) => { 24 + const diff = Date.now() - new Date(iso).getTime(); 25 + const mins = Math.floor(diff / 60000); 26 + if (mins < 1) return "now"; 27 + if (mins < 60) return `${mins}m`; 28 + const hrs = Math.floor(mins / 60); 29 + if (hrs < 24) return `${hrs}h`; 30 + const days = Math.floor(hrs / 24); 31 + return `${days}d`; 32 + }; 33 + 34 + export function getByteToCharMap(text: string): number[] { 35 + const encoder = new TextEncoder(); 36 + 37 + const map: number[] = []; 38 + let byteIndex = 0; 39 + let charIndex = 0; 40 + 41 + for (const char of text) { 42 + const bytes = encoder.encode(char); 43 + for (let i = 0; i < bytes.length; i++) { 44 + map[byteIndex++] = charIndex; 45 + } 46 + charIndex += char.length; 47 + } 48 + 49 + return map; 50 + } 51 + 52 + export function facetByteRangeToCharRange( 53 + byteStart: number, 54 + byteEnd: number, 55 + byteToCharMap: number[], 56 + ): [number, number] { 57 + return [ 58 + byteToCharMap[byteStart] ?? 0, 59 + byteToCharMap[byteEnd - 1]! + 1, // inclusive end -> exclusive char end 60 + ]; 61 + } 62 + 63 + interface FacetRange { 64 + start: number; 65 + end: number; 66 + feature: Facet["features"][number]; 67 + } 68 + 69 + export function extractFacetRanges( 70 + text: string, 71 + facets: Facet[], 72 + ): FacetRange[] { 73 + const map = getByteToCharMap(text); 74 + return facets.map((f) => { 75 + const [start, end] = facetByteRangeToCharRange( 76 + f.index.byteStart, 77 + f.index.byteEnd, 78 + map, 79 + ); 80 + return { start, end, feature: f.features[0] }; 81 + }); 82 + } 83 + 84 + export function renderTextWithFacets({ 85 + text, 86 + facets, 87 + navigate, 88 + }: { 89 + text: string; 90 + facets: Facet[]; 91 + navigate: (_: any) => void; 92 + }) { 93 + const ranges = extractFacetRanges(text, facets).sort( 94 + (a: any, b: any) => a.start - b.start, 95 + ); 96 + 97 + const result: React.ReactNode[] = []; 98 + let current = 0; 99 + 100 + for (const { start, end, feature } of ranges) { 101 + if (current < start) { 102 + result.push(<span key={current}>{text.slice(current, start)}</span>); 103 + } 104 + 105 + const fragment = text.slice(start, end); 106 + // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 107 + if (feature.$type === "app.bsky.richtext.facet#link" && feature.uri) { 108 + result.push( 109 + <a 110 + // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 111 + href={feature.uri} 112 + key={start} 113 + className="link" 114 + style={{ 115 + textDecoration: "none", 116 + color: "var(--link-text-color)", 117 + wordBreak: "break-all", 118 + }} 119 + target="_blank" 120 + rel="noreferrer" 121 + onClick={(e) => { 122 + e.stopPropagation(); 123 + }} 124 + > 125 + {fragment} 126 + </a>, 127 + ); 128 + } else if ( 129 + feature.$type === "app.bsky.richtext.facet#mention" && 130 + // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 131 + feature.did 132 + ) { 133 + result.push( 134 + <span 135 + key={start} 136 + style={{ color: "var(--link-text-color)" }} 137 + className=" cursor-pointer" 138 + onClick={(e) => { 139 + e.stopPropagation(); 140 + navigate({ 141 + to: "/profile/$did", 142 + // @ts-expect-error i didnt bother with the correct types here sorry. bsky api types are cursed 143 + params: { did: feature.did }, 144 + }); 145 + }} 146 + > 147 + {fragment} 148 + </span>, 149 + ); 150 + } else if (feature.$type === "app.bsky.richtext.facet#tag") { 151 + result.push( 152 + <span 153 + key={start} 154 + style={{ color: "var(--link-text-color)" }} 155 + onClick={(e) => { 156 + e.stopPropagation(); 157 + }} 158 + > 159 + {fragment} 160 + </span>, 161 + ); 162 + } else { 163 + result.push(<span key={start}>{fragment}</span>); 164 + } 165 + 166 + current = end; 167 + } 168 + 169 + if (current < text.length) { 170 + result.push(<span key={current}>{text.slice(current)}</span>); 171 + } 172 + 173 + return result; 174 + } 175 + 176 + export function getDomain(url: string) { 177 + try { 178 + const { hostname } = new URL(url); 179 + return hostname; 180 + } catch (e) { 181 + if (!url.startsWith("http")) { 182 + try { 183 + const { hostname } = new URL("http://" + url); 184 + return hostname; 185 + } catch { 186 + return null; 187 + } 188 + } 189 + return null; 190 + } 191 + } 192 + 193 + export function randomString(length = 8) { 194 + const chars = 195 + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 196 + return Array.from( 197 + { length }, 198 + () => chars[Math.floor(Math.random() * chars.length)], 199 + ).join(""); 200 + } 201 + 202 + export function HitSlopButton({ 203 + onClick, 204 + children, 205 + style = {}, 206 + ...rest 207 + }: React.HTMLAttributes<HTMLSpanElement> & { 208 + onClick?: (e: React.MouseEvent) => void; 209 + children: React.ReactNode; 210 + style?: React.CSSProperties; 211 + }) { 212 + return ( 213 + <span 214 + style={{ 215 + position: "relative", 216 + display: "inline-block", 217 + cursor: "pointer", 218 + }} 219 + > 220 + <span 221 + style={{ 222 + position: "absolute", 223 + top: -8, 224 + left: -8, 225 + right: -8, 226 + bottom: -8, 227 + zIndex: 0, 228 + }} 229 + onClick={(e) => { 230 + e.stopPropagation(); 231 + onClick?.(e); 232 + }} 233 + /> 234 + <span 235 + style={{ 236 + ...style, 237 + position: "relative", 238 + zIndex: 1, 239 + pointerEvents: "none", 240 + }} 241 + {...rest} 242 + > 243 + {children} 244 + </span> 245 + </span> 246 + ); 247 + } 248 + 249 + export const btnstyle = { 250 + display: "flex", 251 + gap: 4, 252 + cursor: "pointer", 253 + alignItems: "center", 254 + fontSize: 14, 255 + };
+4 -4
src/routes/__root.tsx
··· 22 import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; 23 import { Import } from "~/components/Import"; 24 import Login from "~/components/Login"; 25 import { NotFound } from "~/components/NotFound"; 26 - import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; 27 import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider"; 28 import { PollMutationQueueProvider } from "~/providers/PollMutationQueueProvider"; 29 import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; ··· 249 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 250 <nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start"> 251 <div className="flex items-center gap-3 mb-4"> 252 - <FluentEmojiHighContrastGlowingStar 253 className="h-8 w-8" 254 style={{ 255 color: ··· 506 507 <nav className="hidden sm:flex items-center lg:hidden h-screen flex-col gap-2 p-4 dark:border-gray-800 sticky top-0 self-start"> 508 <div className="flex items-center gap-3 mb-4"> 509 - <FluentEmojiHighContrastGlowingStar 510 className="h-8 w-8" 511 style={{ 512 color: ··· 843 ) : ( 844 <div className="lg:hidden flex items-center fixed bottom-0 left-0 right-0 justify-between px-4 py-3 border-0 shadow border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 z-10"> 845 <div className="flex items-center gap-2"> 846 - <FluentEmojiHighContrastGlowingStar 847 className="h-6 w-6" 848 style={{ 849 color:
··· 22 import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; 23 import { Import } from "~/components/Import"; 24 import Login from "~/components/Login"; 25 + import Logo from "~/components/LogoSvg"; 26 import { NotFound } from "~/components/NotFound"; 27 import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider"; 28 import { PollMutationQueueProvider } from "~/providers/PollMutationQueueProvider"; 29 import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; ··· 249 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 250 <nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start"> 251 <div className="flex items-center gap-3 mb-4"> 252 + <Logo 253 className="h-8 w-8" 254 style={{ 255 color: ··· 506 507 <nav className="hidden sm:flex items-center lg:hidden h-screen flex-col gap-2 p-4 dark:border-gray-800 sticky top-0 self-start"> 508 <div className="flex items-center gap-3 mb-4"> 509 + <Logo 510 className="h-8 w-8" 511 style={{ 512 color: ··· 843 ) : ( 844 <div className="lg:hidden flex items-center fixed bottom-0 left-0 right-0 justify-between px-4 py-3 border-0 shadow border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 z-10"> 845 <div className="flex items-center gap-2"> 846 + <Logo 847 className="h-6 w-6" 848 style={{ 849 color:
+3 -6
src/routes/notifications.tsx
··· 11 useReusableTabScrollRestore, 12 } from "~/components/ReusableTabRoute"; 13 import { 14 - MdiCardsHeartOutline, 15 - MdiCommentOutline, 16 - MdiRepeat, 17 UniversalPostRendererATURILoader, 18 } from "~/components/UniversalPostRenderer"; 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; ··· 544 className="flex-1 border-t py-2 px-4 flex flex-row items-center gap-2 transition-colors hover:bg-gray-100 hover:dark:bg-gray-800" 545 > 546 {type === "like" ? ( 547 - <MdiCardsHeartOutline height={22} width={22} /> 548 ) : type === "repost" ? ( 549 - <MdiRepeat height={22} width={22} /> 550 ) : type === "reply" ? ( 551 - <MdiCommentOutline height={22} width={22} /> 552 ) : type === "quote" ? ( 553 <IconMdiMessageReplyTextOutline 554 height={22}
··· 11 useReusableTabScrollRestore, 12 } from "~/components/ReusableTabRoute"; 13 import { 14 UniversalPostRendererATURILoader, 15 } from "~/components/UniversalPostRenderer"; 16 import { useAuth } from "~/providers/UnifiedAuthProvider"; ··· 541 className="flex-1 border-t py-2 px-4 flex flex-row items-center gap-2 transition-colors hover:bg-gray-100 hover:dark:bg-gray-800" 542 > 543 {type === "like" ? ( 544 + <IconMdiCardsHeartOutline height={22} width={22} /> 545 ) : type === "repost" ? ( 546 + <IconMdiRepeat height={22} width={22} /> 547 ) : type === "reply" ? ( 548 + <IconMdiCommentOutline height={22} width={22} /> 549 ) : type === "quote" ? ( 550 <IconMdiMessageReplyTextOutline 551 height={22}
+1 -1
src/routes/profile.$did/index.tsx
··· 13 useReusableTabScrollRestore, 14 } from "~/components/ReusableTabRoute"; 15 import { 16 - renderTextWithFacets, 17 UniversalPostRendererATURILoader, 18 } from "~/components/UniversalPostRenderer"; 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 20 import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; 21 import {
··· 13 useReusableTabScrollRestore, 14 } from "~/components/ReusableTabRoute"; 15 import { 16 UniversalPostRendererATURILoader, 17 } from "~/components/UniversalPostRenderer"; 18 + import { renderTextWithFacets } from "~/components/UtilityFunctions"; 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 20 import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; 21 import {
-66
test-poll-implementation.md
··· 1 - # Poll Implementation Summary 2 - 3 - ## Implementation Complete! ✅ 4 - 5 - I have successfully implemented the poll embed functionality as requested: 6 - 7 - ### 1. Composer.tsx - Creating Poll Records ✅ 8 - 9 - - Modified the `handlePost` function to create an additional record in the `app.reddwarf.embed.poll` collection when a poll is created 10 - - Uses the same `rkey` as the main post 11 - - Includes all required schema fields: 12 - - `subject`: References the main post with URI and CID 13 - - `a`, `b`: Required poll options 14 - - `c`, `d`: Optional poll options 15 - - `expiry`: Poll expiration time 16 - - `multiple`: Set to false (can be made configurable later) 17 - - `createdAt`: Timestamp 18 - 19 - ### 2. UniversalPostRenderer.tsx - Detecting and Rendering Polls ✅ 20 - 21 - #### Constellation Links Integration 22 - 23 - - Added `constellationLinks` prop to `UniversalPostRenderer`, `UniversalPostRendererRawRecordShim`, and `PostEmbeds` 24 - - Modified `UniversalPostRendererATURILoader` to fetch constellation data and pass it through the component hierarchy 25 - - Updated all component calls to properly pass the links data 26 - 27 - #### Poll Detection Logic 28 - 29 - - Modified `PostEmbeds` function to check for `app.reddwarf.embed.poll` records in constellation links 30 - - When a poll record is found with the same `rkey`, it replaces the external embed with a `PollEmbed` component 31 - - The check happens before rendering external link embeds 32 - 33 - #### PollEmbed Component 34 - 35 - - Created a new `PollEmbed` component that fetches poll data using the existing `useQueryArbitrary` hook 36 - - Renders poll options in a clean, Material Design 3 style 37 - - Shows loading state while fetching poll data 38 - - Displays error state if poll fails to load 39 - - Shows poll expiry status and end date 40 - - Handles up to 4 poll options (A, B, C, D) 41 - 42 - ### 3. Data Flow ✅ 43 - 44 - 1. **Poll Creation**: User creates a post with poll in Composer 45 - 2. **Dual Records**: Two records are created with the same `rkey`: 46 - - Main post: `app.bsky.feed.post/{rkey}` 47 - - Poll embed: `app.reddwarf.embed.poll/{rkey}` 48 - 3. **Detection**: When posts are rendered, constellation links are checked for poll records 49 - 4. **Rendering**: If poll record exists, external embed is replaced with PollEmbed component 50 - 5. **Display**: Poll data is fetched and displayed in a beautiful card format 51 - 52 - ### 4. Integration Points ✅ 53 - 54 - - **Constellation**: Used for discovering poll records linked to posts 55 - - **Slingshot**: Used via `useQueryArbitrary` to fetch poll record data from user's PDS 56 - - **Existing Components**: Integrated seamlessly with current embed system 57 - - **UI Consistency**: Follows existing Material Design 3 patterns 58 - 59 - ### 5. Technical Details ✅ 60 - 61 - - **Schema Compliance**: Follows the exact schema provided 62 - - **Error Handling**: Graceful fallbacks if poll records fail to load 63 - - **Performance**: Uses existing TanStack Query caching system 64 - - **Type Safety**: Full TypeScript support with proper typing 65 - 66 - The implementation is now ready and should work seamlessly with the existing codebase. When users create posts with polls, they will see the poll embed instead of the external embed placeholder, and the poll data will be displayed in an interactive, visually appealing format.
···