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 19 const IconMaterialSymbolsTag: typeof import('~icons/material-symbols/tag.jsx').default 20 20 const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default 21 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 22 24 const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default 23 25 const IconMdiCheckCircle: typeof import('~icons/mdi/check-circle.jsx').default 24 26 const IconMdiCheckboxMultipleMarked: typeof import('~icons/mdi/checkbox-multiple-marked.jsx').default 25 27 const IconMdiClock: typeof import('~icons/mdi/clock.jsx').default 26 28 const IconMdiClockOutline: typeof import('~icons/mdi/clock-outline.jsx').default 27 29 const IconMdiClose: typeof import('~icons/mdi/close.jsx').default 30 + const IconMdiCommentOutline: typeof import('~icons/mdi/comment-outline.jsx').default 28 31 const IconMdiGlobe: typeof import('~icons/mdi/globe.jsx').default 29 32 const IconMdiLock: typeof import('~icons/mdi/lock.jsx').default 30 33 const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default 34 + const IconMdiMoreHoriz: typeof import('~icons/mdi/more-horiz.jsx').default 31 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 32 42 const IconMdiShield: typeof import('~icons/mdi/shield.jsx').default 33 43 const IconMdiShieldOutline: typeof import('~icons/mdi/shield-outline.jsx').default 44 + const IconMdiVerified: typeof import('~icons/mdi/verified.jsx').default 34 45 }
+1 -1
src/components/DefaultCatchBoundary.tsx
··· 1 + import type { ErrorComponentProps } from "@tanstack/react-router"; 1 2 import { 2 3 ErrorComponent, 3 4 Link, ··· 5 6 useMatch, 6 7 useRouter, 7 8 } from "@tanstack/react-router"; 8 - import type { ErrorComponentProps } from "@tanstack/react-router"; 9 9 10 10 export function DefaultCatchBoundary({ error }: ErrorComponentProps) { 11 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 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"; 2 10 import { useNavigate } from "@tanstack/react-router"; 3 11 import DOMPurify from "dompurify"; 4 12 import { useAtom } from "jotai"; 5 13 import { DropdownMenu } from "radix-ui"; 6 14 import { HoverCard } from "radix-ui"; 7 15 import * as React from "react"; 8 - import { type SVGProps } from "react"; 16 + import { useEffect, useState } from "react"; 9 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"; 10 26 import { 11 27 composerAtom, 12 28 constellationURLAtom, ··· 14 30 enableWafrnTextAtom, 15 31 imgCDNAtom, 16 32 } from "~/utils/atoms"; 33 + import { useFastLike } from "~/utils/likeMutationQueue"; 17 34 import { useHydratedEmbed } from "~/utils/useHydrated"; 18 35 import { 19 - useQueryArbitrary, 20 36 useQueryConstellation, 21 37 useQueryIdentity, 22 38 useQueryPost, ··· 24 40 yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks, 25 41 } from "~/utils/useQuery"; 26 42 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 43 + import { PostEmbeds } from "./PostEmbeds"; 44 + import { 45 + btnstyle, 46 + fullDateTimeFormat, 47 + HitSlopButton, 48 + randomString, 49 + renderTextWithFacets, 50 + shortTimeAgo, 51 + } from "./UtilityFunctions"; 33 52 34 53 export interface UniversalPostRendererATURILoaderProps { 35 54 atUri: string; ··· 53 72 filterMustBeReply?: boolean; 54 73 } 55 74 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 75 export function UniversalPostRendererATURILoader({ 150 76 atUri, 151 77 onConstellation, ··· 167 93 filterMustHaveMedia, 168 94 filterMustBeReply, 169 95 }: UniversalPostRendererATURILoaderProps) { 170 - // todo remove this once tree rendering is implemented, use a prop like isTree 171 96 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 97 const parsed = new AtUri(atUri); 187 98 const did = parsed?.host; 188 99 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 100 221 101 const { 222 102 data: postQuery, 223 103 isLoading: isPostLoading, 224 104 isError: isPostError, 225 105 } = 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 106 244 107 const { data: resolved } = useQueryIdentity(did || ""); 245 108 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 109 const { data: links } = useQueryConstellation({ 309 110 method: "/links/all", 310 111 target: atUri, 311 112 }); 312 113 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 114 const { data: opProfile } = useQueryProfile( 379 115 resolved ? `at://${resolved?.did}/app.bsky.actor.profile/self` : undefined, 380 116 ); 381 117 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 118 const [likes, setLikes] = React.useState<number | null>(null); 393 119 const [reposts, setReposts] = React.useState<number | null>(null); 394 120 const [replies, setReplies] = React.useState<number | null>(null); 395 121 396 122 React.useEffect(() => { 397 - // /*mass comment*/ console.log(JSON.stringify(links, null, 2)); 398 123 setLikes( 399 124 links 400 125 ? links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0 ··· 412 137 : null, 413 138 ); 414 139 }, [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 140 423 141 const [constellationurl] = useAtom(constellationURLAtom); 424 142 ··· 435 153 enabled: !!atUri && !!maxReplies && !isQuote, 436 154 }); 437 155 438 - const { 439 - data: repliesData, 440 - // fetchNextPage, 441 - // hasNextPage, 442 - // isFetchingNextPage, 443 - } = infinitequeryresults; 156 + const { data: repliesData } = infinitequeryresults; 444 157 445 - // auto-fetch all pages 446 158 useEffect(() => { 447 159 if (!maxReplies || isQuote || TEMPLINEAR) return; 448 160 if ( ··· 464 176 : [], 465 177 ) 466 178 : []; 467 - 468 - //const [oldestOpsReply, setOldestOpsReply] = useState<string | undefined>(undefined); 469 179 470 180 const { oldestOpsReply, oldestOpsReplyElseNewestNonOpsReply } = (() => { 471 181 if (isQuote || !replyAturis || replyAturis.length === 0 || !maxReplies) ··· 474 184 oldestOpsReplyElseNewestNonOpsReply: undefined, 475 185 }; 476 186 477 - const opdid = new AtUri( 478 - //postQuery?.value.reply?.root.uri ?? postQuery?.uri ?? atUri 479 - atUri, 480 - ).host; 187 + const opdid = new AtUri(atUri).host; 481 188 482 189 const opReplies = replyAturis.filter( 483 190 (aturi) => new AtUri(aturi).host === opdid, ··· 485 192 486 193 if (opReplies.length > 0) { 487 194 const opreply = opReplies[opReplies.length - 1]; 488 - //setOldestOpsReply(opreply); 489 195 return { 490 196 oldestOpsReply: opreply, 491 197 oldestOpsReplyElseNewestNonOpsReply: opreply, ··· 498 204 } 499 205 })(); 500 206 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 207 if (!postQuery?.value) { 511 - // deleted post more often than a non-resolvable post 512 208 return <></>; 513 209 } 514 210 515 211 return ( 516 212 <> 517 - {/* <span>uprrs {maxReplies} {!!maxReplies&&!!oldestOpsReplyElseNewestNonOpsReply ? "true" : "false"}</span> */} 518 213 <UniversalPostRendererRawRecordShim 519 214 detailed={detailed} 520 215 postRecord={postQuery} ··· 535 230 : bottomReplyLine 536 231 } 537 232 topReplyLine={topReplyLine} 538 - //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} 539 233 bottomBorder={ 540 234 maxReplies && oldestOpsReplyElseNewestNonOpsReply 541 235 ? false ··· 545 239 } 546 240 feedviewpost={feedviewpost} 547 241 repostedby={repostedby} 548 - //style={{...style, background: oldestOpsReply === atUri ? "Red" : undefined}} 549 242 style={style} 550 243 ref={ref} 551 244 dataIndexPropPass={dataIndexPropPass} ··· 561 254 <> 562 255 {maxReplies && maxReplies === 0 && replies && replies > 0 ? ( 563 256 <> 564 - {/* <div>hello</div> */} 565 257 <MoreReplies atUri={atUri} /> 566 258 </> 567 259 ) : ( ··· 570 262 </> 571 263 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 572 264 <> 573 - {/* <span>hello {maxReplies}</span> */} 574 265 <UniversalPostRendererATURILoader 575 - //detailed={detailed} 576 266 atUri={oldestOpsReplyElseNewestNonOpsReply} 577 267 bottomReplyLine={(maxReplies ?? 0) > 0} 578 268 topReplyLine={ ··· 622 312 opacity: 0.5, 623 313 }} 624 314 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 315 /> 627 316 </div> 628 317 ··· 692 381 filterMustHaveMedia?: boolean; 693 382 filterMustBeReply?: boolean; 694 383 }) { 695 - // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`); 696 384 const navigate = useNavigate(); 697 385 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 386 const hasEmbed = (postRecord?.value as ATPAPI.AppBskyFeedPost.Record)?.embed; 765 387 const hasImages = hasEmbed?.$type === "app.bsky.embed.images"; 766 388 const hasVideo = hasEmbed?.$type === "app.bsky.embed.video"; ··· 786 408 787 409 const [imgcdn] = useAtom(imgCDNAtom); 788 410 789 - const parsedaturi = new AtUri(aturi); //parseAtUri(aturi); 411 + const parsedaturi = new AtUri(aturi); 790 412 791 413 const fakeprofileviewbasic = React.useMemo<AppBskyActorDefs.ProfileViewBasic>( 792 414 () => ({ ··· 841 463 ], 842 464 ); 843 465 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 466 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent 874 467 ?.uri; 875 468 const feedviewpostreplydid = ··· 893 486 894 487 return ( 895 488 <> 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 489 <UniversalPostRenderer 902 490 expanded={detailed} 903 491 onPostClick={() => ··· 907 495 params: { did: parsedaturi.host, rkey: parsedaturi.rkey }, 908 496 }) 909 497 } 910 - // onProfileClick={() => parsedaturi && navigate({to: "/profile/$did", 911 - // params: {did: parsedaturi.did} 912 - // })} 913 498 onProfileClick={(e) => { 914 499 e.stopPropagation(); 915 500 if (parsedaturi) { ··· 925 510 bottomReplyLine={bottomReplyLine} 926 511 topReplyLine={topReplyLine} 927 512 bottomBorder={bottomBorder} 928 - //extraOptionalItemInfo={{reply: postRecord?.value?.reply as AppBskyFeedDefs.ReplyRef, post: fakepost}} 929 513 feedviewpostreplyhandle={feedviewpostreplyhandle} 930 514 repostedby={feedviewpostrepostedbyhandle} 931 515 style={style} ··· 942 526 ); 943 527 } 944 528 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({ 529 + export function UniversalPostRenderer({ 1379 530 post, 1380 531 uprrrsauthor, 1381 - //setMainItem, 1382 - //isMainItem, 1383 532 onPostClick, 1384 533 onProfileClick, 1385 534 expanded, 1386 - //expanded, 1387 535 isQuote, 1388 - //isQuote, 1389 536 extraOptionalItemInfo, 1390 537 bottomReplyLine, 1391 538 topReplyLine, ··· 1403 550 maxReplies, 1404 551 constellationLinks, 1405 552 }: { 1406 - post: PostView; 553 + post: AppBskyFeedDefs.PostView; 1407 554 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 555 onPostClick?: (e: React.MouseEvent) => void; 1414 556 onProfileClick?: (e: React.MouseEvent) => void; 1415 557 expanded?: boolean; 1416 558 isQuote?: boolean; 1417 - extraOptionalItemInfo?: FeedViewPost; 559 + extraOptionalItemInfo?: AppBskyFeedDefs.FeedViewPost; 1418 560 bottomReplyLine?: boolean; 1419 561 topReplyLine?: boolean; 1420 562 salt: string; ··· 1442 584 post.viewer?.repost, 1443 585 ); 1444 586 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 587 1452 588 const repostOrUnrepostPost = async () => { 1453 589 if (!agent) { ··· 1517 653 (showBridgyText ? unfedibridgy : undefined) ?? 1518 654 (showWafrnText ? unfediwafrn : undefined); 1519 655 1520 - /* fuck you */ 1521 656 const isMainItem = false; 1522 657 const setMainItem = (any: any) => {}; 1523 - // eslint-disable-next-line react-hooks/refs 1524 - //console.log("Received ref in UniversalPostRenderer:", usedref); 658 + 1525 659 return ( 1526 660 <div ref={ref} style={style} data-index={dataIndexPropPass}> 1527 661 <div 1528 - //ref={ref} 1529 662 key={salt + "-" + (post.uri || emergencySalt)} 1530 663 onClick={ 1531 664 isMainItem ··· 1542 675 : undefined 1543 676 } 1544 677 style={{ 1545 - //...style, 1546 - //border: "1px solid #e1e8ed", 1547 - //borderRadius: 12, 1548 678 opacity: "1 !important", 1549 679 background: "transparent", 1550 680 paddingLeft: isQuote ? 12 : 16, 1551 681 paddingRight: isQuote ? 12 : 16, 1552 - //paddingTop: 16, 1553 682 paddingTop: isRepost ? 10 : isQuote ? 12 : topReplyLine ? 8 : 16, 1554 - //paddingBottom: bottomReplyLine ? 0 : 16, 1555 683 paddingBottom: 0, 1556 684 fontFamily: "system-ui, sans-serif", 1557 - //boxShadow: "0 2px 8px rgba(0,0,0,0.04)", 1558 685 position: "relative", 1559 - // dont cursor: "pointer", 1560 686 borderBottomWidth: bottomBorder ? (isQuote ? 0 : 1) : 0, 1561 687 }} 1562 688 className="border-gray-300 dark:border-gray-800" ··· 1571 697 fontSize: 14, 1572 698 maxHeight: "1rem", 1573 699 justifyContent: "flex-start", 1574 - //color: theme.textSecondary, 1575 700 gap: 4, 1576 701 alignItems: "center", 1577 702 }} 1578 703 className="text-gray-500 dark:text-gray-400" 1579 704 > 1580 - <MdiRepost /> Reposted by @{isRepost}{" "} 705 + <IconMdiRepost /> Reposted by @{isRepost} 1581 706 </div> 1582 707 )} 1583 708 {!isQuote && ( 1584 709 <div 1585 710 style={{ 1586 - opacity: 1587 - topReplyLine || isReply /*&& (true || expanded)*/ ? 0.5 : 0, 711 + opacity: topReplyLine || isReply ? 0.5 : 0, 1588 712 position: "absolute", 1589 713 top: 0, 1590 - left: 36, // why 36 ??? 1591 - //left: 16 + (42 / 2), 714 + left: 36, 1592 715 width: 2, 1593 - //height: "100%", 1594 716 height: isRepost 1595 717 ? "calc(16px + 1rem - 6px)" 1596 718 : topReplyLine 1597 719 ? 8 - 6 1598 720 : 16 - 6, 1599 - // background: theme.textSecondary, 1600 - //opacity: 0.5, 1601 - // no flex here 1602 721 }} 1603 722 className="bg-gray-500 dark:bg-gray-400" 1604 723 /> ··· 1651 770 <div className="flex flex-col gap-3"> 1652 771 <div> 1653 772 <div className="text-gray-900 dark:text-gray-100 font-medium text-md"> 1654 - {post.author.displayName || post.author.handle}{" "} 773 + {post.author.displayName || post.author.handle} 1655 774 </div> 1656 775 <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1"> 1657 776 <Mutual targetdidorhandle={post.author.did} />@ 1658 - {post.author.handle}{" "} 777 + {post.author.handle} 1659 778 </div> 1660 779 </div> 1661 780 {uprrrsauthor?.description && ( ··· 1663 782 {uprrrsauthor.description} 1664 783 </div> 1665 784 )} 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 785 </div> 1685 786 </div> 1686 - 1687 - {/* <HoverCard.Arrow className="fill-gray-50 dark:fill-gray-900" /> */} 1688 787 </HoverCard.Content> 1689 788 </HoverCard.Portal> 1690 789 </HoverCard.Root> ··· 1701 800 marginRight: expanded || isQuote ? 0 : 12, 1702 801 }} 1703 802 > 1704 - {/* dummy for later use */} 1705 803 <div style={{ width: 42, height: 42 + 6, minHeight: 42 + 6 }} /> 1706 - {/* reply line !!!! bottomReplyLine */} 1707 804 {bottomReplyLine && ( 1708 805 <div 1709 806 style={{ 1710 807 width: 2, 1711 808 height: "100%", 1712 - //background: theme.textSecondary, 1713 809 opacity: 0.5, 1714 - // no flex here 1715 - //color: "Red", 1716 - //zIndex: 99 1717 810 }} 1718 811 className="bg-gray-500 dark:bg-gray-400" 1719 812 /> 1720 813 )} 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 814 </div> 1732 815 <div style={{ flex: 1, maxWidth: "100%" }}> 1733 816 <div ··· 1745 828 <div 1746 829 style={{ 1747 830 display: "flex", 1748 - //overflow: "hidden", // hey why is overflow hidden unapplied 1749 831 overflow: "hidden", 1750 832 textOverflow: "ellipsis", 1751 833 flexShrink: 1, ··· 1770 852 minWidth: 0, 1771 853 gap: 4, 1772 854 alignItems: "center", 1773 - //color: theme.text, 1774 855 }} 1775 856 className="text-gray-900 dark:text-gray-100" 1776 857 > 1777 - {/* verified checkmark */} 1778 - {post.author.displayName || post.author.handle}{" "} 858 + {post.author.displayName || post.author.handle} 1779 859 {post.author.verification?.verifiedStatus == "valid" && ( 1780 - <MdiVerified /> 860 + <IconMdiVerified /> 1781 861 )} 1782 862 </span> 1783 863 1784 864 <span 1785 865 style={{ 1786 - //color: theme.textSecondary, 1787 866 fontSize: 16, 1788 867 overflowX: "hidden", 1789 868 textOverflow: "ellipsis", ··· 1806 885 > 1807 886 <span 1808 887 style={{ 1809 - //color: theme.textSecondary, 1810 888 fontSize: 16, 1811 889 marginLeft: 8, 1812 890 whiteSpace: "nowrap", ··· 1815 893 }} 1816 894 className="text-gray-500 dark:text-gray-400" 1817 895 > 1818 - · {/* time placeholder */} 1819 - {shortTimeAgo(post.indexedAt)} 896 + · {shortTimeAgo(post.indexedAt)} 1820 897 </span> 1821 898 </div> 1822 899 </div> 1823 - {/* reply indicator */} 1824 900 {!!feedviewpostreplyhandle && ( 1825 901 <div 1826 902 style={{ ··· 1829 905 paddingBottom: 2, 1830 906 fontSize: 14, 1831 907 justifyContent: "flex-start", 1832 - //color: theme.textSecondary, 1833 908 gap: 4, 1834 909 alignItems: "center", 1835 - //marginLeft: 36, 1836 910 height: 1837 911 !(expanded || isQuote) && !!feedviewpostreplyhandle 1838 912 ? "1rem" ··· 1842 916 }} 1843 917 className="text-gray-500 dark:text-gray-400" 1844 918 > 1845 - <MdiReply /> Reply to @{feedviewpostreplyhandle} 919 + <IconMdiReply /> Reply to @{feedviewpostreplyhandle} 1846 920 </div> 1847 921 )} 1848 922 <div ··· 1884 958 {post.embed && depth < 1 && !concise ? ( 1885 959 <PostEmbeds 1886 960 embed={post.embed} 1887 - //moderation={moderation} 1888 961 viewContext={PostEmbedViewContext.Feed} 1889 962 salt={salt} 1890 963 navigate={navigate} ··· 1895 968 /> 1896 969 ) : null} 1897 970 {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 971 <> 1902 972 <div className="border-gray-300 dark:border-gray-800 p-3 rounded-xl border italic text-gray-400 text-[14px]"> 1903 973 (there is an embed here thats too deep to render) ··· 1914 984 <div 1915 985 style={{ 1916 986 overflow: "hidden", 1917 - //color: theme.textSecondary, 1918 987 fontSize: 14, 1919 988 display: "flex", 1920 989 borderBottomStyle: "solid", 1921 - //borderBottomColor: theme.border, 1922 - //background: "#f00", 1923 - // height: "1rem", 1924 990 paddingTop: 4, 1925 991 paddingBottom: 8, 1926 992 borderBottomWidth: 1, 1927 993 marginBottom: 8, 1928 - }} // important for height animation 994 + }} 1929 995 className="text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-800 was7" 1930 996 > 1931 997 {fullDateTimeFormat(post.indexedAt)} ··· 1938 1004 display: "flex", 1939 1005 gap: 32, 1940 1006 paddingTop: 8, 1941 - //color: theme.textSecondary, 1942 1007 fontSize: 15, 1943 1008 justifyContent: "space-between", 1944 - //background: "#0f0", 1945 1009 }} 1946 1010 className="text-gray-500 dark:text-gray-400" 1947 1011 > ··· 1953 1017 ...btnstyle, 1954 1018 }} 1955 1019 > 1956 - <MdiCommentOutline /> 1020 + <IconMdiCommentOutline /> 1957 1021 {post.replyCount} 1958 1022 </HitSlopButton> 1959 1023 <DropdownMenu.Root modal={false}> ··· 1965 1029 }} 1966 1030 aria-label="Repost or quote post" 1967 1031 > 1968 - {hasRetweeted ? <MdiRepeatGreen /> : <MdiRepeat />} 1032 + {hasRetweeted ? <IconMdiRepeat color="#5CEFAA" /> : <IconMdiRepeat />} 1969 1033 {post.repostCount ?? 0} 1970 1034 </div> 1971 1035 </DropdownMenu.Trigger> ··· 1980 1044 onSelect={repostOrUnrepostPost} 1981 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" 1982 1046 > 1983 - <MdiRepeat 1047 + <IconMdiRepeat 1984 1048 className={hasRetweeted ? "text-green-400" : ""} 1985 1049 /> 1986 1050 <span>{hasRetweeted ? "Undo Repost" : "Repost"}</span> ··· 1995 1059 }} 1996 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" 1997 1061 > 1998 - {/* You might want a specific quote icon here */} 1999 - <MdiCommentOutline /> 1062 + <IconMdiCommentOutline /> 2000 1063 <span>Quote</span> 2001 1064 </DropdownMenu.Item> 2002 1065 </DropdownMenu.Content> ··· 2011 1074 ...(liked ? { color: "#EC4899" } : {}), 2012 1075 }} 2013 1076 > 2014 - {liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />} 1077 + {liked ? <IconMdiCardsHeart /> : <IconMdiCardsHeartOutline />} 2015 1078 {(post.likeCount || 0) + (liked ? 1 : 0)} 2016 1079 </HitSlopButton> 2017 1080 <div style={{ display: "flex", gap: 8 }}> ··· 2030 1093 title: "Copied to clipboard!", 2031 1094 }); 2032 1095 } catch (_e) { 2033 - // idk 2034 1096 renderSnack({ 2035 1097 title: "Failed to copy link", 2036 1098 }); ··· 2040 1102 ...btnstyle, 2041 1103 }} 2042 1104 > 2043 - <MdiShareVariant /> 1105 + <IconMdiShareVariant /> 2044 1106 </HitSlopButton> 2045 1107 <HitSlopButton 2046 1108 onClick={() => { ··· 2050 1112 }} 2051 1113 > 2052 1114 <span style={btnstyle}> 2053 - <MdiMoreHoriz /> 1115 + <IconMdiMoreHoriz /> 2054 1116 </span> 2055 1117 </HitSlopButton> 2056 1118 </div> ··· 2059 1121 </div> 2060 1122 <div 2061 1123 style={{ 2062 - //height: bottomReplyLine ? 16 : 0 2063 1124 height: isQuote ? 12 : 16, 2064 1125 }} 2065 1126 /> ··· 2070 1131 ); 2071 1132 } 2072 1133 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 1134 enum PostEmbedViewContext { 2138 1135 ThreadHighlighted = "ThreadHighlighted", 2139 1136 Feed = "Feed", 2140 1137 FeedEmbedRecordWithMedia = "FeedEmbedRecordWithMedia", 2141 1138 } 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 - };
+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 22 import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; 23 23 import { Import } from "~/components/Import"; 24 24 import Login from "~/components/Login"; 25 + import Logo from "~/components/LogoSvg"; 25 26 import { NotFound } from "~/components/NotFound"; 26 - import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; 27 27 import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider"; 28 28 import { PollMutationQueueProvider } from "~/providers/PollMutationQueueProvider"; 29 29 import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; ··· 249 249 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 250 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 251 <div className="flex items-center gap-3 mb-4"> 252 - <FluentEmojiHighContrastGlowingStar 252 + <Logo 253 253 className="h-8 w-8" 254 254 style={{ 255 255 color: ··· 506 506 507 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 508 <div className="flex items-center gap-3 mb-4"> 509 - <FluentEmojiHighContrastGlowingStar 509 + <Logo 510 510 className="h-8 w-8" 511 511 style={{ 512 512 color: ··· 843 843 ) : ( 844 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 845 <div className="flex items-center gap-2"> 846 - <FluentEmojiHighContrastGlowingStar 846 + <Logo 847 847 className="h-6 w-6" 848 848 style={{ 849 849 color:
+3 -6
src/routes/notifications.tsx
··· 11 11 useReusableTabScrollRestore, 12 12 } from "~/components/ReusableTabRoute"; 13 13 import { 14 - MdiCardsHeartOutline, 15 - MdiCommentOutline, 16 - MdiRepeat, 17 14 UniversalPostRendererATURILoader, 18 15 } from "~/components/UniversalPostRenderer"; 19 16 import { useAuth } from "~/providers/UnifiedAuthProvider"; ··· 544 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" 545 542 > 546 543 {type === "like" ? ( 547 - <MdiCardsHeartOutline height={22} width={22} /> 544 + <IconMdiCardsHeartOutline height={22} width={22} /> 548 545 ) : type === "repost" ? ( 549 - <MdiRepeat height={22} width={22} /> 546 + <IconMdiRepeat height={22} width={22} /> 550 547 ) : type === "reply" ? ( 551 - <MdiCommentOutline height={22} width={22} /> 548 + <IconMdiCommentOutline height={22} width={22} /> 552 549 ) : type === "quote" ? ( 553 550 <IconMdiMessageReplyTextOutline 554 551 height={22}
+1 -1
src/routes/profile.$did/index.tsx
··· 13 13 useReusableTabScrollRestore, 14 14 } from "~/components/ReusableTabRoute"; 15 15 import { 16 - renderTextWithFacets, 17 16 UniversalPostRendererATURILoader, 18 17 } from "~/components/UniversalPostRenderer"; 18 + import { renderTextWithFacets } from "~/components/UtilityFunctions"; 19 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 20 20 import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; 21 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.