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

Configure Feed

Select the types of activity you want to include in your feed.

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 + }