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

AppView-first initial post fetching & polls handling

+550 -61
+6 -2
README.md
··· 1 # Red Dwarf 2 - Red Dwarf is a Bluesky client that does not use any AppView servers, instead it gathers the data from [Constellation](https://constellation.microcosm.blue/) and each users' PDS. 3 4 ![screenshot of red dwarf](/public/screenshot.jpg) 5 6 huge thanks to [Microcosm](https://microcosm.blue/) for making this possible 7 8 - issue tracker kanban board: [https://github.com/users/rimar1337/projects/1/views/1]https://github.com/users/rimar1337/projects/1/views/1 9 10 ## running dev and build 11 in the `vite.config.ts` file you should change these values
··· 1 # Red Dwarf 2 + Red Dwarf is a Bluesky client that does not depend on Bluesky’s AppView servers. 3 + 4 + It preserves authoritative independence by fetching records directly from each user’s PDS (via [Slingshot](https://slingshot.microcosm.blue/)) 5 + and reconstructing relationships through backlinks (via [Constellation](https://constellation.microcosm.blue/)), 6 + while optionally using AppView as an optimization layer when available. 7 8 ![screenshot of red dwarf](/public/screenshot.jpg) 9 10 huge thanks to [Microcosm](https://microcosm.blue/) for making this possible 11 12 + issue tracker kanban board: [currently on GitHub Projects](https://github.com/users/rimar1337/projects/1/views/1) 13 14 ## running dev and build 15 in the `vite.config.ts` file you should change these values
+5 -3
policy.ts
··· 35 36 ## About Red Dwarf 37 38 - Red Dwarf is a Bluesky client that does not rely on Bluesky API App Servers. 39 - Instead, it uses Microcosm to fetch records directly from each user’s PDS (via Slingshot) 40 - and connect them using backlinks (via Constellation). 41 42 ## Hosting Your Own Instance 43
··· 35 36 ## About Red Dwarf 37 38 + Red Dwarf is a Bluesky client that does not depend on Bluesky’s AppView servers. 39 + 40 + It preserves authoritative independence by fetching records directly from each user’s PDS (via Slingshot) 41 + and reconstructing relationships through backlinks (via Constellation), 42 + while optionally using AppView as an optimization layer when available. 43 44 ## Hosting Your Own Instance 45
+72 -35
src/components/PollComponents.tsx
··· 1 import { useAtom } from "jotai"; 2 import * as React from "react"; 3 ··· 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", ··· 22 collection: "app.reddwarf.poll.vote.a", 23 path: ".subject.uri", 24 customkey: "constellation-polls", 25 }); 26 27 const { data: voteCountsB } = useQueryConstellation({ ··· 30 collection: "app.reddwarf.poll.vote.b", 31 path: ".subject.uri", 32 customkey: "constellation-polls", 33 }); 34 35 const { data: voteCountsC } = useQueryConstellation({ ··· 38 collection: "app.reddwarf.poll.vote.c", 39 path: ".subject.uri", 40 customkey: "constellation-polls", 41 }); 42 43 const { data: voteCountsD } = useQueryConstellation({ ··· 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 ?? {}), ··· 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"> ··· 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 />
··· 1 + //import * as ATPAPI from "@atproto/api" 2 import { useAtom } from "jotai"; 3 import * as React from "react"; 4 ··· 6 usePollData, 7 usePollMutationQueue, 8 } from "~/providers/PollMutationQueueProvider"; 9 + //import { useAuth } from "~/providers/UnifiedAuthProvider"; 10 import { renderSnack } from "~/routes/__root"; 11 import { imgCDNAtom } from "~/utils/atoms"; 12 import { useQueryArbitrary, useQueryConstellation, useQueryProfile } from "~/utils/useQuery"; 13 14 + import { type embedtryfall } from "./PostEmbeds"; 15 + import { ExternalLinkEmbed } from "./PostEmbeds"; 16 + 17 + export function PollEmbed({ 18 + did, 19 + rkey, 20 + redactedLoading, 21 + embedtryfall 22 + }: { 23 + did: string; 24 + rkey: string; 25 + redactedLoading?: boolean; 26 + embedtryfall?: embedtryfall; 27 + }) { 28 + //const { agent } = useAuth(); 29 const { refreshPollData } = usePollMutationQueue(); 30 const pollUri = `at://${did}/app.reddwarf.embed.poll/${rkey}`; 31 const { data: pollRecord, isLoading, error } = useQueryArbitrary(pollUri); 32 + const dontLoadPolls = embedtryfall && (isLoading || pollRecord === undefined || error !== null) || false 33 34 const { data: voteCountsA } = useQueryConstellation({ 35 method: "/links/count/distinct-dids", ··· 37 collection: "app.reddwarf.poll.vote.a", 38 path: ".subject.uri", 39 customkey: "constellation-polls", 40 + enabled: !dontLoadPolls 41 }); 42 43 const { data: voteCountsB } = useQueryConstellation({ ··· 46 collection: "app.reddwarf.poll.vote.b", 47 path: ".subject.uri", 48 customkey: "constellation-polls", 49 + enabled: !dontLoadPolls 50 }); 51 52 const { data: voteCountsC } = useQueryConstellation({ ··· 55 collection: "app.reddwarf.poll.vote.c", 56 path: ".subject.uri", 57 customkey: "constellation-polls", 58 + enabled: !dontLoadPolls 59 }); 60 61 const { data: voteCountsD } = useQueryConstellation({ ··· 64 collection: "app.reddwarf.poll.vote.d", 65 path: ".subject.uri", 66 customkey: "constellation-polls", 67 + enabled: !dontLoadPolls 68 }); 69 70 + // const { data: votersA } = useQueryConstellation({ 71 + // method: "/links", 72 + // target: pollUri, 73 + // collection: "app.reddwarf.poll.vote.a", 74 + // path: ".subject.uri", 75 + // customkey: "constellation-polls", 76 + // enabled: !isLoading 77 + // }); 78 + // const { data: votersB } = useQueryConstellation({ 79 + // method: "/links", 80 + // target: pollUri, 81 + // collection: "app.reddwarf.poll.vote.b", 82 + // path: ".subject.uri", 83 + // customkey: "constellation-polls", 84 + // enabled: !isLoading 85 + // }); 86 + // const { data: votersC } = useQueryConstellation({ 87 + // method: "/links", 88 + // target: pollUri, 89 + // collection: "app.reddwarf.poll.vote.c", 90 + // path: ".subject.uri", 91 + // customkey: "constellation-polls", 92 + // enabled: !isLoading 93 + // }); 94 + // const { data: votersD } = useQueryConstellation({ 95 + // method: "/links", 96 + // target: pollUri, 97 + // collection: "app.reddwarf.poll.vote.d", 98 + // path: ".subject.uri", 99 + // customkey: "constellation-polls", 100 + // enabled: !isLoading 101 + // }); 102 103 const poll = { 104 ...(pollRecord?.value ?? {}), ··· 122 d: parseInt((voteCountsD as any)?.total || "0"), 123 }; 124 125 + const { results, totalVotes, handleVote, votersA, votersB, votersC, votersD } = usePollData( 126 pollUri, 127 pollRecord?.cid, 128 !!poll.multiple, 129 serverCounts, 130 + !dontLoadPolls 131 ); 132 + if (dontLoadPolls && embedtryfall) { 133 + const link = embedtryfall.embed.external; 134 + const onOpen = embedtryfall.onOpen 135 + return ( 136 + <> 137 + {/* pass thru confirm<br /> 138 + embedtryfall = {JSON.stringify(embedtryfall, null, 2)}<br /> 139 + isLoading = {JSON.stringify(isLoading, null, 2)}<br /> 140 + pollRecord = {JSON.stringify(pollRecord, null, 2)}<br /> 141 + error = {JSON.stringify(error, null, 2)}<br /> */} 142 + <ExternalLinkEmbed link={link} onOpen={onOpen} style={{ marginTop: 0 }} redactedLoading={redactedLoading}/> 143 + </> 144 + ) 145 + } 146 + if (isLoading && !embedtryfall) { 147 return ( 148 <div className="animate-pulse"> 149 <div className="flex items-center gap-2 mb-3"> ··· 165 166 return ( 167 <> 168 + <div className={`${redactedLoading ? "pointer-events-none": ""} my-4`}> 169 <div className="mb-4 flex items-center gap-3"> 170 <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"> 171 <IconMdiGlobe />
+17 -5
src/components/PostEmbeds.tsx
··· 1 import { 2 AppBskyEmbedDefs, 3 AppBskyEmbedExternal, ··· 55 nopics, 56 lightboxCallback, 57 constellationLinks, 58 - redactedLoading 59 }: { 60 embed?: Embed; 61 moderation?: ModerationDecision; ··· 69 lightboxCallback?: (d: LightboxProps) => void; 70 constellationLinks?: any; 71 redactedLoading?: boolean; 72 }) { 73 function setLightboxIndex(number: number) { 74 navigate({ ··· 548 if (AppBskyEmbedExternal.isView(embed)) { 549 const pollLinks = constellationLinks?.links?.["app.reddwarf.embed.poll"]; 550 const hasPollLink = pollLinks && Object.keys(pollLinks).length > 0; 551 552 - if (hasPollLink && postid) { 553 // warning: i gave up and warpped it in a div lmao 554 return ( 555 <div className={(redactedLoading ? " blur animate-pulse " : undefined)}> 556 - <PollEmbed did={postid.did} rkey={postid.rkey} /> 557 </div> 558 ); 559 } 560 561 const link = embed.external; 562 return ( 563 - <ExternalLinkEmbed link={link} onOpen={onOpen} style={{ marginTop: 0 }} redactedLoading={redactedLoading} /> 564 ); 565 } 566 ··· 579 580 return <div />; 581 } 582 583 export function ExternalLinkEmbed({ 584 link, 585 onOpen, 586 style, 587 - redactedLoading 588 }: { 589 link: AppBskyEmbedExternal.ViewExternal; 590 onOpen?: () => void; 591 style?: React.CSSProperties; 592 redactedLoading?: boolean; 593 }) { 594 const { uri, title, description, thumb } = link; 595 const thumbAspectRatio = 1.91; 596
··· 1 + import * as ATPAPI from "@atproto/api" 2 import { 3 AppBskyEmbedDefs, 4 AppBskyEmbedExternal, ··· 56 nopics, 57 lightboxCallback, 58 constellationLinks, 59 + redactedLoading, 60 + referral 61 }: { 62 embed?: Embed; 63 moderation?: ModerationDecision; ··· 71 lightboxCallback?: (d: LightboxProps) => void; 72 constellationLinks?: any; 73 redactedLoading?: boolean; 74 + referral?: string[]; 75 }) { 76 function setLightboxIndex(number: number) { 77 navigate({ ··· 551 if (AppBskyEmbedExternal.isView(embed)) { 552 const pollLinks = constellationLinks?.links?.["app.reddwarf.embed.poll"]; 553 const hasPollLink = pollLinks && Object.keys(pollLinks).length > 0; 554 + const isfromappview = referral?.includes("appview") 555 556 + if ((hasPollLink || isfromappview) && postid) { 557 // warning: i gave up and warpped it in a div lmao 558 return ( 559 <div className={(redactedLoading ? " blur animate-pulse " : undefined)}> 560 + <PollEmbed did={postid.did} rkey={postid.rkey} embedtryfall={isfromappview ? {embed, onOpen} : undefined} redactedLoading={redactedLoading}/> 561 </div> 562 ); 563 } 564 565 const link = embed.external; 566 return ( 567 + <ExternalLinkEmbed link={link} onOpen={onOpen} style={{ marginTop: 0 }} redactedLoading={redactedLoading}/> 568 ); 569 } 570 ··· 583 584 return <div />; 585 } 586 + export type embedtryfall = { 587 + embed: ATPAPI.AppBskyEmbedExternal.View, 588 + onOpen?: () => void; 589 + } 590 591 export function ExternalLinkEmbed({ 592 link, 593 onOpen, 594 style, 595 + redactedLoading, 596 + referral 597 }: { 598 link: AppBskyEmbedExternal.ViewExternal; 599 onOpen?: () => void; 600 style?: React.CSSProperties; 601 redactedLoading?: boolean; 602 + referral?: string[]; 603 }) { 604 + //const fromappview = referral?.includes("appview") 605 + //const [] 606 const { uri, title, description, thumb } = link; 607 const thumbAspectRatio = 1.91; 608
+224 -15
src/components/UniversalPostRenderer.tsx
··· 27 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 28 //import type { ContentLabel } from "~/types/moderation"; 29 import { 30 composerAtom, 31 constellationURLAtom, 32 enableBridgyTextAtom, 33 enableWafrnTextAtom, 34 imgCDNAtom, ··· 41 useQueryIdentity, 42 useQueryPost, 43 useQueryProfile, 44 yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks, 45 } from "~/utils/useQuery"; 46 ··· 97 filterMustHaveMedia, 98 filterMustBeReply, 99 }: UniversalPostRendererATURILoaderProps) { 100 const TEMPLINEAR = true; 101 const parsed = new AtUri(atUri); 102 const did = parsed?.host; ··· 607 lightboxCallback, 608 maxReplies, 609 constellationLinks, 610 }: { 611 post: AppBskyFeedDefs.PostView; 612 uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed; ··· 631 lightboxCallback?: (d: LightboxProps) => void; 632 maxReplies?: number; 633 constellationLinks?: any; 634 }) { 635 636 // todo move moderation to one of the UniversalPostRenderer wrapper components, and not the pure renderer component. please. thanks ··· 929 return null // if feed view post then moderated post isnt important and just remove it from view 930 } 931 return ( 932 - <div 933 - className={`flex flex-col gap-0 border-gray-200 dark:border-gray-800 ${bottomReplyLine ? "" : "border-b"}`} 934 - onClick={ 935 - isMainItem 936 - ? onPostClick 937 - : setMainItem 938 ? onPostClick 939 - ? (e) => { 940 - setMainItem({ post: post }); 941 - onPostClick(e); 942 - } 943 - : () => { 944 - setMainItem({ post: post }); 945 - } 946 - : undefined 947 - }> 948 949 <div style={{ width: 42, height: 16, minHeight: 16 }} className="flex items-center flex-col mx-4"> 950 <div ··· 1367 nopics={nopics} 1368 lightboxCallback={lightboxCallback} 1369 constellationLinks={constellationLinks} 1370 /> 1371 ) : null} 1372 {post.embed && depth > 0 && (
··· 27 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 28 //import type { ContentLabel } from "~/types/moderation"; 29 import { 30 + appviewUrlAtom, 31 composerAtom, 32 constellationURLAtom, 33 + enableAppViewAtom, 34 enableBridgyTextAtom, 35 enableWafrnTextAtom, 36 imgCDNAtom, ··· 43 useQueryIdentity, 44 useQueryPost, 45 useQueryProfile, 46 + useQuerySingularAVPostQuery, 47 yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks, 48 } from "~/utils/useQuery"; 49 ··· 100 filterMustHaveMedia, 101 filterMustBeReply, 102 }: UniversalPostRendererATURILoaderProps) { 103 + const [usesAV] = useAtom(enableAppViewAtom); 104 + if (usesAV) { 105 + return ( 106 + <UniversalPostRendererATURILoader_AppView 107 + atUri={atUri} 108 + onConstellation={onConstellation} 109 + detailed={detailed} 110 + bottomReplyLine={bottomReplyLine} 111 + topReplyLine={topReplyLine} 112 + bottomBorder={bottomBorder} 113 + feedviewpost={feedviewpost} 114 + repostedby={repostedby} 115 + style={style} 116 + ref={ref} 117 + dataIndexPropPass={dataIndexPropPass} 118 + nopics={nopics} 119 + concise={concise} 120 + lightboxCallback={lightboxCallback} 121 + maxReplies={maxReplies} 122 + isQuote={isQuote} 123 + filterNoReplies={filterNoReplies} 124 + filterMustHaveMedia={filterMustHaveMedia} 125 + filterMustBeReply={filterMustBeReply} 126 + /> 127 + ) 128 + } 129 + return ( 130 + <UniversalPostRendererATURILoader_Microcosm 131 + atUri={atUri} 132 + onConstellation={onConstellation} 133 + detailed={detailed} 134 + bottomReplyLine={bottomReplyLine} 135 + topReplyLine={topReplyLine} 136 + bottomBorder={bottomBorder} 137 + feedviewpost={feedviewpost} 138 + repostedby={repostedby} 139 + style={style} 140 + ref={ref} 141 + dataIndexPropPass={dataIndexPropPass} 142 + nopics={nopics} 143 + concise={concise} 144 + lightboxCallback={lightboxCallback} 145 + maxReplies={maxReplies} 146 + isQuote={isQuote} 147 + filterNoReplies={filterNoReplies} 148 + filterMustHaveMedia={filterMustHaveMedia} 149 + filterMustBeReply={filterMustBeReply} 150 + /> 151 + ) 152 + } 153 + /* 154 + todo: 155 + - either 156 + - put constellation based reply threading or 157 + - use a getPostThreadV2 once for quick reply threadings (the post thread page always 158 + fetches replies via constellation for complteness) 159 + - do the profile pages too 160 + */ 161 + export function UniversalPostRendererATURILoader_AppView({ 162 + atUri, 163 + onConstellation, 164 + detailed = false, 165 + bottomReplyLine, 166 + topReplyLine, 167 + bottomBorder = true, 168 + feedviewpost = false, 169 + repostedby, 170 + style, 171 + ref, 172 + dataIndexPropPass, 173 + nopics, 174 + concise, 175 + lightboxCallback, 176 + maxReplies, 177 + isQuote, 178 + filterNoReplies, 179 + filterMustHaveMedia, 180 + filterMustBeReply, 181 + }: UniversalPostRendererATURILoaderProps) { 182 + const [avurl] = useAtom(appviewUrlAtom); 183 + const navigate = useNavigate(); 184 + const parsedaturi = new AtUri(atUri); 185 + 186 + const { data, isLoading, isEnabled, isError, error } = useQuerySingularAVPostQuery({ aturi: atUri, avurl: avurl }); 187 + 188 + 189 + const thereply = (data?.record as AppBskyFeedPost.Record)?.reply?.parent 190 + ?.uri; 191 + const feedviewpostreplydid = 192 + thereply && !filterNoReplies ? new AtUri(thereply).host : undefined; 193 + const replyhookvalue = useQueryIdentity( 194 + feedviewpost ? feedviewpostreplydid : undefined, 195 + ); 196 + const feedviewpostreplyhandle = replyhookvalue?.data?.handle; 197 + 198 + const aturirepostbydid = repostedby ? new AtUri(repostedby).host : undefined; 199 + const repostedbyhookvalue = useQueryIdentity( 200 + repostedby ? aturirepostbydid : undefined, 201 + ); 202 + const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle; 203 + if (!isLoading && data === undefined) { 204 + return ( 205 + <UniversalPostRendererATURILoader_Microcosm 206 + atUri={atUri} 207 + onConstellation={onConstellation} 208 + detailed={detailed} 209 + bottomReplyLine={bottomReplyLine} 210 + topReplyLine={topReplyLine} 211 + bottomBorder={bottomBorder} 212 + feedviewpost={feedviewpost} 213 + repostedby={repostedby} 214 + style={style} 215 + ref={ref} 216 + dataIndexPropPass={dataIndexPropPass} 217 + nopics={nopics} 218 + concise={concise} 219 + lightboxCallback={lightboxCallback} 220 + maxReplies={maxReplies} 221 + isQuote={isQuote} 222 + filterNoReplies={filterNoReplies} 223 + filterMustHaveMedia={filterMustHaveMedia} 224 + filterMustBeReply={filterMustBeReply} 225 + /> 226 + ) 227 + } 228 + return ( 229 + <UniversalPostRenderer 230 + referral={["appview"]} 231 + expanded={detailed} 232 + onPostClick={() => 233 + parsedaturi && 234 + navigate({ 235 + to: "/profile/$did/post/$rkey", 236 + params: { did: parsedaturi.host, rkey: parsedaturi.rkey }, 237 + }) 238 + } 239 + onProfileClick={(e) => { 240 + e.stopPropagation(); 241 + if (parsedaturi) { 242 + navigate({ 243 + to: "/profile/$did", 244 + params: { did: parsedaturi.host }, 245 + }); 246 + } 247 + }} 248 + post={data || { 249 + uri: atUri, 250 + cid: atUri, 251 + author: { 252 + did: parsedaturi.host, 253 + handle: parsedaturi.host, 254 + }, 255 + record: {}, 256 + indexedAt: "", 257 + }} // todo: this is bad. just make it so that UPR allows missing data 258 + uprrrsauthor={{ 259 + ...(data?.author || 260 + { 261 + did: parsedaturi.host, 262 + handle: parsedaturi.host, 263 + }), 264 + "$type": "app.bsky.actor.defs#profileViewDetailed", 265 + }} 266 + salt={atUri} 267 + bottomReplyLine={bottomReplyLine} 268 + topReplyLine={topReplyLine} 269 + bottomBorder={bottomBorder} 270 + feedviewpost={feedviewpost} 271 + feedviewpostreplyhandle={feedviewpostreplyhandle} 272 + repostedby={feedviewpostrepostedbyhandle} 273 + style={style} 274 + ref={ref} 275 + dataIndexPropPass={dataIndexPropPass} 276 + nopics={nopics} 277 + concise={concise} 278 + lightboxCallback={lightboxCallback} 279 + maxReplies={maxReplies} 280 + isQuote={isQuote} 281 + constellationLinks={{}} 282 + /> 283 + ) 284 + } 285 + export function UniversalPostRendererATURILoader_Microcosm({ 286 + atUri, 287 + onConstellation, 288 + detailed = false, 289 + bottomReplyLine, 290 + topReplyLine, 291 + bottomBorder = true, 292 + feedviewpost = false, 293 + repostedby, 294 + style, 295 + ref, 296 + dataIndexPropPass, 297 + nopics, 298 + concise, 299 + lightboxCallback, 300 + maxReplies, 301 + isQuote, 302 + filterNoReplies, 303 + filterMustHaveMedia, 304 + filterMustBeReply, 305 + }: UniversalPostRendererATURILoaderProps) { 306 const TEMPLINEAR = true; 307 const parsed = new AtUri(atUri); 308 const did = parsed?.host; ··· 813 lightboxCallback, 814 maxReplies, 815 constellationLinks, 816 + referral, 817 }: { 818 post: AppBskyFeedDefs.PostView; 819 uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed; ··· 838 lightboxCallback?: (d: LightboxProps) => void; 839 maxReplies?: number; 840 constellationLinks?: any; 841 + referral?: string[]; 842 }) { 843 844 // todo move moderation to one of the UniversalPostRenderer wrapper components, and not the pure renderer component. please. thanks ··· 1137 return null // if feed view post then moderated post isnt important and just remove it from view 1138 } 1139 return ( 1140 + <div 1141 + className={`flex flex-col gap-0 border-gray-200 dark:border-gray-800 ${bottomReplyLine ? "" : "border-b"}`} 1142 + onClick={ 1143 + isMainItem 1144 ? onPostClick 1145 + : setMainItem 1146 + ? onPostClick 1147 + ? (e) => { 1148 + setMainItem({ post: post }); 1149 + onPostClick(e); 1150 + } 1151 + : () => { 1152 + setMainItem({ post: post }); 1153 + } 1154 + : undefined 1155 + }> 1156 1157 <div style={{ width: 42, height: 16, minHeight: 16 }} className="flex items-center flex-col mx-4"> 1158 <div ··· 1575 nopics={nopics} 1576 lightboxCallback={lightboxCallback} 1577 constellationLinks={constellationLinks} 1578 + referral={[...referral || [], "im upr!"]} 1579 /> 1580 ) : null} 1581 {post.embed && depth > 0 && (
+9
src/providers/PollMutationQueueProvider.tsx
··· 355 pollCid: string | undefined, 356 isMultiple: boolean, 357 serverCounts: { a: number; b: number; c: number; d: number }, 358 ) { 359 const { agent } = useAuth(); 360 const myDid = agent?.did; ··· 371 collection: "app.reddwarf.poll.vote.a", 372 path: ".subject.uri", 373 customkey: "constellation-polls", 374 }); 375 const { data: votersB } = useQueryConstellation({ 376 method: "/links", ··· 378 collection: "app.reddwarf.poll.vote.b", 379 path: ".subject.uri", 380 customkey: "constellation-polls", 381 }); 382 const { data: votersC } = useQueryConstellation({ 383 method: "/links", ··· 385 collection: "app.reddwarf.poll.vote.c", 386 path: ".subject.uri", 387 customkey: "constellation-polls", 388 }); 389 const { data: votersD } = useQueryConstellation({ 390 method: "/links", ··· 392 collection: "app.reddwarf.poll.vote.d", 393 path: ".subject.uri", 394 customkey: "constellation-polls", 395 }); 396 397 const handleVote = useCallback( ··· 480 stateD.hasVoted, 481 totalVotes: stateA.count + stateB.count + stateC.count + stateD.count, 482 handleVote, 483 }; 484 }, [ 485 localVotes,
··· 355 pollCid: string | undefined, 356 isMultiple: boolean, 357 serverCounts: { a: number; b: number; c: number; d: number }, 358 + enabled?: boolean 359 ) { 360 const { agent } = useAuth(); 361 const myDid = agent?.did; ··· 372 collection: "app.reddwarf.poll.vote.a", 373 path: ".subject.uri", 374 customkey: "constellation-polls", 375 + enabled: enabled, 376 }); 377 const { data: votersB } = useQueryConstellation({ 378 method: "/links", ··· 380 collection: "app.reddwarf.poll.vote.b", 381 path: ".subject.uri", 382 customkey: "constellation-polls", 383 + enabled: enabled, 384 }); 385 const { data: votersC } = useQueryConstellation({ 386 method: "/links", ··· 388 collection: "app.reddwarf.poll.vote.c", 389 path: ".subject.uri", 390 customkey: "constellation-polls", 391 + enabled: enabled, 392 }); 393 const { data: votersD } = useQueryConstellation({ 394 method: "/links", ··· 396 collection: "app.reddwarf.poll.vote.d", 397 path: ".subject.uri", 398 customkey: "constellation-polls", 399 + enabled: enabled, 400 }); 401 402 const handleVote = useCallback( ··· 485 stateD.hasVoted, 486 totalVotes: stateA.count + stateB.count + stateC.count + stateD.count, 487 handleVote, 488 + votersA, 489 + votersB, 490 + votersC, 491 + votersD 492 }; 493 }, [ 494 localVotes,
+4 -1
src/routes/about.tsx
··· 2 3 import { FORCED_LABELER_DIDS, HOST_ABOUT_MARKDOWN, HOST_ADMIN, HOST_DESCRIPTION, HOST_HERO, HOST_LABELMERGE, HOST_SIGNUP_PDS } from '~/../policy'; 4 import { Header } from '~/components/Header'; 5 - import { defaultconstellationURL, defaultImgCDN, defaultLycanURL, defaultslingshotURL, defaultVideoCDN } from '~/utils/atoms'; 6 7 import { ProfileSmall } from './__root'; 8 import { NotificationItem } from './notifications'; ··· 219 220 <span className="font-medium">Lycan (Personal Search):</span> 221 <span className={defaultLycanURL ? "" : "italic"}>{defaultLycanURL || "not set"}</span> 222 </div> 223 {/* {hostmandate && (<Heading2 title="Host-Mandated Labelers" />)} */} 224 <Heading3 title="General Moderation" />
··· 2 3 import { FORCED_LABELER_DIDS, HOST_ABOUT_MARKDOWN, HOST_ADMIN, HOST_DESCRIPTION, HOST_HERO, HOST_LABELMERGE, HOST_SIGNUP_PDS } from '~/../policy'; 4 import { Header } from '~/components/Header'; 5 + import { defaultAppviewURL, defaultconstellationURL, defaultImgCDN, defaultLycanURL, defaultslingshotURL, defaultVideoCDN } from '~/utils/atoms'; 6 7 import { ProfileSmall } from './__root'; 8 import { NotificationItem } from './notifications'; ··· 219 220 <span className="font-medium">Lycan (Personal Search):</span> 221 <span className={defaultLycanURL ? "" : "italic"}>{defaultLycanURL || "not set"}</span> 222 + 223 + <span className="font-medium">AppView (Bluesky Index):</span> 224 + <span className={defaultAppviewURL? "" : "italic"}>{defaultAppviewURL || "not set"}</span> 225 </div> 226 {/* {hostmandate && (<Heading2 title="Host-Mandated Labelers" />)} */} 227 <Heading3 title="General Moderation" />
+21
src/routes/settings.tsx
··· 8 import Login from "~/components/Login"; 9 import { useAuth } from "~/providers/UnifiedAuthProvider"; 10 import { 11 constellationURLAtom, 12 defaultconstellationURL, 13 defaulthue, 14 defaultImgCDN, 15 defaultLycanURL, 16 defaultslingshotURL, 17 defaultVideoCDN, 18 enableBitesAtom, 19 enableBridgyTextAtom, 20 enableWafrnTextAtom, ··· 34 export function Settings() { 35 const navigate = useNavigate(); 36 const { agent } = useAuth(); 37 return ( 38 <> 39 <Header ··· 197 description={"Show the original text of posts from Wafrn instances"} 198 //init={false} 199 /> 200 <p className="text-gray-500 dark:text-gray-400 py-4 px-4 text-sm border rounded-xl mx-4 mt-8 mb-4"> 201 Notice: Please restart/refresh the app if changes arent applying 202 correctly 203 </p> 204 </> 205 ); 206 }
··· 8 import Login from "~/components/Login"; 9 import { useAuth } from "~/providers/UnifiedAuthProvider"; 10 import { 11 + appviewUrlAtom, 12 constellationURLAtom, 13 + defaultAppviewURL, 14 defaultconstellationURL, 15 defaulthue, 16 defaultImgCDN, 17 defaultLycanURL, 18 defaultslingshotURL, 19 defaultVideoCDN, 20 + enableAppViewAtom, 21 enableBitesAtom, 22 enableBridgyTextAtom, 23 enableWafrnTextAtom, ··· 37 export function Settings() { 38 const navigate = useNavigate(); 39 const { agent } = useAuth(); 40 + const [isAppViewEnabled] = useAtom(enableAppViewAtom); 41 return ( 42 <> 43 <Header ··· 201 description={"Show the original text of posts from Wafrn instances"} 202 //init={false} 203 /> 204 + <div className="h-4" /> 205 + <SwitchSetting 206 + atom={enableAppViewAtom} 207 + title={"AppView-First"} 208 + description={"Prioritize using an AppView to hydrate posts & profiles before using microcosm"} 209 + //init={false} 210 + /> 211 + <div className={`${isAppViewEnabled ? "" : "opacity-50 pointer-events-none"}`}> 212 + <div className="h-4" /> 213 + <TextInputSetting 214 + atom={appviewUrlAtom} 215 + title={"AppView URL"} 216 + description={"Enable text search across posts you've interacted with"} 217 + init={defaultAppviewURL} 218 + /> 219 + </div> 220 <p className="text-gray-500 dark:text-gray-400 py-4 px-4 text-sm border rounded-xl mx-4 mt-8 mb-4"> 221 Notice: Please restart/refresh the app if changes arent applying 222 correctly 223 </p> 224 + <div className="h-60" /> 225 </> 226 ); 227 }
+10
src/utils/atoms.ts
··· 160 false 161 ); 162 163 164 // polls state 165
··· 160 false 161 ); 162 163 + export const enableAppViewAtom = atomWithStorage<boolean>( 164 + "enableAppViewAtom", 165 + true 166 + ); 167 + export const defaultAppviewURL = "https://api.bsky.app"; 168 + export const appviewUrlAtom = atomWithStorage<string>( 169 + "AppviewUrl", 170 + defaultAppviewURL 171 + ); 172 + 173 174 // polls state 175
+182
src/utils/useQuery.ts
··· 250 cursor?: string; 251 dids?: string[]; 252 customkey?: string; 253 }) { 254 // : QueryOptions< 255 // | linksRecordsResponse ··· 260 // Error 261 // > 262 return queryOptions({ 263 queryKey: [ 264 "constellation", 265 query?.method, ··· 335 cursor?: string; 336 dids?: string[]; 337 customkey?: string; 338 }): UseQueryResult<linksRecordsResponse, Error>; 339 export function useQueryConstellation(query: { 340 method: "/links/distinct-dids"; ··· 343 path: string; 344 cursor?: string; 345 customkey?: string; 346 }): UseQueryResult<linksDidsResponse, Error>; 347 export function useQueryConstellation(query: { 348 method: "/links/count"; ··· 351 path: string; 352 cursor?: string; 353 customkey?: string; 354 }): UseQueryResult<linksCountResponse, Error>; 355 export function useQueryConstellation(query: { 356 method: "/links/count/distinct-dids"; ··· 359 path: string; 360 cursor?: string; 361 customkey?: string; 362 }): UseQueryResult<linksCountResponse, Error>; 363 export function useQueryConstellation(query: { 364 method: "/links/all"; 365 target: string; 366 customkey?: string; 367 }): UseQueryResult<linksAllResponse, Error>; 368 export function useQueryConstellation(): undefined; 369 export function useQueryConstellation(query: { 370 method: "undefined"; 371 target: string; 372 customkey?: string; 373 }): undefined; 374 export function useQueryConstellation(query?: { 375 method: ··· 385 cursor?: string; 386 dids?: string[]; 387 customkey?: string; 388 }): 389 | UseQueryResult< 390 | linksRecordsResponse ··· 1384 export function useQuerySingularLabelQuery(options: SingularLabelQuery) { 1385 return useQuery(constructSingularLabelQuery(options)); 1386 }
··· 250 cursor?: string; 251 dids?: string[]; 252 customkey?: string; 253 + enabled?: boolean; 254 }) { 255 // : QueryOptions< 256 // | linksRecordsResponse ··· 261 // Error 262 // > 263 return queryOptions({ 264 + enabled: query?.enabled, 265 queryKey: [ 266 "constellation", 267 query?.method, ··· 337 cursor?: string; 338 dids?: string[]; 339 customkey?: string; 340 + enabled?: boolean; 341 }): UseQueryResult<linksRecordsResponse, Error>; 342 export function useQueryConstellation(query: { 343 method: "/links/distinct-dids"; ··· 346 path: string; 347 cursor?: string; 348 customkey?: string; 349 + enabled?: boolean; 350 }): UseQueryResult<linksDidsResponse, Error>; 351 export function useQueryConstellation(query: { 352 method: "/links/count"; ··· 355 path: string; 356 cursor?: string; 357 customkey?: string; 358 + enabled?: boolean; 359 }): UseQueryResult<linksCountResponse, Error>; 360 export function useQueryConstellation(query: { 361 method: "/links/count/distinct-dids"; ··· 364 path: string; 365 cursor?: string; 366 customkey?: string; 367 + enabled?: boolean; 368 }): UseQueryResult<linksCountResponse, Error>; 369 export function useQueryConstellation(query: { 370 method: "/links/all"; 371 target: string; 372 customkey?: string; 373 + enabled?: boolean; 374 }): UseQueryResult<linksAllResponse, Error>; 375 export function useQueryConstellation(): undefined; 376 export function useQueryConstellation(query: { 377 method: "undefined"; 378 target: string; 379 customkey?: string; 380 + enabled?: boolean; 381 }): undefined; 382 export function useQueryConstellation(query?: { 383 method: ··· 393 cursor?: string; 394 dids?: string[]; 395 customkey?: string; 396 + enabled?: boolean; 397 }): 398 | UseQueryResult< 399 | linksRecordsResponse ··· 1393 export function useQuerySingularLabelQuery(options: SingularLabelQuery) { 1394 return useQuery(constructSingularLabelQuery(options)); 1395 } 1396 + 1397 + 1398 + type SingularAVPostQuery = { 1399 + aturi: string, 1400 + avurl: string, 1401 + } 1402 + type SingularAVPostResult = ATPAPI.AppBskyFeedDefs.PostView 1403 + 1404 + type AVPostQueryPostsQueryParams = { 1405 + aturis: string[], 1406 + avurl: string, 1407 + } 1408 + 1409 + const MAX_URIS_PER_REQUEST = 25; 1410 + function chunk<T>(arr: T[], size: number): T[][] { 1411 + const result: T[][] = []; 1412 + for (let i = 0; i < arr.length; i += size) { 1413 + result.push(arr.slice(i, i + size)); 1414 + } 1415 + return result; 1416 + } 1417 + 1418 + 1419 + export async function innerAVPostsQueryFn( 1420 + options: AVPostQueryPostsQueryParams 1421 + ): Promise<ATPAPI.AppBskyFeedGetPosts.OutputSchema | undefined> { 1422 + const { aturis, avurl } = options; 1423 + 1424 + if (!aturis?.length) return undefined; 1425 + 1426 + const batches = chunk(aturis, MAX_URIS_PER_REQUEST); 1427 + 1428 + const responses = await Promise.all( 1429 + batches.map(async (batch) => { 1430 + const params = new URLSearchParams(); 1431 + batch.forEach((uri) => params.append("uris", uri)); 1432 + 1433 + const url = `${avurl}/xrpc/app.bsky.feed.getPosts?${params.toString()}`; 1434 + 1435 + const res = await fetch(url); 1436 + if (!res.ok) { 1437 + throw new Error(`Labelmerge fetch failed: ${res.status} ${res.statusText}`); 1438 + } 1439 + 1440 + return (await res.json()) as ATPAPI.AppBskyFeedGetPosts.OutputSchema; 1441 + }) 1442 + ); 1443 + 1444 + // Merge all posts into one response 1445 + const merged: ATPAPI.AppBskyFeedGetPosts.OutputSchema = { 1446 + posts: responses.flatMap((r) => r.posts ?? []), 1447 + }; 1448 + 1449 + return merged; 1450 + } 1451 + 1452 + const postquerymerge = create( 1453 + /*<Record<String,SingularLabelResult>[], SingularLabelQuery>*/ { 1454 + // The fetcher resolves the list of queries(here just a list of user ids as number) to one single api call. 1455 + fetcher: async (savpqa: SingularAVPostQuery[]) => { 1456 + // Use a shared QueryClient if possible; creating a new one per fetch is usually not needed 1457 + 1458 + // Deduplicate, but don’t sort 1459 + const sarr = Array.from(new Set(savpqa.map((savpq) => savpq.aturi))); 1460 + 1461 + //const result = await batShitQueryClient.fetchQuery( 1462 + // constructLabelMergeQuery({ s: sarr, l: larr }), 1463 + //); 1464 + const result = await innerAVPostsQueryFn({aturis: sarr, avurl: savpqa.at(-1)?.avurl || savpqa[0].avurl}) 1465 + //const qfn = constructLabelMergeQuery({ s: sarr, l: larr }).queryFn 1466 + //const result = await (qfn ? qfn() : ()=>{}) 1467 + if (!result) return []; 1468 + 1469 + // Build maps for quick lookup 1470 + //const errmap = new Map<string, LabelMergeQueryLabelsOutputSchemaError>(); 1471 + // const resmap = new Map<string, SingularAVPostResult>(); 1472 + 1473 + // result.posts?.forEach((post) => resmap.set(post.uri, post)); 1474 + 1475 + // // Map back to the original queries 1476 + // const output: Record<string, SingularAVPostResult>[] = savpqa.map((savpq) => { 1477 + // const key = savpq.aturi; // or just slq.l if you prefer 1478 + 1479 + // //const err = errmap.get(slq.l); 1480 + // const post = resmap.get(key); 1481 + 1482 + // //if (err) return { [key]: { error: err } }; 1483 + // if (post) return { [key]: { labels: label } }; 1484 + 1485 + // // if result is neither, it means the subject is free of labels 1486 + // return { 1487 + // [key]: { labels: undefined} 1488 + // }; 1489 + // // idiot 1490 + // // return { 1491 + // // [key]: { error: { 1492 + // // s: slq.l, 1493 + // // e: `!internal-bslm-unknown: ${slq.s}` 1494 + // // }} 1495 + // // }; 1496 + // }); 1497 + const output = result.posts; 1498 + 1499 + return output; 1500 + }, 1501 + // when we call users.fetch, this will resolve the correct user using the field `id` 1502 + resolver: (rslra, savpq) => { 1503 + if (rslra.length < 1) { 1504 + return undefined; 1505 + } 1506 + // const result: SingularLabelResult | undefined = slra.find((slr, i) => { 1507 + // // find if error first 1508 + // const error = slr.error; 1509 + // const label = slr.labels; 1510 + // if (error) { 1511 + // if (slq.l === error.s) { 1512 + // return slq; 1513 + // } 1514 + // } else if (label) { 1515 + // // if not error 1516 + // if (slq.l === label.src && slq.s === label.uri) { 1517 + // return slq; 1518 + // } 1519 + // // else unhandled not found 1520 + // } else { 1521 + // return undefined; 1522 + // } 1523 + // return undefined; 1524 + // }); 1525 + //const outputMap: Record<string, SingularLabelResult> = Object.assign({}, ...rslra) 1526 + //const key = `${slq.l}::${slq.s}`; // or just slq.l if you prefer 1527 + const item = rslra.find(obj => obj.uri === savpq.aturi); 1528 + const result: SingularAVPostResult | undefined = item//outputMap[key] 1529 + return result; 1530 + }, 1531 + scheduler: windowScheduler(10 * 100), // 1 second 1532 + }, 1533 + ); 1534 + 1535 + export function constructSingularAVPostQuery(options: SingularAVPostQuery) { 1536 + const { aturi, avurl } = options; 1537 + 1538 + return queryOptions({ 1539 + queryKey: ["__volatile","savpq", aturi], 1540 + 1541 + enabled: !!aturi && !!avurl, 1542 + 1543 + queryFn: async (): Promise<SingularAVPostResult | undefined> => { 1544 + // const result = (await labelmerge.fetch(options).catch(err => {throw { error: err } as SingularLabelResult})) as SingularLabelResult 1545 + // if (result.error) { 1546 + // throw result.error 1547 + // } 1548 + // return result; 1549 + const result = (await postquerymerge 1550 + .fetch(options))as SingularAVPostResult; 1551 + // .catch( 1552 + // (err) => ({ error: err }) as SingularAVPostResult, 1553 + // )) as SingularAVPostResult; 1554 + 1555 + if (result === undefined) { 1556 + throw new Error("what the hell happened") 1557 + } 1558 + return result; 1559 + }, 1560 + 1561 + staleTime: 5 * 60 * 1000, // 5 minutes 1562 + gcTime: 5 * 60 * 1000, 1563 + }); 1564 + } 1565 + 1566 + export function useQuerySingularAVPostQuery(options: SingularAVPostQuery) { 1567 + return useQuery(constructSingularAVPostQuery(options)); 1568 + }