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 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. 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. 3 7 4 8 ![screenshot of red dwarf](/public/screenshot.jpg) 5 9 6 10 huge thanks to [Microcosm](https://microcosm.blue/) for making this possible 7 11 8 - issue tracker kanban board: [https://github.com/users/rimar1337/projects/1/views/1]https://github.com/users/rimar1337/projects/1/views/1 12 + issue tracker kanban board: [currently on GitHub Projects](https://github.com/users/rimar1337/projects/1/views/1) 9 13 10 14 ## running dev and build 11 15 in the `vite.config.ts` file you should change these values
+5 -3
policy.ts
··· 35 35 36 36 ## About Red Dwarf 37 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). 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. 41 43 42 44 ## Hosting Your Own Instance 43 45
+72 -35
src/components/PollComponents.tsx
··· 1 + //import * as ATPAPI from "@atproto/api" 1 2 import { useAtom } from "jotai"; 2 3 import * as React from "react"; 3 4 ··· 5 6 usePollData, 6 7 usePollMutationQueue, 7 8 } from "~/providers/PollMutationQueueProvider"; 8 - import { useAuth } from "~/providers/UnifiedAuthProvider"; 9 + //import { useAuth } from "~/providers/UnifiedAuthProvider"; 9 10 import { renderSnack } from "~/routes/__root"; 10 11 import { imgCDNAtom } from "~/utils/atoms"; 11 12 import { useQueryArbitrary, useQueryConstellation, useQueryProfile } from "~/utils/useQuery"; 12 13 13 - export function PollEmbed({ did, rkey }: { did: string; rkey: string }) { 14 - const { agent } = useAuth(); 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(); 15 29 const { refreshPollData } = usePollMutationQueue(); 16 30 const pollUri = `at://${did}/app.reddwarf.embed.poll/${rkey}`; 17 31 const { data: pollRecord, isLoading, error } = useQueryArbitrary(pollUri); 32 + const dontLoadPolls = embedtryfall && (isLoading || pollRecord === undefined || error !== null) || false 18 33 19 34 const { data: voteCountsA } = useQueryConstellation({ 20 35 method: "/links/count/distinct-dids", ··· 22 37 collection: "app.reddwarf.poll.vote.a", 23 38 path: ".subject.uri", 24 39 customkey: "constellation-polls", 40 + enabled: !dontLoadPolls 25 41 }); 26 42 27 43 const { data: voteCountsB } = useQueryConstellation({ ··· 30 46 collection: "app.reddwarf.poll.vote.b", 31 47 path: ".subject.uri", 32 48 customkey: "constellation-polls", 49 + enabled: !dontLoadPolls 33 50 }); 34 51 35 52 const { data: voteCountsC } = useQueryConstellation({ ··· 38 55 collection: "app.reddwarf.poll.vote.c", 39 56 path: ".subject.uri", 40 57 customkey: "constellation-polls", 58 + enabled: !dontLoadPolls 41 59 }); 42 60 43 61 const { data: voteCountsD } = useQueryConstellation({ ··· 46 64 collection: "app.reddwarf.poll.vote.d", 47 65 path: ".subject.uri", 48 66 customkey: "constellation-polls", 67 + enabled: !dontLoadPolls 49 68 }); 50 69 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 - }); 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 + // }); 79 102 80 103 const poll = { 81 104 ...(pollRecord?.value ?? {}), ··· 99 122 d: parseInt((voteCountsD as any)?.total || "0"), 100 123 }; 101 124 102 - const { results, totalVotes, handleVote } = usePollData( 125 + const { results, totalVotes, handleVote, votersA, votersB, votersC, votersD } = usePollData( 103 126 pollUri, 104 127 pollRecord?.cid, 105 128 !!poll.multiple, 106 129 serverCounts, 130 + !dontLoadPolls 107 131 ); 108 - 109 - if (isLoading) { 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) { 110 147 return ( 111 148 <div className="animate-pulse"> 112 149 <div className="flex items-center gap-2 mb-3"> ··· 128 165 129 166 return ( 130 167 <> 131 - <div className="my-4"> 168 + <div className={`${redactedLoading ? "pointer-events-none": ""} my-4`}> 132 169 <div className="mb-4 flex items-center gap-3"> 133 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"> 134 171 <IconMdiGlobe />
+17 -5
src/components/PostEmbeds.tsx
··· 1 + import * as ATPAPI from "@atproto/api" 1 2 import { 2 3 AppBskyEmbedDefs, 3 4 AppBskyEmbedExternal, ··· 55 56 nopics, 56 57 lightboxCallback, 57 58 constellationLinks, 58 - redactedLoading 59 + redactedLoading, 60 + referral 59 61 }: { 60 62 embed?: Embed; 61 63 moderation?: ModerationDecision; ··· 69 71 lightboxCallback?: (d: LightboxProps) => void; 70 72 constellationLinks?: any; 71 73 redactedLoading?: boolean; 74 + referral?: string[]; 72 75 }) { 73 76 function setLightboxIndex(number: number) { 74 77 navigate({ ··· 548 551 if (AppBskyEmbedExternal.isView(embed)) { 549 552 const pollLinks = constellationLinks?.links?.["app.reddwarf.embed.poll"]; 550 553 const hasPollLink = pollLinks && Object.keys(pollLinks).length > 0; 554 + const isfromappview = referral?.includes("appview") 551 555 552 - if (hasPollLink && postid) { 556 + if ((hasPollLink || isfromappview) && postid) { 553 557 // warning: i gave up and warpped it in a div lmao 554 558 return ( 555 559 <div className={(redactedLoading ? " blur animate-pulse " : undefined)}> 556 - <PollEmbed did={postid.did} rkey={postid.rkey} /> 560 + <PollEmbed did={postid.did} rkey={postid.rkey} embedtryfall={isfromappview ? {embed, onOpen} : undefined} redactedLoading={redactedLoading}/> 557 561 </div> 558 562 ); 559 563 } 560 564 561 565 const link = embed.external; 562 566 return ( 563 - <ExternalLinkEmbed link={link} onOpen={onOpen} style={{ marginTop: 0 }} redactedLoading={redactedLoading} /> 567 + <ExternalLinkEmbed link={link} onOpen={onOpen} style={{ marginTop: 0 }} redactedLoading={redactedLoading}/> 564 568 ); 565 569 } 566 570 ··· 579 583 580 584 return <div />; 581 585 } 586 + export type embedtryfall = { 587 + embed: ATPAPI.AppBskyEmbedExternal.View, 588 + onOpen?: () => void; 589 + } 582 590 583 591 export function ExternalLinkEmbed({ 584 592 link, 585 593 onOpen, 586 594 style, 587 - redactedLoading 595 + redactedLoading, 596 + referral 588 597 }: { 589 598 link: AppBskyEmbedExternal.ViewExternal; 590 599 onOpen?: () => void; 591 600 style?: React.CSSProperties; 592 601 redactedLoading?: boolean; 602 + referral?: string[]; 593 603 }) { 604 + //const fromappview = referral?.includes("appview") 605 + //const [] 594 606 const { uri, title, description, thumb } = link; 595 607 const thumbAspectRatio = 1.91; 596 608
+224 -15
src/components/UniversalPostRenderer.tsx
··· 27 27 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 28 28 //import type { ContentLabel } from "~/types/moderation"; 29 29 import { 30 + appviewUrlAtom, 30 31 composerAtom, 31 32 constellationURLAtom, 33 + enableAppViewAtom, 32 34 enableBridgyTextAtom, 33 35 enableWafrnTextAtom, 34 36 imgCDNAtom, ··· 41 43 useQueryIdentity, 42 44 useQueryPost, 43 45 useQueryProfile, 46 + useQuerySingularAVPostQuery, 44 47 yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks, 45 48 } from "~/utils/useQuery"; 46 49 ··· 97 100 filterMustHaveMedia, 98 101 filterMustBeReply, 99 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) { 100 306 const TEMPLINEAR = true; 101 307 const parsed = new AtUri(atUri); 102 308 const did = parsed?.host; ··· 607 813 lightboxCallback, 608 814 maxReplies, 609 815 constellationLinks, 816 + referral, 610 817 }: { 611 818 post: AppBskyFeedDefs.PostView; 612 819 uprrrsauthor?: AppBskyActorDefs.ProfileViewDetailed; ··· 631 838 lightboxCallback?: (d: LightboxProps) => void; 632 839 maxReplies?: number; 633 840 constellationLinks?: any; 841 + referral?: string[]; 634 842 }) { 635 843 636 844 // todo move moderation to one of the UniversalPostRenderer wrapper components, and not the pure renderer component. please. thanks ··· 929 1137 return null // if feed view post then moderated post isnt important and just remove it from view 930 1138 } 931 1139 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 1140 + <div 1141 + className={`flex flex-col gap-0 border-gray-200 dark:border-gray-800 ${bottomReplyLine ? "" : "border-b"}`} 1142 + onClick={ 1143 + isMainItem 938 1144 ? onPostClick 939 - ? (e) => { 940 - setMainItem({ post: post }); 941 - onPostClick(e); 942 - } 943 - : () => { 944 - setMainItem({ post: post }); 945 - } 946 - : undefined 947 - }> 1145 + : setMainItem 1146 + ? onPostClick 1147 + ? (e) => { 1148 + setMainItem({ post: post }); 1149 + onPostClick(e); 1150 + } 1151 + : () => { 1152 + setMainItem({ post: post }); 1153 + } 1154 + : undefined 1155 + }> 948 1156 949 1157 <div style={{ width: 42, height: 16, minHeight: 16 }} className="flex items-center flex-col mx-4"> 950 1158 <div ··· 1367 1575 nopics={nopics} 1368 1576 lightboxCallback={lightboxCallback} 1369 1577 constellationLinks={constellationLinks} 1578 + referral={[...referral || [], "im upr!"]} 1370 1579 /> 1371 1580 ) : null} 1372 1581 {post.embed && depth > 0 && (
+9
src/providers/PollMutationQueueProvider.tsx
··· 355 355 pollCid: string | undefined, 356 356 isMultiple: boolean, 357 357 serverCounts: { a: number; b: number; c: number; d: number }, 358 + enabled?: boolean 358 359 ) { 359 360 const { agent } = useAuth(); 360 361 const myDid = agent?.did; ··· 371 372 collection: "app.reddwarf.poll.vote.a", 372 373 path: ".subject.uri", 373 374 customkey: "constellation-polls", 375 + enabled: enabled, 374 376 }); 375 377 const { data: votersB } = useQueryConstellation({ 376 378 method: "/links", ··· 378 380 collection: "app.reddwarf.poll.vote.b", 379 381 path: ".subject.uri", 380 382 customkey: "constellation-polls", 383 + enabled: enabled, 381 384 }); 382 385 const { data: votersC } = useQueryConstellation({ 383 386 method: "/links", ··· 385 388 collection: "app.reddwarf.poll.vote.c", 386 389 path: ".subject.uri", 387 390 customkey: "constellation-polls", 391 + enabled: enabled, 388 392 }); 389 393 const { data: votersD } = useQueryConstellation({ 390 394 method: "/links", ··· 392 396 collection: "app.reddwarf.poll.vote.d", 393 397 path: ".subject.uri", 394 398 customkey: "constellation-polls", 399 + enabled: enabled, 395 400 }); 396 401 397 402 const handleVote = useCallback( ··· 480 485 stateD.hasVoted, 481 486 totalVotes: stateA.count + stateB.count + stateC.count + stateD.count, 482 487 handleVote, 488 + votersA, 489 + votersB, 490 + votersC, 491 + votersD 483 492 }; 484 493 }, [ 485 494 localVotes,
+4 -1
src/routes/about.tsx
··· 2 2 3 3 import { FORCED_LABELER_DIDS, HOST_ABOUT_MARKDOWN, HOST_ADMIN, HOST_DESCRIPTION, HOST_HERO, HOST_LABELMERGE, HOST_SIGNUP_PDS } from '~/../policy'; 4 4 import { Header } from '~/components/Header'; 5 - import { defaultconstellationURL, defaultImgCDN, defaultLycanURL, defaultslingshotURL, defaultVideoCDN } from '~/utils/atoms'; 5 + import { defaultAppviewURL, defaultconstellationURL, defaultImgCDN, defaultLycanURL, defaultslingshotURL, defaultVideoCDN } from '~/utils/atoms'; 6 6 7 7 import { ProfileSmall } from './__root'; 8 8 import { NotificationItem } from './notifications'; ··· 219 219 220 220 <span className="font-medium">Lycan (Personal Search):</span> 221 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> 222 225 </div> 223 226 {/* {hostmandate && (<Heading2 title="Host-Mandated Labelers" />)} */} 224 227 <Heading3 title="General Moderation" />
+21
src/routes/settings.tsx
··· 8 8 import Login from "~/components/Login"; 9 9 import { useAuth } from "~/providers/UnifiedAuthProvider"; 10 10 import { 11 + appviewUrlAtom, 11 12 constellationURLAtom, 13 + defaultAppviewURL, 12 14 defaultconstellationURL, 13 15 defaulthue, 14 16 defaultImgCDN, 15 17 defaultLycanURL, 16 18 defaultslingshotURL, 17 19 defaultVideoCDN, 20 + enableAppViewAtom, 18 21 enableBitesAtom, 19 22 enableBridgyTextAtom, 20 23 enableWafrnTextAtom, ··· 34 37 export function Settings() { 35 38 const navigate = useNavigate(); 36 39 const { agent } = useAuth(); 40 + const [isAppViewEnabled] = useAtom(enableAppViewAtom); 37 41 return ( 38 42 <> 39 43 <Header ··· 197 201 description={"Show the original text of posts from Wafrn instances"} 198 202 //init={false} 199 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> 200 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"> 201 221 Notice: Please restart/refresh the app if changes arent applying 202 222 correctly 203 223 </p> 224 + <div className="h-60" /> 204 225 </> 205 226 ); 206 227 }
+10
src/utils/atoms.ts
··· 160 160 false 161 161 ); 162 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 + 163 173 164 174 // polls state 165 175
+182
src/utils/useQuery.ts
··· 250 250 cursor?: string; 251 251 dids?: string[]; 252 252 customkey?: string; 253 + enabled?: boolean; 253 254 }) { 254 255 // : QueryOptions< 255 256 // | linksRecordsResponse ··· 260 261 // Error 261 262 // > 262 263 return queryOptions({ 264 + enabled: query?.enabled, 263 265 queryKey: [ 264 266 "constellation", 265 267 query?.method, ··· 335 337 cursor?: string; 336 338 dids?: string[]; 337 339 customkey?: string; 340 + enabled?: boolean; 338 341 }): UseQueryResult<linksRecordsResponse, Error>; 339 342 export function useQueryConstellation(query: { 340 343 method: "/links/distinct-dids"; ··· 343 346 path: string; 344 347 cursor?: string; 345 348 customkey?: string; 349 + enabled?: boolean; 346 350 }): UseQueryResult<linksDidsResponse, Error>; 347 351 export function useQueryConstellation(query: { 348 352 method: "/links/count"; ··· 351 355 path: string; 352 356 cursor?: string; 353 357 customkey?: string; 358 + enabled?: boolean; 354 359 }): UseQueryResult<linksCountResponse, Error>; 355 360 export function useQueryConstellation(query: { 356 361 method: "/links/count/distinct-dids"; ··· 359 364 path: string; 360 365 cursor?: string; 361 366 customkey?: string; 367 + enabled?: boolean; 362 368 }): UseQueryResult<linksCountResponse, Error>; 363 369 export function useQueryConstellation(query: { 364 370 method: "/links/all"; 365 371 target: string; 366 372 customkey?: string; 373 + enabled?: boolean; 367 374 }): UseQueryResult<linksAllResponse, Error>; 368 375 export function useQueryConstellation(): undefined; 369 376 export function useQueryConstellation(query: { 370 377 method: "undefined"; 371 378 target: string; 372 379 customkey?: string; 380 + enabled?: boolean; 373 381 }): undefined; 374 382 export function useQueryConstellation(query?: { 375 383 method: ··· 385 393 cursor?: string; 386 394 dids?: string[]; 387 395 customkey?: string; 396 + enabled?: boolean; 388 397 }): 389 398 | UseQueryResult< 390 399 | linksRecordsResponse ··· 1384 1393 export function useQuerySingularLabelQuery(options: SingularLabelQuery) { 1385 1394 return useQuery(constructSingularLabelQuery(options)); 1386 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 + }