an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm

Compare changes

Choose any two refs to compare.

+2 -1
.gitignore
··· 7 7 .env 8 8 .nitro 9 9 .tanstack 10 - public/client-metadata.json 10 + public/client-metadata.json 11 + public/resolvers.json
+9
README.md
··· 15 15 16 16 run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder) 17 17 18 + 19 + 20 + you probably dont need to change these 21 + ```ts 22 + const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party" 23 + const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social" 24 + ``` 25 + if you do want to change these, i recommend changing both of these to your own PDS url. i separate the prod and dev urls so that you can change it as needed. here i separated it because if the prod resolver and prod url shares the same domain itll error and prevent logins 26 + 18 27 ## useQuery 19 28 Red Dwarf has been upgraded from its original bespoke caching system to Tanstack Query (react query). this migration was done to achieve a more robust and maintainable approach to data fetching and caching and state synchronization. ive seen serious performance gains from this switch! 20 29
+62 -30
oauthdev.mts
··· 1 - import fs from 'fs'; 2 - import path from 'path'; 1 + import fs from "fs"; 2 + import path from "path"; 3 3 //import { generateClientMetadata } from './src/helpers/oauthClient' 4 4 export const generateClientMetadata = (appOrigin: string) => { 5 - const callbackPath = '/callback'; 5 + const callbackPath = "/callback"; 6 6 7 7 return { 8 - "client_id": `${appOrigin}/client-metadata.json`, 9 - "client_name": "ForumTest", 10 - "client_uri": appOrigin, 11 - "logo_uri": `${appOrigin}/logo192.png`, 12 - "tos_uri": `${appOrigin}/terms-of-service`, 13 - "policy_uri": `${appOrigin}/privacy-policy`, 14 - "redirect_uris": [`${appOrigin}${callbackPath}`] as [string, ...string[]], 15 - "scope": "atproto transition:generic", 16 - "grant_types": ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"], 17 - "response_types": ["code"] as ["code"], 18 - "token_endpoint_auth_method": "none" as "none", 19 - "application_type": "web" as "web", 20 - "dpop_bound_access_tokens": true 21 - }; 22 - } 23 - 8 + client_id: `${appOrigin}/client-metadata.json`, 9 + client_name: "ForumTest", 10 + client_uri: appOrigin, 11 + logo_uri: `${appOrigin}/logo192.png`, 12 + tos_uri: `${appOrigin}/terms-of-service`, 13 + policy_uri: `${appOrigin}/privacy-policy`, 14 + redirect_uris: [`${appOrigin}${callbackPath}`] as [string, ...string[]], 15 + scope: "atproto transition:generic", 16 + grant_types: ["authorization_code", "refresh_token"] as [ 17 + "authorization_code", 18 + "refresh_token", 19 + ], 20 + response_types: ["code"] as ["code"], 21 + token_endpoint_auth_method: "none" as "none", 22 + application_type: "web" as "web", 23 + dpop_bound_access_tokens: true, 24 + }; 25 + }; 24 26 25 - export function generateMetadataPlugin({prod, dev}:{prod: string, dev: string}) { 27 + export function generateMetadataPlugin({ 28 + prod, 29 + dev, 30 + prodResolver = "https://bsky.social", 31 + devResolver = prodResolver, 32 + }: { 33 + prod: string; 34 + dev: string; 35 + prodResolver?: string; 36 + devResolver?: string; 37 + }) { 26 38 return { 27 - name: 'vite-plugin-generate-metadata', 39 + name: "vite-plugin-generate-metadata", 28 40 config(_config: any, { mode }: any) { 29 - let appOrigin; 30 - if (mode === 'production') { 31 - appOrigin = prod 32 - if (!appOrigin || !appOrigin.startsWith('https://')) { 33 - throw new Error('VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build.'); 41 + console.log('๐Ÿ’ก vite mode =', mode) 42 + let appOrigin, resolver; 43 + if (mode === "production") { 44 + appOrigin = prod; 45 + resolver = prodResolver; 46 + if (!appOrigin || !appOrigin.startsWith("https://")) { 47 + throw new Error( 48 + "VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build." 49 + ); 34 50 } 35 51 } else { 36 52 appOrigin = dev; 53 + resolver = devResolver; 37 54 } 38 - 39 - 55 + 40 56 const metadata = generateClientMetadata(appOrigin); 41 - const outputPath = path.resolve(process.cwd(), 'public', 'client-metadata.json'); 57 + const outputPath = path.resolve( 58 + process.cwd(), 59 + "public", 60 + "client-metadata.json" 61 + ); 42 62 43 63 fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2)); 44 64 65 + const resolvers = { 66 + resolver: resolver, 67 + }; 68 + const resolverOutPath = path.resolve( 69 + process.cwd(), 70 + "public", 71 + "resolvers.json" 72 + ); 73 + 74 + fs.writeFileSync(resolverOutPath, JSON.stringify(resolvers, null, 2)); 75 + 76 + 45 77 // /*mass comment*/ console.log(`โœ… Generated client-metadata.json for ${appOrigin}`); 46 78 }, 47 79 }; 48 - } 80 + }
+10
package-lock.json
··· 29 29 "react": "^19.0.0", 30 30 "react-dom": "^19.0.0", 31 31 "react-player": "^3.3.2", 32 + "sonner": "^2.0.7", 32 33 "tailwindcss": "^4.0.6", 33 34 "tanstack-router-keepalive": "^1.0.0" 34 35 }, ··· 12543 12544 "csstype": "^3.1.0", 12544 12545 "seroval": "~1.3.0", 12545 12546 "seroval-plugins": "~1.3.0" 12547 + } 12548 + }, 12549 + "node_modules/sonner": { 12550 + "version": "2.0.7", 12551 + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", 12552 + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", 12553 + "peerDependencies": { 12554 + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", 12555 + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" 12546 12556 } 12547 12557 }, 12548 12558 "node_modules/source-map": {
+1
package.json
··· 33 33 "react": "^19.0.0", 34 34 "react-dom": "^19.0.0", 35 35 "react-player": "^3.3.2", 36 + "sonner": "^2.0.7", 36 37 "tailwindcss": "^4.0.6", 37 38 "tanstack-router-keepalive": "^1.0.0" 38 39 },
+3
src/auto-imports.d.ts
··· 20 20 const IconMdiAccountCircle: typeof import('~icons/mdi/account-circle.jsx').default 21 21 const IconMdiAccountPlus: typeof import('~icons/mdi/account-plus.jsx').default 22 22 const IconMdiCheck: typeof import('~icons/mdi/check.jsx').default 23 + const IconMdiClose: typeof import('~icons/mdi/close.jsx').default 23 24 const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default 24 25 const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default 26 + const IconMdiShield: typeof import('~icons/mdi/shield.jsx').default 27 + const IconMdiShieldOutline: typeof import('~icons/mdi/shield-outline.jsx').default 25 28 }
+37 -3
src/components/Import.tsx
··· 1 1 import { AtUri } from "@atproto/api"; 2 2 import { useNavigate, type UseNavigateResult } from "@tanstack/react-router"; 3 + import { useAtom } from "jotai"; 3 4 import { useState } from "react"; 5 + 6 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 7 + import { lycanURLAtom } from "~/utils/atoms"; 8 + import { useQueryLycanStatus } from "~/utils/useQuery"; 4 9 5 10 /** 6 11 * Basically the best equivalent to Search that i can do 7 12 */ 8 - export function Import() { 9 - const [textInput, setTextInput] = useState<string | undefined>(); 13 + export function Import({ 14 + optionaltextstring, 15 + }: { 16 + optionaltextstring?: string; 17 + }) { 18 + const [textInput, setTextInput] = useState<string | undefined>( 19 + optionaltextstring 20 + ); 10 21 const navigate = useNavigate(); 11 22 23 + const { status } = useAuth(); 24 + const [lycandomain] = useAtom(lycanURLAtom); 25 + const lycanExists = lycandomain !== ""; 26 + const { data: lycanstatusdata } = useQueryLycanStatus(); 27 + const lycanIndexed = lycanstatusdata?.status === "finished" || false; 28 + const lycanIndexing = lycanstatusdata?.status === "in_progress" || false; 29 + const lycanIndexingProgress = lycanIndexing 30 + ? lycanstatusdata?.progress 31 + : undefined; 32 + const authed = status === "signedIn"; 33 + 34 + const lycanReady = lycanExists && lycanIndexed && authed; 35 + 12 36 const handleEnter = () => { 13 37 if (!textInput) return; 14 38 handleImport({ 15 39 text: textInput, 16 40 navigate, 41 + lycanReady: 42 + lycanReady || (!!lycanIndexingProgress && lycanIndexingProgress > 0), 17 43 }); 18 44 }; 45 + 46 + const placeholder = lycanReady ? "Search..." : "Import..."; 19 47 20 48 return ( 21 49 <div className="w-full relative"> ··· 23 51 24 52 <input 25 53 type="text" 26 - placeholder="Import..." 54 + placeholder={placeholder} 27 55 value={textInput} 28 56 onChange={(e) => setTextInput(e.target.value)} 29 57 onKeyDown={(e) => { ··· 38 66 function handleImport({ 39 67 text, 40 68 navigate, 69 + lycanReady, 41 70 }: { 42 71 text: string; 43 72 navigate: UseNavigateResult<string>; 73 + lycanReady?: boolean; 44 74 }) { 45 75 const trimmed = text.trim(); 46 76 // parse text ··· 147 177 // } catch { 148 178 // // continue 149 179 // } 180 + 181 + if (lycanReady) { 182 + navigate({ to: "/search", search: { q: text } }); 183 + } 150 184 }
+181 -61
src/components/UniversalPostRenderer.tsx
··· 1 + import * as ATPAPI from "@atproto/api"; 1 2 import { useNavigate } from "@tanstack/react-router"; 2 3 import DOMPurify from "dompurify"; 3 4 import { useAtom } from "jotai"; ··· 9 10 import { 10 11 composerAtom, 11 12 constellationURLAtom, 13 + enableBridgyTextAtom, 14 + enableWafrnTextAtom, 12 15 imgCDNAtom, 13 - likedPostsAtom, 14 16 } from "~/utils/atoms"; 15 17 import { useHydratedEmbed } from "~/utils/useHydrated"; 16 18 import { ··· 38 40 feedviewpost?: boolean; 39 41 repostedby?: string; 40 42 style?: React.CSSProperties; 41 - ref?: React.Ref<HTMLDivElement>; 43 + ref?: React.RefObject<HTMLDivElement>; 42 44 dataIndexPropPass?: number; 43 45 nopics?: boolean; 44 46 concise?: boolean; 45 47 lightboxCallback?: (d: LightboxProps) => void; 46 48 maxReplies?: number; 47 49 isQuote?: boolean; 50 + filterNoReplies?: boolean; 51 + filterMustHaveMedia?: boolean; 52 + filterMustBeReply?: boolean; 48 53 } 49 54 50 55 // export async function cachedGetRecord({ ··· 157 162 lightboxCallback, 158 163 maxReplies, 159 164 isQuote, 165 + filterNoReplies, 166 + filterMustHaveMedia, 167 + filterMustBeReply, 160 168 }: UniversalPostRendererATURILoaderProps) { 161 169 // todo remove this once tree rendering is implemented, use a prop like isTree 162 170 const TEMPLINEAR = true; ··· 520 528 ? true 521 529 : maxReplies && !oldestOpsReplyElseNewestNonOpsReply 522 530 ? false 523 - : (maxReplies === 0 && (!replies || (!!replies && replies === 0))) ? false : bottomReplyLine 531 + : maxReplies === 0 && (!replies || (!!replies && replies === 0)) 532 + ? false 533 + : bottomReplyLine 524 534 } 525 535 topReplyLine={topReplyLine} 526 536 //bottomBorder={maxReplies&&oldestOpsReplyElseNewestNonOpsReply ? false : bottomBorder} ··· 542 552 lightboxCallback={lightboxCallback} 543 553 maxReplies={maxReplies} 544 554 isQuote={isQuote} 555 + filterNoReplies={filterNoReplies} 556 + filterMustHaveMedia={filterMustHaveMedia} 557 + filterMustBeReply={filterMustBeReply} 545 558 /> 546 559 <> 547 - {(maxReplies && maxReplies === 0 && replies && replies > 0) ? ( 560 + {maxReplies && maxReplies === 0 && replies && replies > 0 ? ( 548 561 <> 549 - {/* <div>hello</div> */} 550 - <MoreReplies atUri={atUri} /> 562 + {/* <div>hello</div> */} 563 + <MoreReplies atUri={atUri} /> 551 564 </> 552 - ) : (<></>)} 565 + ) : ( 566 + <></> 567 + )} 553 568 </> 554 569 {!isQuote && oldestOpsReplyElseNewestNonOpsReply && ( 555 570 <> ··· 644 659 lightboxCallback, 645 660 maxReplies, 646 661 isQuote, 662 + filterNoReplies, 663 + filterMustHaveMedia, 664 + filterMustBeReply, 647 665 }: { 648 666 postRecord: any; 649 667 profileRecord: any; ··· 659 677 feedviewpost?: boolean; 660 678 repostedby?: string; 661 679 style?: React.CSSProperties; 662 - ref?: React.Ref<HTMLDivElement>; 680 + ref?: React.RefObject<HTMLDivElement>; 663 681 dataIndexPropPass?: number; 664 682 nopics?: boolean; 665 683 concise?: boolean; 666 684 lightboxCallback?: (d: LightboxProps) => void; 667 685 maxReplies?: number; 668 686 isQuote?: boolean; 687 + filterNoReplies?: boolean; 688 + filterMustHaveMedia?: boolean; 689 + filterMustBeReply?: boolean; 669 690 }) { 670 691 // /*mass comment*/ console.log(`received aturi: ${aturi} of post content: ${postRecord}`); 671 692 const navigate = useNavigate(); ··· 736 757 // run(); 737 758 // }, [postRecord, resolved?.did]); 738 759 760 + const hasEmbed = (postRecord?.value as ATPAPI.AppBskyFeedPost.Record)?.embed; 761 + const hasImages = hasEmbed?.$type === "app.bsky.embed.images"; 762 + const hasVideo = hasEmbed?.$type === "app.bsky.embed.video"; 763 + const isquotewithmedia = hasEmbed?.$type === "app.bsky.embed.recordWithMedia"; 764 + const isQuotewithImages = 765 + isquotewithmedia && 766 + (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === 767 + "app.bsky.embed.images"; 768 + const isQuotewithVideo = 769 + isquotewithmedia && 770 + (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === 771 + "app.bsky.embed.video"; 772 + 773 + const hasMedia = 774 + hasEmbed && 775 + (hasImages || hasVideo || isQuotewithImages || isQuotewithVideo); 776 + 739 777 const { 740 778 data: hydratedEmbed, 741 779 isLoading: isEmbedLoading, ··· 830 868 // }, [fakepost, get, set]); 831 869 const thereply = (fakepost?.record as AppBskyFeedPost.Record)?.reply?.parent 832 870 ?.uri; 833 - const feedviewpostreplydid = thereply ? new AtUri(thereply).host : undefined; 871 + const feedviewpostreplydid = 872 + thereply && !filterNoReplies ? new AtUri(thereply).host : undefined; 834 873 const replyhookvalue = useQueryIdentity( 835 874 feedviewpost ? feedviewpostreplydid : undefined 836 875 ); ··· 841 880 repostedby ? aturirepostbydid : undefined 842 881 ); 843 882 const feedviewpostrepostedbyhandle = repostedbyhookvalue?.data?.handle; 883 + 884 + if (filterNoReplies && thereply) return null; 885 + 886 + if (filterMustHaveMedia && !hasMedia) return null; 887 + 888 + if (filterMustBeReply && !thereply) return null; 889 + 844 890 return ( 845 891 <> 846 892 {/* <p> 847 893 {postRecord?.value?.embed.$type + " " + JSON.stringify(hydratedEmbed)} 848 894 </p> */} 895 + {/* <span>filtermustbereply is {filterMustBeReply ? "true" : "false"}</span> 896 + <span>thereply is {thereply ? "true" : "false"}</span> */} 849 897 <UniversalPostRenderer 850 898 expanded={detailed} 851 899 onPostClick={() => ··· 1204 1252 1205 1253 import defaultpfp from "~/../public/favicon.png"; 1206 1254 import { useAuth } from "~/providers/UnifiedAuthProvider"; 1207 - import { FeedItemRenderAturiLoader, FollowButton, Mutual } from "~/routes/profile.$did"; 1255 + import { renderSnack } from "~/routes/__root"; 1256 + import { 1257 + FeedItemRenderAturiLoader, 1258 + FollowButton, 1259 + Mutual, 1260 + } from "~/routes/profile.$did"; 1208 1261 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 1262 + import { useFastLike } from "~/utils/likeMutationQueue"; 1209 1263 // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; 1210 1264 // import type { 1211 1265 // ViewRecord, ··· 1358 1412 depth?: number; 1359 1413 repostedby?: string; 1360 1414 style?: React.CSSProperties; 1361 - ref?: React.Ref<HTMLDivElement>; 1415 + ref?: React.RefObject<HTMLDivElement>; 1362 1416 dataIndexPropPass?: number; 1363 1417 nopics?: boolean; 1364 1418 concise?: boolean; ··· 1367 1421 }) { 1368 1422 const parsed = new AtUri(post.uri); 1369 1423 const navigate = useNavigate(); 1370 - const [likedPosts, setLikedPosts] = useAtom(likedPostsAtom); 1371 1424 const [hasRetweeted, setHasRetweeted] = useState<boolean>( 1372 1425 post.viewer?.repost ? true : false 1373 - ); 1374 - const [hasLiked, setHasLiked] = useState<boolean>( 1375 - post.uri in likedPosts || post.viewer?.like ? true : false 1376 1426 ); 1377 1427 const [, setComposerPost] = useAtom(composerAtom); 1378 1428 const { agent } = useAuth(); 1379 - const [likeUri, setLikeUri] = useState<string | undefined>(post.viewer?.like); 1380 1429 const [retweetUri, setRetweetUri] = useState<string | undefined>( 1381 1430 post.viewer?.repost 1382 1431 ); 1383 - 1384 - const likeOrUnlikePost = async () => { 1385 - const newLikedPosts = { ...likedPosts }; 1386 - if (!agent) { 1387 - console.error("Agent is null or undefined"); 1388 - return; 1389 - } 1390 - if (hasLiked) { 1391 - if (post.uri in likedPosts) { 1392 - const likeUri = likedPosts[post.uri]; 1393 - setLikeUri(likeUri); 1394 - } 1395 - if (likeUri) { 1396 - await agent.deleteLike(likeUri); 1397 - setHasLiked(false); 1398 - delete newLikedPosts[post.uri]; 1399 - } 1400 - } else { 1401 - const { uri } = await agent.like(post.uri, post.cid); 1402 - setLikeUri(uri); 1403 - setHasLiked(true); 1404 - newLikedPosts[post.uri] = uri; 1405 - } 1406 - setLikedPosts(newLikedPosts); 1407 - }; 1432 + const { liked, toggle, backfill } = useFastLike(post.uri, post.cid); 1433 + // const bovref = useBackfillOnView(post.uri, post.cid); 1434 + // React.useLayoutEffect(()=>{ 1435 + // if (expanded && !isQuote) { 1436 + // backfill(); 1437 + // } 1438 + // },[backfill, expanded, isQuote]) 1408 1439 1409 1440 const repostOrUnrepostPost = async () => { 1410 1441 if (!agent) { ··· 1435 1466 : undefined; 1436 1467 1437 1468 const emergencySalt = randomString(); 1438 - const fedi = (post.record as { bridgyOriginalText?: string }) 1469 + 1470 + const [showBridgyText] = useAtom(enableBridgyTextAtom); 1471 + const [showWafrnText] = useAtom(enableWafrnTextAtom); 1472 + 1473 + const unfedibridgy = (post.record as { bridgyOriginalText?: string }) 1439 1474 .bridgyOriginalText; 1475 + const unfediwafrnPartial = (post.record as { fullText?: string }).fullText; 1476 + const unfediwafrnTags = (post.record as { fullTags?: string }).fullTags; 1477 + const unfediwafrnUnHost = (post.record as { fediverseId?: string }) 1478 + .fediverseId; 1479 + 1480 + const undfediwafrnHost = unfediwafrnUnHost 1481 + ? new URL(unfediwafrnUnHost).hostname 1482 + : undefined; 1483 + 1484 + const tags = unfediwafrnTags 1485 + ? unfediwafrnTags 1486 + .split("\n") 1487 + .map((t) => t.trim()) 1488 + .filter(Boolean) 1489 + : undefined; 1490 + 1491 + const links = tags 1492 + ? tags 1493 + .map((tag) => { 1494 + const encoded = encodeURIComponent(tag); 1495 + return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`; 1496 + }) 1497 + .join("<br>") 1498 + : ""; 1499 + 1500 + const unfediwafrn = unfediwafrnPartial 1501 + ? unfediwafrnPartial + (links ? `<br>${links}` : "") 1502 + : undefined; 1503 + 1504 + const fedi = 1505 + (showBridgyText ? unfedibridgy : undefined) ?? 1506 + (showWafrnText ? unfediwafrn : undefined); 1440 1507 1441 1508 /* fuck you */ 1442 1509 const isMainItem = false; 1443 1510 const setMainItem = (any: any) => {}; 1444 1511 // eslint-disable-next-line react-hooks/refs 1445 - console.log("Received ref in UniversalPostRenderer:", ref); 1512 + //console.log("Received ref in UniversalPostRenderer:", usedref); 1446 1513 return ( 1447 1514 <div ref={ref} style={style} data-index={dataIndexPropPass}> 1448 1515 <div ··· 1575 1642 {post.author.displayName || post.author.handle}{" "} 1576 1643 </div> 1577 1644 <div className="text-gray-500 dark:text-gray-400 text-md flex flex-row gap-1"> 1578 - <Mutual targetdidorhandle={post.author.did} />@{post.author.handle}{" "} 1645 + <Mutual targetdidorhandle={post.author.did} />@ 1646 + {post.author.handle}{" "} 1579 1647 </div> 1580 1648 </div> 1581 1649 {uprrrsauthor?.description && ( ··· 1823 1891 </div> 1824 1892 </> 1825 1893 )} 1826 - <div style={{ paddingTop: post.embed && !concise && depth < 1 ? 4 : 0 }}> 1894 + <div 1895 + style={{ 1896 + paddingTop: post.embed && !concise && depth < 1 ? 4 : 0, 1897 + }} 1898 + > 1827 1899 <> 1828 1900 {expanded && ( 1829 1901 <div ··· 1919 1991 </DropdownMenu.Root> 1920 1992 <HitSlopButton 1921 1993 onClick={() => { 1922 - likeOrUnlikePost(); 1994 + toggle(); 1923 1995 }} 1924 1996 style={{ 1925 1997 ...btnstyle, 1926 - ...(hasLiked ? { color: "#EC4899" } : {}), 1998 + ...(liked ? { color: "#EC4899" } : {}), 1927 1999 }} 1928 2000 > 1929 - {hasLiked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />} 1930 - {(post.likeCount || 0) + (hasLiked ? 1 : 0)} 2001 + {liked ? <MdiCardsHeart /> : <MdiCardsHeartOutline />} 2002 + {(post.likeCount || 0) + (liked ? 1 : 0)} 1931 2003 </HitSlopButton> 1932 2004 <div style={{ display: "flex", gap: 8 }}> 1933 2005 <HitSlopButton ··· 1941 2013 "/post/" + 1942 2014 post.uri.split("/").pop() 1943 2015 ); 2016 + renderSnack({ 2017 + title: "Copied to clipboard!", 2018 + }); 1944 2019 } catch (_e) { 1945 2020 // idk 2021 + renderSnack({ 2022 + title: "Failed to copy link", 2023 + }); 1946 2024 } 1947 2025 }} 1948 2026 style={{ ··· 1951 2029 > 1952 2030 <MdiShareVariant /> 1953 2031 </HitSlopButton> 1954 - <span style={btnstyle}> 1955 - <MdiMoreHoriz /> 1956 - </span> 2032 + <HitSlopButton 2033 + onClick={() => { 2034 + renderSnack({ 2035 + title: "Not implemented yet...", 2036 + }); 2037 + }} 2038 + > 2039 + <span style={btnstyle}> 2040 + <MdiMoreHoriz /> 2041 + </span> 2042 + </HitSlopButton> 1957 2043 </div> 1958 2044 </div> 1959 2045 )} ··· 2192 2278 // <MaybeFeedCard view={embed.record} /> 2193 2279 // </div> 2194 2280 // ) 2195 - } else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.feed.generator") { 2196 - return <div className="rounded-xl border"><FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder/></div> 2281 + } else if ( 2282 + !!reallybaduri && 2283 + !!reallybadaturi && 2284 + reallybadaturi.collection === "app.bsky.feed.generator" 2285 + ) { 2286 + return ( 2287 + <div className="rounded-xl border"> 2288 + <FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder /> 2289 + </div> 2290 + ); 2197 2291 } 2198 2292 2199 2293 // list embed ··· 2205 2299 // <MaybeListCard view={embed.record} /> 2206 2300 // </div> 2207 2301 // ) 2208 - } else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.graph.list") { 2209 - return <div className="rounded-xl border"><FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder listmode disablePropagation /></div> 2302 + } else if ( 2303 + !!reallybaduri && 2304 + !!reallybadaturi && 2305 + reallybadaturi.collection === "app.bsky.graph.list" 2306 + ) { 2307 + return ( 2308 + <div className="rounded-xl border"> 2309 + <FeedItemRenderAturiLoader 2310 + aturi={reallybaduri} 2311 + disableBottomBorder 2312 + listmode 2313 + disablePropagation 2314 + /> 2315 + </div> 2316 + ); 2210 2317 } 2211 2318 2212 2319 // starter pack embed ··· 2218 2325 // <StarterPackCard starterPack={embed.record} /> 2219 2326 // </div> 2220 2327 // ) 2221 - } else if (!!reallybaduri && !!reallybadaturi && reallybadaturi.collection === "app.bsky.graph.starterpack") { 2222 - return <div className="rounded-xl border"><FeedItemRenderAturiLoader aturi={reallybaduri} disableBottomBorder listmode disablePropagation /></div> 2328 + } else if ( 2329 + !!reallybaduri && 2330 + !!reallybadaturi && 2331 + reallybadaturi.collection === "app.bsky.graph.starterpack" 2332 + ) { 2333 + return ( 2334 + <div className="rounded-xl border"> 2335 + <FeedItemRenderAturiLoader 2336 + aturi={reallybaduri} 2337 + disableBottomBorder 2338 + listmode 2339 + disablePropagation 2340 + /> 2341 + </div> 2342 + ); 2223 2343 } 2224 2344 2225 2345 // quote post ··· 2713 2833 className="link" 2714 2834 style={{ 2715 2835 textDecoration: "none", 2716 - color: "rgb(29, 122, 242)", 2836 + color: "var(--link-text-color)", 2717 2837 wordBreak: "break-all", 2718 2838 }} 2719 2839 target="_blank" ··· 2733 2853 result.push( 2734 2854 <span 2735 2855 key={start} 2736 - style={{ color: "rgb(29, 122, 242)" }} 2856 + style={{ color: "var(--link-text-color)" }} 2737 2857 className=" cursor-pointer" 2738 2858 onClick={(e) => { 2739 2859 e.stopPropagation(); ··· 2751 2871 result.push( 2752 2872 <span 2753 2873 key={start} 2754 - style={{ color: "rgb(29, 122, 242)" }} 2874 + style={{ color: "var(--link-text-color)" }} 2755 2875 onClick={(e) => { 2756 2876 e.stopPropagation(); 2757 2877 }}
+163
src/providers/LikeMutationQueueProvider.tsx
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { TID } from "@atproto/common-web"; 3 + import { useQueryClient } from "@tanstack/react-query"; 4 + import { useAtom } from "jotai"; 5 + import React, { createContext, use, useCallback, useEffect, useRef } from "react"; 6 + 7 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 8 + import { renderSnack } from "~/routes/__root"; 9 + import { constellationURLAtom, internalLikedPostsAtom } from "~/utils/atoms"; 10 + import { constructArbitraryQuery, constructConstellationQuery, type linksRecordsResponse } from "~/utils/useQuery"; 11 + 12 + export type LikeRecord = { uri: string; target: string; cid: string }; 13 + export type LikeMutation = { type: 'like'; target: string; cid: string }; 14 + export type UnlikeMutation = { type: 'unlike'; likeRecordUri: string; target: string, originalRecord: LikeRecord }; 15 + export type Mutation = LikeMutation | UnlikeMutation; 16 + 17 + interface LikeMutationQueueContextType { 18 + fastState: (target: string) => LikeRecord | null | undefined; 19 + fastToggle: (target:string, cid:string) => void; 20 + backfillState: (target: string, user: string) => Promise<void>; 21 + } 22 + 23 + const LikeMutationQueueContext = createContext<LikeMutationQueueContextType | undefined>(undefined); 24 + 25 + export function LikeMutationQueueProvider({ children }: { children: React.ReactNode }) { 26 + const { agent } = useAuth(); 27 + const queryClient = useQueryClient(); 28 + const [likedPosts, setLikedPosts] = useAtom(internalLikedPostsAtom); 29 + const [constellationurl] = useAtom(constellationURLAtom); 30 + 31 + const likedPostsRef = useRef(likedPosts); 32 + useEffect(() => { 33 + likedPostsRef.current = likedPosts; 34 + }, [likedPosts]); 35 + 36 + const queueRef = useRef<Mutation[]>([]); 37 + const runningRef = useRef(false); 38 + 39 + const fastState = (target: string) => likedPosts[target]; 40 + 41 + const setFastState = useCallback( 42 + (target: string, record: LikeRecord | null) => 43 + setLikedPosts((prev) => ({ ...prev, [target]: record })), 44 + [setLikedPosts] 45 + ); 46 + 47 + const enqueue = (mutation: Mutation) => queueRef.current.push(mutation); 48 + 49 + const fastToggle = useCallback((target: string, cid: string) => { 50 + const likedRecord = likedPostsRef.current[target]; 51 + 52 + if (likedRecord) { 53 + setFastState(target, null); 54 + if (likedRecord.uri !== 'pending') { 55 + enqueue({ type: "unlike", likeRecordUri: likedRecord.uri, target, originalRecord: likedRecord }); 56 + } 57 + } else { 58 + setFastState(target, { uri: "pending", target, cid }); 59 + enqueue({ type: "like", target, cid }); 60 + } 61 + }, [setFastState]); 62 + 63 + /** 64 + * 65 + * @deprecated dont use it yet, will cause infinite rerenders 66 + */ 67 + const backfillState = async (target: string, user: string) => { 68 + const query = constructConstellationQuery({ 69 + constellation: constellationurl, 70 + method: "/links", 71 + target, 72 + collection: "app.bsky.feed.like", 73 + path: ".subject.uri", 74 + dids: [user], 75 + }); 76 + const data = await queryClient.fetchQuery(query); 77 + const likes = (data as linksRecordsResponse)?.linking_records?.slice(0, 50) ?? []; 78 + const found = likes.find((r) => r.did === user); 79 + if (found) { 80 + const uri = `at://${found.did}/${found.collection}/${found.rkey}`; 81 + const ciddata = await queryClient.fetchQuery( 82 + constructArbitraryQuery(uri) 83 + ); 84 + if (ciddata?.cid) 85 + setFastState(target, { uri, target, cid: ciddata?.cid }); 86 + } else { 87 + setFastState(target, null); 88 + } 89 + }; 90 + 91 + 92 + useEffect(() => { 93 + if (!agent?.did) return; 94 + 95 + const processQueue = async () => { 96 + if (runningRef.current || queueRef.current.length === 0) return; 97 + runningRef.current = true; 98 + 99 + while (queueRef.current.length > 0) { 100 + const mutation = queueRef.current.shift()!; 101 + try { 102 + if (mutation.type === "like") { 103 + const newRecord = { 104 + repo: agent.did!, 105 + collection: "app.bsky.feed.like", 106 + rkey: TID.next().toString(), 107 + record: { 108 + $type: "app.bsky.feed.like", 109 + subject: { uri: mutation.target, cid: mutation.cid }, 110 + createdAt: new Date().toISOString(), 111 + }, 112 + }; 113 + const response = await agent.com.atproto.repo.createRecord(newRecord); 114 + if (!response.success) throw new Error("createRecord failed"); 115 + 116 + const uri = `at://${agent.did}/${newRecord.collection}/${newRecord.rkey}`; 117 + setFastState(mutation.target, { 118 + uri, 119 + target: mutation.target, 120 + cid: mutation.cid, 121 + }); 122 + } else if (mutation.type === "unlike") { 123 + const aturi = new AtUri(mutation.likeRecordUri); 124 + await agent.com.atproto.repo.deleteRecord({ repo: agent.did!, collection: aturi.collection, rkey: aturi.rkey }); 125 + setFastState(mutation.target, null); 126 + } 127 + } catch (err) { 128 + console.error("Like mutation failed, reverting:", err); 129 + renderSnack({ 130 + title: 'Like Mutation Failed', 131 + description: 'Please try again.', 132 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 133 + }) 134 + if (mutation.type === 'like') { 135 + setFastState(mutation.target, null); 136 + } else if (mutation.type === 'unlike') { 137 + setFastState(mutation.target, mutation.originalRecord); 138 + } 139 + } 140 + } 141 + runningRef.current = false; 142 + }; 143 + 144 + const interval = setInterval(processQueue, 1000); 145 + return () => clearInterval(interval); 146 + }, [agent, setFastState]); 147 + 148 + const value = { fastState, fastToggle, backfillState }; 149 + 150 + return ( 151 + <LikeMutationQueueContext value={value}> 152 + {children} 153 + </LikeMutationQueueContext> 154 + ); 155 + } 156 + 157 + export function useLikeMutationQueue() { 158 + const context = use(LikeMutationQueueContext); 159 + if (context === undefined) { 160 + throw new Error('useLikeMutationQueue must be used within a LikeMutationQueueProvider'); 161 + } 162 + return context; 163 + }
+63
src/routeTree.gen.ts
··· 12 12 import { Route as SettingsRouteImport } from './routes/settings' 13 13 import { Route as SearchRouteImport } from './routes/search' 14 14 import { Route as NotificationsRouteImport } from './routes/notifications' 15 + import { Route as ModerationRouteImport } from './routes/moderation' 15 16 import { Route as FeedsRouteImport } from './routes/feeds' 16 17 import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout' 17 18 import { Route as IndexRouteImport } from './routes/index' 18 19 import { Route as CallbackIndexRouteImport } from './routes/callback/index' 19 20 import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout' 20 21 import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index' 22 + import { Route as ProfileDidFollowsRouteImport } from './routes/profile.$did/follows' 23 + import { Route as ProfileDidFollowersRouteImport } from './routes/profile.$did/followers' 21 24 import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b' 22 25 import { Route as PathlessLayoutNestedLayoutRouteARouteImport } from './routes/_pathlessLayout/_nested-layout/route-a' 23 26 import { Route as ProfileDidPostRkeyRouteImport } from './routes/profile.$did/post.$rkey' ··· 40 43 const NotificationsRoute = NotificationsRouteImport.update({ 41 44 id: '/notifications', 42 45 path: '/notifications', 46 + getParentRoute: () => rootRouteImport, 47 + } as any) 48 + const ModerationRoute = ModerationRouteImport.update({ 49 + id: '/moderation', 50 + path: '/moderation', 43 51 getParentRoute: () => rootRouteImport, 44 52 } as any) 45 53 const FeedsRoute = FeedsRouteImport.update({ ··· 71 79 path: '/profile/$did/', 72 80 getParentRoute: () => rootRouteImport, 73 81 } as any) 82 + const ProfileDidFollowsRoute = ProfileDidFollowsRouteImport.update({ 83 + id: '/profile/$did/follows', 84 + path: '/profile/$did/follows', 85 + getParentRoute: () => rootRouteImport, 86 + } as any) 87 + const ProfileDidFollowersRoute = ProfileDidFollowersRouteImport.update({ 88 + id: '/profile/$did/followers', 89 + path: '/profile/$did/followers', 90 + getParentRoute: () => rootRouteImport, 91 + } as any) 74 92 const PathlessLayoutNestedLayoutRouteBRoute = 75 93 PathlessLayoutNestedLayoutRouteBRouteImport.update({ 76 94 id: '/route-b', ··· 121 139 export interface FileRoutesByFullPath { 122 140 '/': typeof IndexRoute 123 141 '/feeds': typeof FeedsRoute 142 + '/moderation': typeof ModerationRoute 124 143 '/notifications': typeof NotificationsRoute 125 144 '/search': typeof SearchRoute 126 145 '/settings': typeof SettingsRoute 127 146 '/callback': typeof CallbackIndexRoute 128 147 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 129 148 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 149 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 150 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 130 151 '/profile/$did': typeof ProfileDidIndexRoute 131 152 '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 132 153 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren ··· 138 159 export interface FileRoutesByTo { 139 160 '/': typeof IndexRoute 140 161 '/feeds': typeof FeedsRoute 162 + '/moderation': typeof ModerationRoute 141 163 '/notifications': typeof NotificationsRoute 142 164 '/search': typeof SearchRoute 143 165 '/settings': typeof SettingsRoute 144 166 '/callback': typeof CallbackIndexRoute 145 167 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 146 168 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 169 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 170 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 147 171 '/profile/$did': typeof ProfileDidIndexRoute 148 172 '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 149 173 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren ··· 157 181 '/': typeof IndexRoute 158 182 '/_pathlessLayout': typeof PathlessLayoutRouteWithChildren 159 183 '/feeds': typeof FeedsRoute 184 + '/moderation': typeof ModerationRoute 160 185 '/notifications': typeof NotificationsRoute 161 186 '/search': typeof SearchRoute 162 187 '/settings': typeof SettingsRoute ··· 164 189 '/callback/': typeof CallbackIndexRoute 165 190 '/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 166 191 '/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 192 + '/profile/$did/followers': typeof ProfileDidFollowersRoute 193 + '/profile/$did/follows': typeof ProfileDidFollowsRoute 167 194 '/profile/$did/': typeof ProfileDidIndexRoute 168 195 '/profile/$did/feed/$rkey': typeof ProfileDidFeedRkeyRoute 169 196 '/profile/$did/post/$rkey': typeof ProfileDidPostRkeyRouteWithChildren ··· 177 204 fullPaths: 178 205 | '/' 179 206 | '/feeds' 207 + | '/moderation' 180 208 | '/notifications' 181 209 | '/search' 182 210 | '/settings' 183 211 | '/callback' 184 212 | '/route-a' 185 213 | '/route-b' 214 + | '/profile/$did/followers' 215 + | '/profile/$did/follows' 186 216 | '/profile/$did' 187 217 | '/profile/$did/feed/$rkey' 188 218 | '/profile/$did/post/$rkey' ··· 194 224 to: 195 225 | '/' 196 226 | '/feeds' 227 + | '/moderation' 197 228 | '/notifications' 198 229 | '/search' 199 230 | '/settings' 200 231 | '/callback' 201 232 | '/route-a' 202 233 | '/route-b' 234 + | '/profile/$did/followers' 235 + | '/profile/$did/follows' 203 236 | '/profile/$did' 204 237 | '/profile/$did/feed/$rkey' 205 238 | '/profile/$did/post/$rkey' ··· 212 245 | '/' 213 246 | '/_pathlessLayout' 214 247 | '/feeds' 248 + | '/moderation' 215 249 | '/notifications' 216 250 | '/search' 217 251 | '/settings' ··· 219 253 | '/callback/' 220 254 | '/_pathlessLayout/_nested-layout/route-a' 221 255 | '/_pathlessLayout/_nested-layout/route-b' 256 + | '/profile/$did/followers' 257 + | '/profile/$did/follows' 222 258 | '/profile/$did/' 223 259 | '/profile/$did/feed/$rkey' 224 260 | '/profile/$did/post/$rkey' ··· 232 268 IndexRoute: typeof IndexRoute 233 269 PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren 234 270 FeedsRoute: typeof FeedsRoute 271 + ModerationRoute: typeof ModerationRoute 235 272 NotificationsRoute: typeof NotificationsRoute 236 273 SearchRoute: typeof SearchRoute 237 274 SettingsRoute: typeof SettingsRoute 238 275 CallbackIndexRoute: typeof CallbackIndexRoute 276 + ProfileDidFollowersRoute: typeof ProfileDidFollowersRoute 277 + ProfileDidFollowsRoute: typeof ProfileDidFollowsRoute 239 278 ProfileDidIndexRoute: typeof ProfileDidIndexRoute 240 279 ProfileDidFeedRkeyRoute: typeof ProfileDidFeedRkeyRoute 241 280 ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRouteWithChildren ··· 264 303 preLoaderRoute: typeof NotificationsRouteImport 265 304 parentRoute: typeof rootRouteImport 266 305 } 306 + '/moderation': { 307 + id: '/moderation' 308 + path: '/moderation' 309 + fullPath: '/moderation' 310 + preLoaderRoute: typeof ModerationRouteImport 311 + parentRoute: typeof rootRouteImport 312 + } 267 313 '/feeds': { 268 314 id: '/feeds' 269 315 path: '/feeds' ··· 306 352 preLoaderRoute: typeof ProfileDidIndexRouteImport 307 353 parentRoute: typeof rootRouteImport 308 354 } 355 + '/profile/$did/follows': { 356 + id: '/profile/$did/follows' 357 + path: '/profile/$did/follows' 358 + fullPath: '/profile/$did/follows' 359 + preLoaderRoute: typeof ProfileDidFollowsRouteImport 360 + parentRoute: typeof rootRouteImport 361 + } 362 + '/profile/$did/followers': { 363 + id: '/profile/$did/followers' 364 + path: '/profile/$did/followers' 365 + fullPath: '/profile/$did/followers' 366 + preLoaderRoute: typeof ProfileDidFollowersRouteImport 367 + parentRoute: typeof rootRouteImport 368 + } 309 369 '/_pathlessLayout/_nested-layout/route-b': { 310 370 id: '/_pathlessLayout/_nested-layout/route-b' 311 371 path: '/route-b' ··· 416 476 IndexRoute: IndexRoute, 417 477 PathlessLayoutRoute: PathlessLayoutRouteWithChildren, 418 478 FeedsRoute: FeedsRoute, 479 + ModerationRoute: ModerationRoute, 419 480 NotificationsRoute: NotificationsRoute, 420 481 SearchRoute: SearchRoute, 421 482 SettingsRoute: SettingsRoute, 422 483 CallbackIndexRoute: CallbackIndexRoute, 484 + ProfileDidFollowersRoute: ProfileDidFollowersRoute, 485 + ProfileDidFollowsRoute: ProfileDidFollowsRoute, 423 486 ProfileDidIndexRoute: ProfileDidIndexRoute, 424 487 ProfileDidFeedRkeyRoute: ProfileDidFeedRkeyRoute, 425 488 ProfileDidPostRkeyRoute: ProfileDidPostRkeyRouteWithChildren,
+178 -16
src/routes/__root.tsx
··· 14 14 import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; 15 15 import { useAtom } from "jotai"; 16 16 import * as React from "react"; 17 + import { toast as sonnerToast } from "sonner"; 18 + import { Toaster } from "sonner"; 17 19 import { KeepAliveOutlet, KeepAliveProvider } from "tanstack-router-keepalive"; 18 20 19 21 import { Composer } from "~/components/Composer"; ··· 22 24 import Login from "~/components/Login"; 23 25 import { NotFound } from "~/components/NotFound"; 24 26 import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; 27 + import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider"; 25 28 import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; 26 29 import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms"; 27 30 import { seo } from "~/utils/seo"; ··· 79 82 function RootComponent() { 80 83 return ( 81 84 <UnifiedAuthProvider> 82 - <RootDocument> 83 - <KeepAliveProvider> 84 - <KeepAliveOutlet /> 85 - </KeepAliveProvider> 86 - </RootDocument> 85 + <LikeMutationQueueProvider> 86 + <RootDocument> 87 + <KeepAliveProvider> 88 + <AppToaster /> 89 + <KeepAliveOutlet /> 90 + </KeepAliveProvider> 91 + </RootDocument> 92 + </LikeMutationQueueProvider> 87 93 </UnifiedAuthProvider> 88 94 ); 89 95 } 90 96 97 + export function AppToaster() { 98 + return ( 99 + <Toaster 100 + position="bottom-center" 101 + toastOptions={{ 102 + duration: 4000, 103 + }} 104 + /> 105 + ); 106 + } 107 + 108 + export function renderSnack({ 109 + title, 110 + description, 111 + button, 112 + }: Omit<ToastProps, "id">) { 113 + return sonnerToast.custom((id) => ( 114 + <Snack 115 + id={id} 116 + title={title} 117 + description={description} 118 + button={ 119 + button?.label 120 + ? { 121 + label: button?.label, 122 + onClick: () => { 123 + button?.onClick?.(); 124 + }, 125 + } 126 + : undefined 127 + } 128 + /> 129 + )); 130 + } 131 + 132 + function Snack(props: ToastProps) { 133 + const { title, description, button, id } = props; 134 + 135 + return ( 136 + <div 137 + role="status" 138 + aria-live="polite" 139 + className=" 140 + w-full md:max-w-[520px] 141 + flex items-center justify-between 142 + rounded-md 143 + px-4 py-3 144 + shadow-sm 145 + dark:bg-gray-300 dark:text-gray-900 146 + bg-gray-700 text-gray-100 147 + ring-1 dark:ring-gray-200 ring-gray-800 148 + " 149 + > 150 + <div className="flex-1 min-w-0"> 151 + <p className="text-sm font-medium truncate">{title}</p> 152 + {description ? ( 153 + <p className="mt-1 text-sm dark:text-gray-600 text-gray-300 truncate"> 154 + {description} 155 + </p> 156 + ) : null} 157 + </div> 158 + 159 + {button ? ( 160 + <div className="ml-4 flex-shrink-0"> 161 + <button 162 + className=" 163 + text-sm font-medium 164 + px-3 py-1 rounded-md 165 + bg-gray-200 text-gray-900 166 + hover:bg-gray-300 167 + dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 168 + focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-300 dark:focus:ring-gray-700 169 + " 170 + onClick={() => { 171 + button.onClick(); 172 + sonnerToast.dismiss(id); 173 + }} 174 + > 175 + {button.label} 176 + </button> 177 + </div> 178 + ) : null} 179 + <button className=" ml-4" 180 + onClick={() => { 181 + sonnerToast.dismiss(id); 182 + }} 183 + > 184 + <IconMdiClose /> 185 + </button> 186 + </div> 187 + ); 188 + } 189 + 190 + /* Types */ 191 + interface ToastProps { 192 + id: string | number; 193 + title: string; 194 + description?: string; 195 + button?: { 196 + label: string; 197 + onClick: () => void; 198 + }; 199 + } 200 + 91 201 function RootDocument({ children }: { children: React.ReactNode }) { 92 202 useAtomCssVar(hueAtom, "--tw-gray-hue"); 93 203 const location = useLocation(); ··· 103 213 const isSettings = location.pathname.startsWith("/settings"); 104 214 const isSearch = location.pathname.startsWith("/search"); 105 215 const isFeeds = location.pathname.startsWith("/feeds"); 216 + const isModeration = location.pathname.startsWith("/moderation"); 106 217 107 218 const locationEnum: 108 219 | "feeds" ··· 110 221 | "settings" 111 222 | "notifications" 112 223 | "profile" 224 + | "moderation" 113 225 | "home" = isFeeds 114 226 ? "feeds" 115 227 : isSearch ··· 120 232 ? "notifications" 121 233 : isProfile 122 234 ? "profile" 123 - : "home"; 235 + : isModeration 236 + ? "moderation" 237 + : "home"; 124 238 125 239 const [, setComposerPost] = useAtom(composerAtom); 126 240 ··· 131 245 <div className="min-h-screen flex justify-center bg-gray-50 dark:bg-gray-950"> 132 246 <nav className="hidden lg:flex h-screen w-[250px] flex-col gap-0 p-4 dark:border-gray-800 sticky top-0 self-start"> 133 247 <div className="flex items-center gap-3 mb-4"> 134 - <FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 248 + <FluentEmojiHighContrastGlowingStar 249 + className="h-8 w-8" 250 + style={{ 251 + color: 252 + "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))", 253 + }} 254 + /> 135 255 <span className="font-extrabold text-2xl tracking-tight text-gray-900 dark:text-gray-100"> 136 256 Red Dwarf{" "} 137 257 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 193 313 }) 194 314 } 195 315 text="Feeds" 316 + /> 317 + <MaterialNavItem 318 + InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />} 319 + ActiveIcon={<IconMdiShield className="w-6 h-6" />} 320 + active={locationEnum === "moderation"} 321 + onClickCallbback={() => 322 + navigate({ 323 + to: "/moderation", 324 + //params: { did: agent.assertDid }, 325 + }) 326 + } 327 + text="Moderation" 196 328 /> 197 329 <MaterialNavItem 198 330 InactiveIcon={ ··· 232 364 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 233 365 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 234 366 //active={true} 235 - onClickCallbback={() => setComposerPost({ kind: 'root' })} 367 + onClickCallbback={() => setComposerPost({ kind: "root" })} 236 368 text="Post" 237 369 /> 238 370 </div> ··· 370 502 371 503 <nav className="hidden sm:flex items-center lg:hidden h-screen flex-col gap-2 p-4 dark:border-gray-800 sticky top-0 self-start"> 372 504 <div className="flex items-center gap-3 mb-4"> 373 - <FluentEmojiHighContrastGlowingStar className="h-8 w-8" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 505 + <FluentEmojiHighContrastGlowingStar 506 + className="h-8 w-8" 507 + style={{ 508 + color: 509 + "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))", 510 + }} 511 + /> 374 512 </div> 375 513 <MaterialNavItem 376 514 small ··· 433 571 /> 434 572 <MaterialNavItem 435 573 small 574 + InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />} 575 + ActiveIcon={<IconMdiShield className="w-6 h-6" />} 576 + active={locationEnum === "moderation"} 577 + onClickCallbback={() => 578 + navigate({ 579 + to: "/moderation", 580 + //params: { did: agent.assertDid }, 581 + }) 582 + } 583 + text="Moderation" 584 + /> 585 + <MaterialNavItem 586 + small 436 587 InactiveIcon={ 437 588 <IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" /> 438 589 } ··· 472 623 InactiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 473 624 ActiveIcon={<IconMdiPencilOutline className="w-6 h-6" />} 474 625 //active={true} 475 - onClickCallbback={() => setComposerPost({ kind: 'root' })} 626 + onClickCallbback={() => setComposerPost({ kind: "root" })} 476 627 text="Post" 477 628 /> 478 629 </div> ··· 482 633 <button 483 634 className="lg:hidden fixed bottom-22 right-4 z-50 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 rounded-2xl w-14 h-14 flex items-center justify-center transition-all" 484 635 style={{ boxShadow: "0 4px 24px 0 rgba(0,0,0,0.12)" }} 485 - onClick={() => setComposerPost({ kind: 'root' })} 636 + onClick={() => setComposerPost({ kind: "root" })} 486 637 type="button" 487 638 aria-label="Create Post" 488 639 > ··· 499 650 </main> 500 651 501 652 <aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col"> 502 - <div className="px-4 pt-4"><Import /></div> 653 + <div className="px-4 pt-4"> 654 + <Import /> 655 + </div> 503 656 <Login /> 504 657 505 658 <div className="flex-1"></div> 506 659 <p className="text-xs text-gray-400 dark:text-gray-500 text-justify mx-4 mb-4"> 507 - Red Dwarf is a Bluesky client that does not rely on any Bluesky API App Servers. Instead, it uses Microcosm to fetch records directly from each users' PDS (via Slingshot) and connect them using backlinks (via Constellation) 660 + Red Dwarf is a Bluesky client that does not rely on any Bluesky API 661 + App Servers. Instead, it uses Microcosm to fetch records directly 662 + from each users' PDS (via Slingshot) and connect them using 663 + backlinks (via Constellation) 508 664 </p> 509 665 </aside> 510 666 </div> ··· 651 807 <IconMaterialSymbolsSettingsOutline className="w-6 h-6" /> 652 808 } 653 809 ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />} 654 - active={locationEnum === "settings"} 810 + active={locationEnum === "settings" || locationEnum === "feeds" || locationEnum === "moderation"} 655 811 onClickCallbback={() => 656 812 navigate({ 657 813 to: "/settings", ··· 680 836 ) : ( 681 837 <div className="lg:hidden flex items-center fixed bottom-0 left-0 right-0 justify-between px-4 py-3 border-0 shadow border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 z-10"> 682 838 <div className="flex items-center gap-2"> 683 - <FluentEmojiHighContrastGlowingStar className="h-6 w-6" style={{color: "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))"}} /> 839 + <FluentEmojiHighContrastGlowingStar 840 + className="h-6 w-6" 841 + style={{ 842 + color: 843 + "oklch(0.6616 0.2249 calc(25.88 + (var(--safe-hue) - 28))", 844 + }} 845 + /> 684 846 <span className="font-bold text-lg text-gray-900 dark:text-gray-100"> 685 847 Red Dwarf{" "} 686 848 {/* <span className="text-gray-500 dark:text-gray-400 text-sm"> ··· 700 862 ); 701 863 } 702 864 703 - function MaterialNavItem({ 865 + export function MaterialNavItem({ 704 866 InactiveIcon, 705 867 ActiveIcon, 706 868 text,
+18 -1
src/routes/feeds.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 3 + import { Header } from "~/components/Header"; 4 + 3 5 export const Route = createFileRoute("/feeds")({ 4 6 component: Feeds, 5 7 }); 6 8 7 9 export function Feeds() { 8 - return <div className="p-6">Feeds page (coming soon)</div>; 10 + return ( 11 + <div className=""> 12 + <Header 13 + title={`Feeds`} 14 + backButtonCallback={() => { 15 + if (window.history.length > 1) { 16 + window.history.back(); 17 + } else { 18 + window.location.assign("/"); 19 + } 20 + }} 21 + bottomBorderDisabled={true} 22 + /> 23 + Feeds page (coming soon) 24 + </div> 25 + ); 9 26 }
+269
src/routes/moderation.tsx
··· 1 + import * as ATPAPI from "@atproto/api"; 2 + import { 3 + isAdultContentPref, 4 + isBskyAppStatePref, 5 + isContentLabelPref, 6 + isFeedViewPref, 7 + isLabelersPref, 8 + isMutedWordsPref, 9 + isSavedFeedsPref, 10 + } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 11 + import { createFileRoute } from "@tanstack/react-router"; 12 + import { useAtom } from "jotai"; 13 + import { Switch } from "radix-ui"; 14 + 15 + import { Header } from "~/components/Header"; 16 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 17 + import { quickAuthAtom } from "~/utils/atoms"; 18 + import { useQueryIdentity, useQueryPreferences } from "~/utils/useQuery"; 19 + 20 + import { renderSnack } from "./__root"; 21 + import { NotificationItem } from "./notifications"; 22 + import { SettingHeading } from "./settings"; 23 + 24 + export const Route = createFileRoute("/moderation")({ 25 + component: RouteComponent, 26 + }); 27 + 28 + function RouteComponent() { 29 + const { agent } = useAuth(); 30 + 31 + const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 32 + const isAuthRestoring = quickAuth ? status === "loading" : false; 33 + 34 + const identityresultmaybe = useQueryIdentity( 35 + !isAuthRestoring ? agent?.did : undefined 36 + ); 37 + const identity = identityresultmaybe?.data; 38 + 39 + const prefsresultmaybe = useQueryPreferences({ 40 + agent: !isAuthRestoring ? (agent ?? undefined) : undefined, 41 + pdsUrl: !isAuthRestoring ? identity?.pds : undefined, 42 + }); 43 + const rawprefs = prefsresultmaybe?.data?.preferences as 44 + | ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"] 45 + | undefined; 46 + 47 + //console.log(JSON.stringify(prefs, null, 2)) 48 + 49 + const parsedPref = parsePreferences(rawprefs); 50 + 51 + return ( 52 + <div> 53 + <Header 54 + title={`Moderation`} 55 + backButtonCallback={() => { 56 + if (window.history.length > 1) { 57 + window.history.back(); 58 + } else { 59 + window.location.assign("/"); 60 + } 61 + }} 62 + bottomBorderDisabled={true} 63 + /> 64 + {/* <SettingHeading title="Moderation Tools" /> 65 + <p> 66 + todo: add all these: 67 + <br /> 68 + - Interaction settings 69 + <br /> 70 + - Muted words & tags 71 + <br /> 72 + - Moderation lists 73 + <br /> 74 + - Muted accounts 75 + <br /> 76 + - Blocked accounts 77 + <br /> 78 + - Verification settings 79 + <br /> 80 + </p> */} 81 + <SettingHeading title="Content Filters" /> 82 + <div> 83 + <div className="flex items-center gap-4 px-4 py-2 border-b"> 84 + <label 85 + htmlFor={`switch-${"hardcoded"}`} 86 + className="flex flex-row flex-1" 87 + > 88 + <div className="flex flex-col"> 89 + <span className="text-md">{"Adult Content"}</span> 90 + <span className="text-sm text-gray-500 dark:text-gray-400"> 91 + {"Enable adult content"} 92 + </span> 93 + </div> 94 + </label> 95 + 96 + <Switch.Root 97 + id={`switch-${"hardcoded"}`} 98 + checked={parsedPref?.adultContentEnabled} 99 + onCheckedChange={(v) => { 100 + renderSnack({ 101 + title: "Sorry... Modifying preferences is not implemented yet", 102 + description: "You can use another app to change preferences", 103 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 104 + }); 105 + }} 106 + className="m3switch root" 107 + > 108 + <Switch.Thumb className="m3switch thumb " /> 109 + </Switch.Root> 110 + </div> 111 + <div className=""> 112 + {Object.entries(parsedPref?.contentLabelPrefs ?? {}).map( 113 + ([label, visibility]) => ( 114 + <div 115 + key={label} 116 + className="flex justify-between border-b py-2 px-4" 117 + > 118 + <label 119 + htmlFor={`switch-${"hardcoded"}`} 120 + className="flex flex-row flex-1" 121 + > 122 + <div className="flex flex-col"> 123 + <span className="text-md">{label}</span> 124 + <span className="text-sm text-gray-500 dark:text-gray-400"> 125 + {"uknown labeler"} 126 + </span> 127 + </div> 128 + </label> 129 + {/* <span className="text-md text-gray-500 dark:text-gray-400"> 130 + {visibility} 131 + </span> */} 132 + <TripleToggle 133 + value={visibility as "ignore" | "warn" | "hide"} 134 + /> 135 + </div> 136 + ) 137 + )} 138 + </div> 139 + </div> 140 + <SettingHeading title="Advanced" /> 141 + {parsedPref?.labelers.map((labeler) => { 142 + return ( 143 + <NotificationItem 144 + key={labeler} 145 + notification={labeler} 146 + labeler={true} 147 + /> 148 + ); 149 + })} 150 + </div> 151 + ); 152 + } 153 + 154 + export function TripleToggle({ 155 + value, 156 + onChange, 157 + }: { 158 + value: "ignore" | "warn" | "hide"; 159 + onChange?: (newValue: "ignore" | "warn" | "hide") => void; 160 + }) { 161 + const options: Array<"ignore" | "warn" | "hide"> = ["ignore", "warn", "hide"]; 162 + return ( 163 + <div className="flex rounded-full bg-gray-200 dark:bg-gray-800 p-1 text-sm"> 164 + {options.map((opt) => { 165 + const isActive = opt === value; 166 + return ( 167 + <button 168 + key={opt} 169 + onClick={() => { 170 + renderSnack({ 171 + title: "Sorry... Modifying preferences is not implemented yet", 172 + description: "You can use another app to change preferences", 173 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 174 + }); 175 + onChange?.(opt); 176 + }} 177 + className={`flex-1 px-3 py-1.5 rounded-full transition-colors ${ 178 + isActive 179 + ? "bg-gray-400 dark:bg-gray-600 text-white" 180 + : "text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700" 181 + }`} 182 + > 183 + {" "} 184 + {opt.charAt(0).toUpperCase() + opt.slice(1)} 185 + </button> 186 + ); 187 + })} 188 + </div> 189 + ); 190 + } 191 + 192 + type PrefItem = 193 + ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"][number]; 194 + 195 + export interface NormalizedPreferences { 196 + contentLabelPrefs: Record<string, string>; 197 + mutedWords: string[]; 198 + feedViewPrefs: Record<string, any>; 199 + labelers: string[]; 200 + adultContentEnabled: boolean; 201 + savedFeeds: { 202 + pinned: string[]; 203 + saved: string[]; 204 + }; 205 + nuxs: string[]; 206 + } 207 + 208 + export function parsePreferences( 209 + prefs?: PrefItem[] 210 + ): NormalizedPreferences | undefined { 211 + if (!prefs) return undefined; 212 + const normalized: NormalizedPreferences = { 213 + contentLabelPrefs: {}, 214 + mutedWords: [], 215 + feedViewPrefs: {}, 216 + labelers: [], 217 + adultContentEnabled: false, 218 + savedFeeds: { pinned: [], saved: [] }, 219 + nuxs: [], 220 + }; 221 + 222 + for (const pref of prefs) { 223 + switch (pref.$type) { 224 + case "app.bsky.actor.defs#contentLabelPref": 225 + if (!isContentLabelPref(pref)) break; 226 + normalized.contentLabelPrefs[pref.label] = pref.visibility; 227 + break; 228 + 229 + case "app.bsky.actor.defs#mutedWordsPref": 230 + if (!isMutedWordsPref(pref)) break; 231 + for (const item of pref.items ?? []) { 232 + normalized.mutedWords.push(item.value); 233 + } 234 + break; 235 + 236 + case "app.bsky.actor.defs#feedViewPref": 237 + if (!isFeedViewPref(pref)) break; 238 + normalized.feedViewPrefs[pref.feed] = pref; 239 + break; 240 + 241 + case "app.bsky.actor.defs#labelersPref": 242 + if (!isLabelersPref(pref)) break; 243 + normalized.labelers.push(...(pref.labelers?.map((l) => l.did) ?? [])); 244 + break; 245 + 246 + case "app.bsky.actor.defs#adultContentPref": 247 + if (!isAdultContentPref(pref)) break; 248 + normalized.adultContentEnabled = !!pref.enabled; 249 + break; 250 + 251 + case "app.bsky.actor.defs#savedFeedsPref": 252 + if (!isSavedFeedsPref(pref)) break; 253 + normalized.savedFeeds.pinned.push(...(pref.pinned ?? [])); 254 + normalized.savedFeeds.saved.push(...(pref.saved ?? [])); 255 + break; 256 + 257 + case "app.bsky.actor.defs#bskyAppStatePref": 258 + if (!isBskyAppStatePref(pref)) break; 259 + normalized.nuxs.push(...(pref.nuxs?.map((n) => n.id) ?? [])); 260 + break; 261 + 262 + default: 263 + // unknown pref type โ€” just ignore for now 264 + break; 265 + } 266 + } 267 + 268 + return normalized; 269 + }
+86 -6
src/routes/notifications.tsx
··· 19 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 20 20 import { 21 21 constellationURLAtom, 22 + enableBitesAtom, 22 23 imgCDNAtom, 23 24 postInteractionsFiltersAtom, 24 25 } from "~/utils/atoms"; ··· 56 57 }); 57 58 58 59 export default function NotificationsTabs() { 60 + const [bitesEnabled] = useAtom(enableBitesAtom); 59 61 return ( 60 62 <ReusableTabRoute 61 63 route={`Notifications`} ··· 63 65 Mentions: <MentionsTab />, 64 66 Follows: <FollowsTab />, 65 67 "Post Interactions": <PostInteractionsTab />, 68 + ...bitesEnabled ? { 69 + Bites: <BitesTab />, 70 + } : {} 66 71 }} 67 72 /> 68 73 ); ··· 132 137 ); 133 138 } 134 139 135 - function FollowsTab() { 140 + export function FollowsTab({did}:{did?:string}) { 136 141 const { agent } = useAuth(); 142 + const userdidunsafe = did ?? agent?.did; 143 + const { data: identity} = useQueryIdentity(userdidunsafe); 144 + const userdid = identity?.did; 145 + 137 146 const [constellationurl] = useAtom(constellationURLAtom); 138 147 const infinitequeryresults = useInfiniteQuery({ 139 148 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 140 149 { 141 150 constellation: constellationurl, 142 151 method: "/links", 143 - target: agent?.did, 152 + target: userdid, 144 153 collection: "app.bsky.graph.follow", 145 154 path: ".subject", 146 155 } 147 156 ), 148 - enabled: !!agent?.did, 157 + enabled: !!userdid, 149 158 }); 150 159 151 160 const { ··· 196 205 ); 197 206 } 198 207 208 + 209 + export function BitesTab({did}:{did?:string}) { 210 + const { agent } = useAuth(); 211 + const userdidunsafe = did ?? agent?.did; 212 + const { data: identity} = useQueryIdentity(userdidunsafe); 213 + const userdid = identity?.did; 214 + 215 + const [constellationurl] = useAtom(constellationURLAtom); 216 + const infinitequeryresults = useInfiniteQuery({ 217 + ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 218 + { 219 + constellation: constellationurl, 220 + method: "/links", 221 + target: "at://"+userdid, 222 + collection: "net.wafrn.feed.bite", 223 + path: ".subject", 224 + staleMult: 0 // safe fun 225 + } 226 + ), 227 + enabled: !!userdid, 228 + }); 229 + 230 + const { 231 + data: infiniteFollowsData, 232 + fetchNextPage, 233 + hasNextPage, 234 + isFetchingNextPage, 235 + isLoading, 236 + isError, 237 + error, 238 + } = infinitequeryresults; 239 + 240 + const followsAturis = React.useMemo(() => { 241 + // Get all replies from the standard infinite query 242 + return ( 243 + infiniteFollowsData?.pages.flatMap( 244 + (page) => 245 + page?.linking_records.map( 246 + (r) => `at://${r.did}/${r.collection}/${r.rkey}` 247 + ) ?? [] 248 + ) ?? [] 249 + ); 250 + }, [infiniteFollowsData]); 251 + 252 + useReusableTabScrollRestore("Notifications"); 253 + 254 + if (isLoading) return <LoadingState text="Loading bites..." />; 255 + if (isError) return <ErrorState error={error} />; 256 + 257 + if (!followsAturis?.length) return <EmptyState text="No bites yet." />; 258 + 259 + return ( 260 + <> 261 + {followsAturis.map((m) => ( 262 + <NotificationItem key={m} notification={m} /> 263 + ))} 264 + 265 + {hasNextPage && ( 266 + <button 267 + onClick={() => fetchNextPage()} 268 + disabled={isFetchingNextPage} 269 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 270 + > 271 + {isFetchingNextPage ? "Loading..." : "Load More"} 272 + </button> 273 + )} 274 + </> 275 + ); 276 + } 277 + 199 278 function PostInteractionsTab() { 200 279 const { agent } = useAuth(); 201 280 const { data: identity } = useQueryIdentity(agent?.did); ··· 304 383 ); 305 384 } 306 385 307 - function Chip({ 386 + export function Chip({ 308 387 state, 309 388 text, 310 389 onClick, ··· 493 572 ); 494 573 } 495 574 496 - export function NotificationItem({ notification }: { notification: string }) { 575 + export function NotificationItem({ notification, labeler }: { notification: string, labeler?: boolean }) { 497 576 const aturi = new AtUri(notification); 577 + const bite = aturi.collection === "net.wafrn.feed.bite"; 498 578 const navigate = useNavigate(); 499 579 const { data: identity } = useQueryIdentity(aturi.host); 500 580 const resolvedDid = identity?.did; ··· 538 618 <img 539 619 src={avatar || defaultpfp} 540 620 alt={identity?.handle} 541 - className="w-10 h-10 rounded-full" 621 + className={`w-10 h-10 ${labeler ? "rounded-md" : "rounded-full"}`} 542 622 /> 543 623 ) : ( 544 624 <div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" />
+1
src/routes/profile.$did/feed.$rkey.tsx
··· 13 13 component: FeedRoute, 14 14 }); 15 15 16 + // todo: scroll restoration 16 17 function FeedRoute() { 17 18 const { did, rkey } = Route.useParams(); 18 19 const { agent, status } = useAuth();
+30
src/routes/profile.$did/followers.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + 3 + import { Header } from "~/components/Header"; 4 + 5 + import { FollowsTab } from "../notifications"; 6 + 7 + export const Route = createFileRoute("/profile/$did/followers")({ 8 + component: RouteComponent, 9 + }); 10 + 11 + // todo: scroll restoration 12 + function RouteComponent() { 13 + const params = Route.useParams(); 14 + 15 + return ( 16 + <div> 17 + <Header 18 + title={"Followers"} 19 + backButtonCallback={() => { 20 + if (window.history.length > 1) { 21 + window.history.back(); 22 + } else { 23 + window.location.assign("/"); 24 + } 25 + }} 26 + /> 27 + <FollowsTab did={params.did} /> 28 + </div> 29 + ); 30 + }
+79
src/routes/profile.$did/follows.tsx
··· 1 + import * as ATPAPI from "@atproto/api" 2 + import { createFileRoute } from '@tanstack/react-router' 3 + import React from 'react'; 4 + 5 + import { Header } from '~/components/Header'; 6 + import { useReusableTabScrollRestore } from '~/components/ReusableTabRoute'; 7 + import { useInfiniteQueryAuthorFeed, useQueryIdentity } from '~/utils/useQuery'; 8 + 9 + import { EmptyState, ErrorState, LoadingState, NotificationItem } from '../notifications'; 10 + 11 + export const Route = createFileRoute('/profile/$did/follows')({ 12 + component: RouteComponent, 13 + }) 14 + 15 + // todo: scroll restoration 16 + function RouteComponent() { 17 + const params = Route.useParams(); 18 + return ( 19 + <div> 20 + <Header 21 + title={"Follows"} 22 + backButtonCallback={() => { 23 + if (window.history.length > 1) { 24 + window.history.back(); 25 + } else { 26 + window.location.assign("/"); 27 + } 28 + }} 29 + /> 30 + <Follows did={params.did}/> 31 + </div> 32 + ); 33 + } 34 + 35 + function Follows({did}:{did:string}) { 36 + const {data: identity} = useQueryIdentity(did); 37 + const infinitequeryresults = useInfiniteQueryAuthorFeed(identity?.did, identity?.pds, "app.bsky.graph.follow"); 38 + 39 + const { 40 + data: infiniteFollowsData, 41 + fetchNextPage, 42 + hasNextPage, 43 + isFetchingNextPage, 44 + isLoading, 45 + isError, 46 + error, 47 + } = infinitequeryresults; 48 + 49 + const followsAturis = React.useMemo( 50 + () => infiniteFollowsData?.pages.flatMap((page) => page.records) ?? [], 51 + [infiniteFollowsData] 52 + ); 53 + 54 + useReusableTabScrollRestore("Notifications"); 55 + 56 + if (isLoading) return <LoadingState text="Loading follows..." />; 57 + if (isError) return <ErrorState error={error} />; 58 + 59 + if (!followsAturis?.length) return <EmptyState text="No follows yet." />; 60 + 61 + return ( 62 + <> 63 + {followsAturis.map((m) => { 64 + const record = m.value as unknown as ATPAPI.AppBskyGraphFollow.Record; 65 + return <NotificationItem key={record.subject} notification={record.subject} /> 66 + })} 67 + 68 + {hasNextPage && ( 69 + <button 70 + onClick={() => fetchNextPage()} 71 + disabled={isFetchingNextPage} 72 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold disabled:opacity-50" 73 + > 74 + {isFetchingNextPage ? "Loading..." : "Load More"} 75 + </button> 76 + )} 77 + </> 78 + ); 79 + }
+374 -35
src/routes/profile.$did/index.tsx
··· 1 - import { RichText } from "@atproto/api"; 1 + import { Agent, RichText } from "@atproto/api"; 2 2 import * as ATPAPI from "@atproto/api"; 3 + import { TID } from "@atproto/common-web"; 3 4 import { useQueryClient } from "@tanstack/react-query"; 4 5 import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 5 6 import { useAtom } from "jotai"; ··· 16 17 UniversalPostRendererATURILoader, 17 18 } from "~/components/UniversalPostRenderer"; 18 19 import { useAuth } from "~/providers/UnifiedAuthProvider"; 19 - import { imgCDNAtom } from "~/utils/atoms"; 20 + import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; 20 21 import { 21 22 toggleFollow, 22 23 useGetFollowState, 23 24 useGetOneToOneState, 24 25 } from "~/utils/followState"; 26 + import { useFastSetLikesFromFeed } from "~/utils/likeMutationQueue"; 25 27 import { 26 28 useInfiniteQueryAuthorFeed, 27 29 useQueryArbitrary, 28 30 useQueryConstellation, 31 + useQueryConstellationLinksCountDistinctDids, 29 32 useQueryIdentity, 30 33 useQueryProfile, 31 34 } from "~/utils/useQuery"; 35 + import IconMdiShieldOutline from "~icons/mdi/shield-outline.jsx"; 36 + 37 + import { renderSnack } from "../__root"; 38 + import { Chip } from "../notifications"; 32 39 33 40 export const Route = createFileRoute("/profile/$did/")({ 34 41 component: ProfileComponent, ··· 46 53 error: identityError, 47 54 } = useQueryIdentity(did); 48 55 56 + // i was gonna check the did doc but useQueryIdentity doesnt return that info (slingshot minidoc) 57 + // so instead we should query the labeler profile 58 + 59 + const { data: labelerProfile } = useQueryArbitrary( 60 + identity?.did 61 + ? `at://${identity?.did}/app.bsky.labeler.service/self` 62 + : undefined 63 + ); 64 + 65 + const isLabeler = !!labelerProfile?.cid; 66 + const labelerRecord = isLabeler 67 + ? (labelerProfile?.value as ATPAPI.AppBskyLabelerService.Record) 68 + : undefined; 69 + 49 70 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 50 71 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; 51 72 const pdsUrl = identity?.pds; ··· 76 97 77 98 const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord; 78 99 100 + const resultwhateversure = useQueryConstellationLinksCountDistinctDids( 101 + resolvedDid 102 + ? { 103 + method: "/links/count/distinct-dids", 104 + collection: "app.bsky.graph.follow", 105 + target: resolvedDid, 106 + path: ".subject", 107 + } 108 + : undefined 109 + ); 110 + 111 + const followercount = resultwhateversure?.data?.total; 112 + 79 113 return ( 80 114 <div className=""> 81 115 <Header ··· 122 156 123 157 {/* Avatar (PFP) */} 124 158 <div className="absolute left-[16px] top-[100px] "> 125 - <img 126 - src={getAvatarUrl(profile) || "/favicon.png"} 127 - alt="avatar" 128 - className="w-28 h-28 rounded-full object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700" 129 - /> 159 + {!getAvatarUrl(profile) && isLabeler ? ( 160 + <div 161 + className={`w-28 h-28 ${isLabeler ? "rounded-md" : "rounded-full"} items-center justify-center flex object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700`} 162 + > 163 + <IconMdiShieldOutline className="w-20 h-20" /> 164 + </div> 165 + ) : ( 166 + <img 167 + src={getAvatarUrl(profile) || "/favicon.png"} 168 + alt="avatar" 169 + className={`w-28 h-28 ${isLabeler ? "rounded-md" : "rounded-full"} object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700`} 170 + /> 171 + )} 130 172 </div> 131 173 132 174 <div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5"> 175 + <BiteButton targetdidorhandle={did} /> 133 176 {/* 134 177 todo: full follow and unfollow backfill (along with partial likes backfill, 135 178 just enough for it to be useful) ··· 137 180 also save it persistently 138 181 */} 139 182 <FollowButton targetdidorhandle={did} /> 140 - <button className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]"> 183 + <button 184 + className="rounded-full dark:bg-gray-600 bg-gray-300 px-3 py-2 text-[14px]" 185 + onClick={(e) => { 186 + renderSnack({ 187 + title: "Not Implemented Yet", 188 + description: "Sorry...", 189 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 190 + }); 191 + }} 192 + > 141 193 ... {/* todo: icon */} 142 194 </button> 143 195 </div> ··· 149 201 <Mutual targetdidorhandle={did} /> 150 202 {handle} 151 203 </div> 204 + <div className="flex flex-row gap-2 text-md text-gray-500 dark:text-gray-400 mb-2"> 205 + <Link to="/profile/$did/followers" params={{ did: did }}> 206 + {followercount && ( 207 + <span className="mr-1 text-gray-900 dark:text-gray-200 font-medium"> 208 + {followercount} 209 + </span> 210 + )} 211 + Followers 212 + </Link> 213 + - 214 + <Link to="/profile/$did/follows" params={{ did: did }}> 215 + Follows 216 + </Link> 217 + </div> 152 218 {description && ( 153 219 <div className="text-base leading-relaxed text-gray-800 dark:text-gray-300 mb-5 whitespace-pre-wrap break-words text-[15px]"> 154 220 {/* {description} */} ··· 163 229 <ReusableTabRoute 164 230 route={`Profile` + did} 165 231 tabs={{ 166 - Posts: <PostsTab did={did} />, 167 - Reposts: <RepostsTab did={did} />, 168 - Feeds: <FeedsTab did={did} />, 169 - Lists: <ListsTab did={did} />, 232 + ...(isLabeler 233 + ? { 234 + Labels: <LabelsTab did={did} labelerRecord={labelerRecord} />, 235 + } 236 + : {}), 237 + ...{ 238 + Posts: <PostsTab did={did} />, 239 + Reposts: <RepostsTab did={did} />, 240 + Feeds: <FeedsTab did={did} />, 241 + Lists: <ListsTab did={did} />, 242 + }, 170 243 ...(identity?.did === agent?.did 171 244 ? { Likes: <SelfLikesTab did={did} /> } 172 245 : {}), ··· 191 264 ); 192 265 } 193 266 267 + export type ProfilePostsFilter = { 268 + posts: boolean; 269 + replies: boolean; 270 + mediaOnly: boolean; 271 + }; 272 + export const defaultProfilePostsFilter: ProfilePostsFilter = { 273 + posts: true, 274 + replies: true, 275 + mediaOnly: false, 276 + }; 277 + 278 + function ProfilePostsFilterChipBar({ 279 + filters, 280 + toggle, 281 + }: { 282 + filters: ProfilePostsFilter | null; 283 + toggle: (key: keyof ProfilePostsFilter) => void; 284 + }) { 285 + const empty = !filters?.replies && !filters?.posts; 286 + const almostEmpty = !filters?.replies && filters?.posts; 287 + 288 + useEffect(() => { 289 + if (empty) { 290 + toggle("posts"); 291 + } 292 + }, [empty, toggle]); 293 + 294 + return ( 295 + <div className="flex flex-row flex-wrap gap-2 px-4 pt-4"> 296 + <Chip 297 + state={filters?.posts ?? true} 298 + text="Posts" 299 + onClick={() => (almostEmpty ? null : toggle("posts"))} 300 + /> 301 + <Chip 302 + state={filters?.replies ?? true} 303 + text="Replies" 304 + onClick={() => toggle("replies")} 305 + /> 306 + <Chip 307 + state={filters?.mediaOnly ?? false} 308 + text="Media Only" 309 + onClick={() => toggle("mediaOnly")} 310 + /> 311 + </div> 312 + ); 313 + } 314 + 194 315 function PostsTab({ did }: { did: string }) { 316 + // todo: this needs to be a (non-persisted is fine) atom to survive navigation 317 + const [filterses, setFilterses] = useAtom(profileChipsAtom); 318 + const filters = filterses?.[did]; 319 + const setFilters = (obj: ProfilePostsFilter) => { 320 + setFilterses((prev) => { 321 + return { 322 + ...prev, 323 + [did]: obj, 324 + }; 325 + }); 326 + }; 327 + useEffect(() => { 328 + if (!filters) { 329 + setFilters(defaultProfilePostsFilter); 330 + } 331 + }); 195 332 useReusableTabScrollRestore(`Profile` + did); 196 333 const queryClient = useQueryClient(); 197 334 const { ··· 227 364 [postsData] 228 365 ); 229 366 367 + const toggle = (key: keyof ProfilePostsFilter) => { 368 + setFilterses((prev) => { 369 + const existing = prev[did] ?? { 370 + posts: false, 371 + replies: false, 372 + mediaOnly: false, 373 + }; // default 374 + 375 + return { 376 + ...prev, 377 + [did]: { 378 + ...existing, 379 + [key]: !existing[key], // safely negate 380 + }, 381 + }; 382 + }); 383 + }; 384 + 230 385 return ( 231 386 <> 232 - <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 387 + {/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 233 388 Posts 234 - </div> 389 + </div> */} 390 + <ProfilePostsFilterChipBar filters={filters} toggle={toggle} /> 235 391 <div> 236 392 {posts.map((post) => ( 237 393 <UniversalPostRendererATURILoader 238 394 key={post.uri} 239 395 atUri={post.uri} 240 396 feedviewpost={true} 397 + filterNoReplies={!filters?.replies} 398 + filterMustHaveMedia={filters?.mediaOnly} 399 + filterMustBeReply={!filters?.posts} 241 400 /> 242 401 ))} 243 402 </div> ··· 404 563 ); 405 564 } 406 565 566 + function LabelsTab({ 567 + did, 568 + labelerRecord, 569 + }: { 570 + did: string; 571 + labelerRecord?: ATPAPI.AppBskyLabelerService.Record; 572 + }) { 573 + useReusableTabScrollRestore(`Profile` + did); 574 + const { agent } = useAuth(); 575 + // const { 576 + // data: identity, 577 + // isLoading: isIdentityLoading, 578 + // error: identityError, 579 + // } = useQueryIdentity(did); 580 + 581 + // const resolvedDid = did.startsWith("did:") ? did : identity?.did; 582 + 583 + const labelMap = new Map( 584 + labelerRecord?.policies?.labelValueDefinitions?.map((def) => { 585 + const locale = def.locales.find((l) => l.lang === "en") ?? def.locales[0]; 586 + return [ 587 + def.identifier, 588 + { 589 + name: locale?.name, 590 + description: locale?.description, 591 + blur: def.blurs, 592 + severity: def.severity, 593 + adultOnly: def.adultOnly, 594 + defaultSetting: def.defaultSetting, 595 + }, 596 + ]; 597 + }) 598 + ); 599 + 600 + return ( 601 + <> 602 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 603 + Labels 604 + </div> 605 + <div> 606 + {[...labelMap.entries()].map(([key, item]) => ( 607 + <div 608 + key={key} 609 + className="border-gray-300 dark:border-gray-700 border-b px-4 py-4" 610 + > 611 + <div className="font-semibold text-lg">{item.name}</div> 612 + <div className="text-sm text-gray-500 dark:text-gray-400"> 613 + {item.description} 614 + </div> 615 + <div className="mt-1 text-xs text-gray-400"> 616 + {item.blur && <span>Blur: {item.blur} </span>} 617 + {item.severity && <span>โ€ข Severity: {item.severity} </span>} 618 + {item.adultOnly && <span>โ€ข 18+ only</span>} 619 + </div> 620 + </div> 621 + ))} 622 + </div> 623 + 624 + {/* Loading and "Load More" states */} 625 + {!labelerRecord && ( 626 + <div className="p-4 text-center text-gray-500">Loading labels...</div> 627 + )} 628 + {/* {!labelerRecord && ( 629 + <div className="p-4 text-center text-gray-500">Loading more...</div> 630 + )} */} 631 + {/* {hasNextPage && !isFetchingNextPage && ( 632 + <button 633 + onClick={() => fetchNextPage()} 634 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 635 + > 636 + Load More Feeds 637 + </button> 638 + )} 639 + {feeds.length === 0 && !arePostsLoading && ( 640 + <div className="p-4 text-center text-gray-500">No feeds found.</div> 641 + )} */} 642 + </> 643 + ); 644 + } 645 + 407 646 export function FeedItemRenderAturiLoader({ 408 647 aturi, 409 648 listmode, ··· 454 693 } 455 694 456 695 const { data: likes } = useQueryConstellation( 457 - // @ts-expect-error overloads sucks 696 + // @ts-expect-error overloads sucks 458 697 !listmode 459 698 ? { 460 699 target: feed.uri, ··· 470 709 className={`px-4 py-4 ${!disableBottomBorder && "border-b"} flex flex-col gap-1`} 471 710 to="/profile/$did/feed/$rkey" 472 711 params={{ did: aturi.host, rkey: aturi.rkey }} 473 - onClick={(e)=>{e.stopPropagation();}} 712 + onClick={(e) => { 713 + e.stopPropagation(); 714 + }} 474 715 > 475 716 <div className="flex flex-row gap-3"> 476 717 <div className="min-w-10 min-h-10"> ··· 574 815 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 575 816 576 817 const { 577 - data: repostsData, 818 + data: likesData, 578 819 fetchNextPage, 579 820 hasNextPage, 580 821 isFetchingNextPage, ··· 585 826 "app.bsky.feed.like" 586 827 ); 587 828 588 - const reposts = React.useMemo( 589 - () => repostsData?.pages.flatMap((page) => page.records) ?? [], 590 - [repostsData] 829 + const likes = React.useMemo( 830 + () => likesData?.pages.flatMap((page) => page.records) ?? [], 831 + [likesData] 591 832 ); 592 833 834 + const { setFastState } = useFastSetLikesFromFeed(); 835 + const seededRef = React.useRef(new Set<string>()); 836 + 837 + useEffect(() => { 838 + for (const like of likes) { 839 + if (!seededRef.current.has(like.uri)) { 840 + seededRef.current.add(like.uri); 841 + const record = like.value as unknown as ATPAPI.AppBskyFeedLike.Record; 842 + setFastState(record.subject.uri, { 843 + target: record.subject.uri, 844 + uri: like.uri, 845 + cid: like.cid, 846 + }); 847 + } 848 + } 849 + }, [likes, setFastState]); 850 + 593 851 return ( 594 852 <> 595 853 <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 596 854 Likes 597 855 </div> 598 856 <div> 599 - {reposts.map((repost) => { 857 + {likes.map((like) => { 600 858 if ( 601 - !repost || 602 - !repost?.value || 603 - !repost?.value?.subject || 859 + !like || 860 + !like?.value || 861 + !like?.value?.subject || 604 862 // @ts-expect-error blehhhhh 605 - !repost?.value?.subject?.uri 863 + !like?.value?.subject?.uri 606 864 ) 607 865 return; 608 - const repostRecord = 609 - repost.value as unknown as ATPAPI.AppBskyFeedLike.Record; 866 + const likeRecord = 867 + like.value as unknown as ATPAPI.AppBskyFeedLike.Record; 610 868 return ( 611 869 <UniversalPostRendererATURILoader 612 - key={repostRecord.subject.uri} 613 - atUri={repostRecord.subject.uri} 870 + key={likeRecord.subject.uri} 871 + atUri={likeRecord.subject.uri} 614 872 feedviewpost={true} 615 873 /> 616 874 ); ··· 618 876 </div> 619 877 620 878 {/* Loading and "Load More" states */} 621 - {arePostsLoading && reposts.length === 0 && ( 622 - <div className="p-4 text-center text-gray-500">Loading posts...</div> 879 + {arePostsLoading && likes.length === 0 && ( 880 + <div className="p-4 text-center text-gray-500">Loading likes...</div> 623 881 )} 624 882 {isFetchingNextPage && ( 625 883 <div className="p-4 text-center text-gray-500">Loading more...</div> ··· 629 887 onClick={() => fetchNextPage()} 630 888 className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 631 889 > 632 - Load More Posts 890 + Load More Likes 633 891 </button> 634 892 )} 635 - {reposts.length === 0 && !arePostsLoading && ( 636 - <div className="p-4 text-center text-gray-500">No posts found.</div> 893 + {likes.length === 0 && !arePostsLoading && ( 894 + <div className="p-4 text-center text-gray-500">No likes found.</div> 637 895 )} 638 896 </> 639 897 ); ··· 690 948 )} 691 949 </> 692 950 ) : ( 693 - <button className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]"> 951 + <button 952 + className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 953 + onClick={(e) => { 954 + renderSnack({ 955 + title: "Not Implemented Yet", 956 + description: "Sorry...", 957 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 958 + }); 959 + }} 960 + > 694 961 Edit Profile 695 962 </button> 696 963 )} 697 964 </> 698 965 ); 966 + } 967 + 968 + export function BiteButton({ 969 + targetdidorhandle, 970 + }: { 971 + targetdidorhandle: string; 972 + }) { 973 + const { agent } = useAuth(); 974 + const { data: identity } = useQueryIdentity(targetdidorhandle); 975 + const [show] = useAtom(enableBitesAtom); 976 + 977 + if (!show) return; 978 + 979 + return ( 980 + <> 981 + <button 982 + onClick={async (e) => { 983 + e.stopPropagation(); 984 + await sendBite({ 985 + agent: agent || undefined, 986 + targetDid: identity?.did, 987 + }); 988 + }} 989 + className="rounded-full h-10 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors px-4 py-2 text-[14px]" 990 + > 991 + Bite 992 + </button> 993 + </> 994 + ); 995 + } 996 + 997 + async function sendBite({ 998 + agent, 999 + targetDid, 1000 + }: { 1001 + agent?: Agent; 1002 + targetDid?: string; 1003 + }) { 1004 + if (!agent?.did || !targetDid) { 1005 + renderSnack({ 1006 + title: "Bite Failed", 1007 + description: "You must be logged-in to bite someone.", 1008 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 1009 + }); 1010 + return; 1011 + } 1012 + const newRecord = { 1013 + repo: agent.did, 1014 + collection: "net.wafrn.feed.bite", 1015 + rkey: TID.next().toString(), 1016 + record: { 1017 + $type: "net.wafrn.feed.bite", 1018 + subject: "at://" + targetDid, 1019 + createdAt: new Date().toISOString(), 1020 + }, 1021 + }; 1022 + 1023 + try { 1024 + await agent.com.atproto.repo.createRecord(newRecord); 1025 + renderSnack({ 1026 + title: "Bite Sent", 1027 + description: "Your bite was delivered.", 1028 + //button: { label: 'Undo', onClick: () => console.log('Undo clicked') }, 1029 + }); 1030 + } catch (err) { 1031 + console.error("Bite failed:", err); 1032 + renderSnack({ 1033 + title: "Bite Failed", 1034 + description: "Your bite failed to be delivered.", 1035 + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, 1036 + }); 1037 + } 699 1038 } 700 1039 701 1040 export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) {
+217 -9
src/routes/search.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 1 + import type { Agent } from "@atproto/api"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 + import { createFileRoute, useSearch } from "@tanstack/react-router"; 4 + import { useAtom } from "jotai"; 5 + import { useEffect,useMemo } from "react"; 2 6 3 7 import { Header } from "~/components/Header"; 4 8 import { Import } from "~/components/Import"; 9 + import { 10 + ReusableTabRoute, 11 + useReusableTabScrollRestore, 12 + } from "~/components/ReusableTabRoute"; 13 + import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 14 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 15 + import { lycanURLAtom } from "~/utils/atoms"; 16 + import { 17 + constructLycanRequestIndexQuery, 18 + useInfiniteQueryLycanSearch, 19 + useQueryIdentity, 20 + useQueryLycanStatus, 21 + } from "~/utils/useQuery"; 22 + 23 + import { renderSnack } from "./__root"; 24 + import { SliderPrimitive } from "./settings"; 5 25 6 26 export const Route = createFileRoute("/search")({ 7 27 component: Search, 8 28 }); 9 29 10 30 export function Search() { 31 + const queryClient = useQueryClient(); 32 + const { agent, status } = useAuth(); 33 + const { data: identity } = useQueryIdentity(agent?.did); 34 + const [lycandomain] = useAtom(lycanURLAtom); 35 + const lycanExists = lycandomain !== ""; 36 + const { data: lycanstatusdata, refetch } = useQueryLycanStatus(); 37 + const lycanIndexed = lycanstatusdata?.status === "finished" || false; 38 + const lycanIndexing = lycanstatusdata?.status === "in_progress" || false; 39 + const lycanIndexingProgress = lycanIndexing 40 + ? lycanstatusdata?.progress 41 + : undefined; 42 + 43 + const authed = status === "signedIn"; 44 + 45 + const lycanReady = lycanExists && lycanIndexed && authed; 46 + 47 + const { q }: { q: string } = useSearch({ from: "/search" }); 48 + 49 + // auto-refetch Lycan status until ready 50 + useEffect(() => { 51 + if (!lycanExists || !authed) return; 52 + if (lycanReady) return; 53 + 54 + const interval = setInterval(() => { 55 + refetch(); 56 + }, 3000); 57 + 58 + return () => clearInterval(interval); 59 + }, [lycanExists, authed, lycanReady, refetch]); 60 + 61 + const maintext = !lycanExists 62 + ? "Sorry we dont have search. But instead, you can load some of these types of content into Red Dwarf:" 63 + : authed 64 + ? lycanReady 65 + ? "Lycan Search is enabled and ready! Type to search posts you've interacted with in the past. You can also load some of these types of content into Red Dwarf:" 66 + : "Sorry, while Lycan Search is enabled, you are not indexed. Index below please. You can load some of these types of content into Red Dwarf:" 67 + : "Sorry, while Lycan Search is enabled, you are unauthed. Please log in to use Lycan. You can load some of these types of content into Red Dwarf:"; 68 + 69 + async function index(opts: { 70 + agent?: Agent; 71 + isAuthed: boolean; 72 + pdsUrl?: string; 73 + feedServiceDid?: string; 74 + }) { 75 + renderSnack({ 76 + title: "Registering account...", 77 + }); 78 + try { 79 + const response = await queryClient.fetchQuery( 80 + constructLycanRequestIndexQuery(opts) 81 + ); 82 + if ( 83 + response?.message !== "Import has already started" && 84 + response?.message !== "Import has been scheduled" 85 + ) { 86 + renderSnack({ 87 + title: "Registration failed!", 88 + description: "Unknown server error (2)", 89 + }); 90 + } else { 91 + renderSnack({ 92 + title: "Succesfully sent registration request!", 93 + description: "Please wait for the server to index your account", 94 + }); 95 + refetch(); 96 + } 97 + } catch { 98 + renderSnack({ 99 + title: "Registration failed!", 100 + description: "Unknown server error (1)", 101 + }); 102 + } 103 + } 104 + 11 105 return ( 12 106 <> 13 107 <Header ··· 21 115 }} 22 116 /> 23 117 <div className=" flex flex-col items-center mt-4 mx-4 gap-4"> 24 - <Import /> 118 + <Import optionaltextstring={q} /> 25 119 <div className="flex flex-col"> 26 - <p className="text-gray-600 dark:text-gray-400"> 27 - Sorry we dont have search. But instead, you can load some of these 28 - types of content into Red Dwarf: 29 - </p> 120 + <p className="text-gray-600 dark:text-gray-400">{maintext}</p> 30 121 <ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400"> 31 122 <li> 32 - Bluesky URLs from supported clients (like{" "} 123 + Bluesky URLs (from supported clients) (like{" "} 33 124 <code className="text-sm">bsky.app</code> or{" "} 34 125 <code className="text-sm">deer.social</code>). 35 126 </li> ··· 39 130 ). 40 131 </li> 41 132 <li> 42 - Plain handles (like{" "} 133 + User Handles (like{" "} 43 134 <code className="text-sm">@username.bsky.social</code>). 44 135 </li> 45 136 <li> 46 - Direct DIDs (Decentralized Identifiers, starting with{" "} 137 + DIDs (Decentralized Identifiers, starting with{" "} 47 138 <code className="text-sm">did:</code>). 48 139 </li> 49 140 </ul> ··· 51 142 Simply paste one of these into the import field above and press 52 143 Enter to load the content. 53 144 </p> 145 + 146 + {lycanExists && authed && !lycanReady ? ( 147 + !lycanIndexing ? ( 148 + <div className="mt-4 mx-auto"> 149 + <button 150 + onClick={() => 151 + index({ 152 + agent: agent || undefined, 153 + isAuthed: status === "signedIn", 154 + pdsUrl: identity?.pds, 155 + feedServiceDid: "did:web:" + lycandomain, 156 + }) 157 + } 158 + className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 159 + text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition" 160 + > 161 + Index my Account 162 + </button> 163 + </div> 164 + ) : ( 165 + <div className="mt-4 gap-2 flex flex-col"> 166 + <span>indexing...</span> 167 + <SliderPrimitive 168 + value={lycanIndexingProgress || 0} 169 + min={0} 170 + max={1} 171 + /> 172 + </div> 173 + ) 174 + ) : ( 175 + <></> 176 + )} 54 177 </div> 55 178 </div> 179 + {q ? <SearchTabs query={q} /> : <></>} 56 180 </> 57 181 ); 58 182 } 183 + 184 + function SearchTabs({ query }: { query: string }) { 185 + return ( 186 + <div> 187 + <ReusableTabRoute 188 + route={`search` + query} 189 + tabs={{ 190 + Likes: <LycanTab query={query} type={"likes"} key={"likes"} />, 191 + Reposts: <LycanTab query={query} type={"reposts"} key={"reposts"} />, 192 + Quotes: <LycanTab query={query} type={"quotes"} key={"quotes"} />, 193 + Pins: <LycanTab query={query} type={"pins"} key={"pins"} />, 194 + }} 195 + /> 196 + </div> 197 + ); 198 + } 199 + 200 + function LycanTab({ 201 + query, 202 + type, 203 + }: { 204 + query: string; 205 + type: "likes" | "pins" | "reposts" | "quotes"; 206 + }) { 207 + useReusableTabScrollRestore("search" + query); 208 + 209 + const { 210 + data: postsData, 211 + fetchNextPage, 212 + hasNextPage, 213 + isFetchingNextPage, 214 + isLoading: arePostsLoading, 215 + } = useInfiniteQueryLycanSearch({ query: query, type: type }); 216 + 217 + const posts = useMemo( 218 + () => 219 + postsData?.pages.flatMap((page) => { 220 + if (page) { 221 + return page.posts; 222 + } else { 223 + return []; 224 + } 225 + }) ?? [], 226 + [postsData] 227 + ); 228 + 229 + return ( 230 + <> 231 + {/* <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 232 + Posts 233 + </div> */} 234 + <div> 235 + {posts.map((post) => ( 236 + <UniversalPostRendererATURILoader 237 + key={post} 238 + atUri={post} 239 + feedviewpost={true} 240 + /> 241 + ))} 242 + </div> 243 + 244 + {/* Loading and "Load More" states */} 245 + {arePostsLoading && posts.length === 0 && ( 246 + <div className="p-4 text-center text-gray-500">Loading posts...</div> 247 + )} 248 + {isFetchingNextPage && ( 249 + <div className="p-4 text-center text-gray-500">Loading more...</div> 250 + )} 251 + {hasNextPage && !isFetchingNextPage && ( 252 + <button 253 + onClick={() => fetchNextPage()} 254 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 255 + > 256 + Load More Posts 257 + </button> 258 + )} 259 + {posts.length === 0 && !arePostsLoading && ( 260 + <div className="p-4 text-center text-gray-500">No posts found.</div> 261 + )} 262 + </> 263 + ); 264 + 265 + return <></>; 266 + }
+188 -17
src/routes/settings.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 2 - import { useAtom } from "jotai"; 3 - import { Slider } from "radix-ui"; 1 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 + import { useAtom, useAtomValue, useSetAtom } from "jotai"; 3 + import { Slider, Switch } from "radix-ui"; 4 + import { useEffect, useState } from "react"; 4 5 5 6 import { Header } from "~/components/Header"; 6 7 import Login from "~/components/Login"; ··· 9 10 defaultconstellationURL, 10 11 defaulthue, 11 12 defaultImgCDN, 13 + defaultLycanURL, 12 14 defaultslingshotURL, 13 15 defaultVideoCDN, 16 + enableBitesAtom, 17 + enableBridgyTextAtom, 18 + enableWafrnTextAtom, 14 19 hueAtom, 15 20 imgCDNAtom, 21 + lycanURLAtom, 16 22 slingshotURLAtom, 17 23 videoCDNAtom, 18 24 } from "~/utils/atoms"; 19 25 26 + import { MaterialNavItem } from "./__root"; 27 + 20 28 export const Route = createFileRoute("/settings")({ 21 29 component: Settings, 22 30 }); 23 31 24 32 export function Settings() { 33 + const navigate = useNavigate(); 25 34 return ( 26 35 <> 27 36 <Header ··· 37 46 <div className="lg:hidden"> 38 47 <Login /> 39 48 </div> 49 + <div className="sm:hidden flex flex-col justify-around mt-4"> 50 + <SettingHeading title="Other Pages" top /> 51 + <MaterialNavItem 52 + InactiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />} 53 + ActiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />} 54 + active={false} 55 + onClickCallbback={() => 56 + navigate({ 57 + to: "/feeds", 58 + //params: { did: agent.assertDid }, 59 + }) 60 + } 61 + text="Feeds" 62 + /> 63 + <MaterialNavItem 64 + InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />} 65 + ActiveIcon={<IconMdiShield className="w-6 h-6" />} 66 + active={false} 67 + onClickCallbback={() => 68 + navigate({ 69 + to: "/moderation", 70 + //params: { did: agent.assertDid }, 71 + }) 72 + } 73 + text="Moderation" 74 + /> 75 + </div> 40 76 <div className="h-4" /> 77 + 78 + <SettingHeading title="Personalization" top /> 79 + <Hue /> 80 + 81 + <SettingHeading title="Network Configuration" /> 82 + <div className="flex flex-col px-4 pb-2"> 83 + <span className="text-md">Service Endpoints</span> 84 + <span className="text-sm text-gray-500 dark:text-gray-400"> 85 + Customize the servers to be used by the app 86 + </span> 87 + </div> 41 88 <TextInputSetting 42 89 atom={constellationURLAtom} 43 90 title={"Constellation"} ··· 66 113 description={"Customize the Slingshot instance to be used by Red Dwarf"} 67 114 init={defaultVideoCDN} 68 115 /> 116 + <TextInputSetting 117 + atom={lycanURLAtom} 118 + title={"Lycan Search"} 119 + description={"Enable text search across posts you've interacted with"} 120 + init={defaultLycanURL} 121 + /> 69 122 70 - <Hue /> 71 - <p className="text-gray-500 dark:text-gray-400 py-4 px-6 text-sm"> 72 - please restart/refresh the app if changes arent applying correctly 123 + <SettingHeading title="Experimental" /> 124 + <SwitchSetting 125 + atom={enableBitesAtom} 126 + title={"Bites"} 127 + description={"Enable Wafrn Bites to bite and be bitten by other people"} 128 + //init={false} 129 + /> 130 + <div className="h-4" /> 131 + <SwitchSetting 132 + atom={enableBridgyTextAtom} 133 + title={"Bridgy Text"} 134 + description={ 135 + "Show the original text of posts bridged from the Fediverse" 136 + } 137 + //init={false} 138 + /> 139 + <div className="h-4" /> 140 + <SwitchSetting 141 + atom={enableWafrnTextAtom} 142 + title={"Wafrn Text"} 143 + description={"Show the original text of posts from Wafrn instances"} 144 + //init={false} 145 + /> 146 + <p className="text-gray-500 dark:text-gray-400 py-4 px-4 text-sm border rounded-xl mx-4 mt-8 mb-4"> 147 + Notice: Please restart/refresh the app if changes arent applying 148 + correctly 73 149 </p> 74 150 </> 75 151 ); 76 152 } 153 + 154 + export function SettingHeading({ 155 + title, 156 + top, 157 + }: { 158 + title: string; 159 + top?: boolean; 160 + }) { 161 + return ( 162 + <div 163 + className="px-4" 164 + style={{ marginTop: top ? 0 : 18, paddingBottom: 12 }} 165 + > 166 + <span className=" text-sm font-medium text-gray-500 dark:text-gray-400"> 167 + {title} 168 + </span> 169 + </div> 170 + ); 171 + } 172 + 173 + export function SwitchSetting({ 174 + atom, 175 + title, 176 + description, 177 + }: { 178 + atom: typeof enableBitesAtom; 179 + title?: string; 180 + description?: string; 181 + }) { 182 + const value = useAtomValue(atom); 183 + const setValue = useSetAtom(atom); 184 + 185 + const [hydrated, setHydrated] = useState(false); 186 + // eslint-disable-next-line react-hooks/set-state-in-effect 187 + useEffect(() => setHydrated(true), []); 188 + 189 + if (!hydrated) { 190 + // Avoid rendering Switch until we know storage is loaded 191 + return null; 192 + } 193 + 194 + return ( 195 + <div className="flex items-center gap-4 px-4 "> 196 + <label htmlFor={`switch-${title}`} className="flex flex-row flex-1"> 197 + <div className="flex flex-col"> 198 + <span className="text-md">{title}</span> 199 + <span className="text-sm text-gray-500 dark:text-gray-400"> 200 + {description} 201 + </span> 202 + </div> 203 + </label> 204 + 205 + <Switch.Root 206 + id={`switch-${title}`} 207 + checked={value} 208 + onCheckedChange={(v) => setValue(v)} 209 + className="m3switch root" 210 + > 211 + <Switch.Thumb className="m3switch thumb " /> 212 + </Switch.Root> 213 + </div> 214 + ); 215 + } 216 + 77 217 function Hue() { 78 218 const [hue, setHue] = useAtom(hueAtom); 79 219 return ( 80 - <div className="flex flex-col px-4 mt-4 "> 81 - <span className="z-10">Hue</span> 82 - <div className="flex flex-row items-center gap-4"> 83 - <SliderComponent 84 - atom={hueAtom} 85 - max={360} 86 - /> 220 + <div className="flex flex-col px-4"> 221 + <span className="z-[2] text-md">Hue</span> 222 + <span className="z-[2] text-sm text-gray-500 dark:text-gray-400"> 223 + Change the colors of the app 224 + </span> 225 + <div className="z-[1] flex flex-row items-center gap-4"> 226 + <SliderComponent atom={hueAtom} max={360} /> 87 227 <button 88 228 onClick={() => setHue(defaulthue ?? 28)} 89 229 className="px-6 py-2 h-12 rounded-full bg-gray-100 dark:bg-gray-800 ··· 154 294 ); 155 295 } 156 296 157 - 158 297 interface SliderProps { 159 298 atom: typeof hueAtom; 160 299 min?: number; ··· 168 307 max = 100, 169 308 step = 1, 170 309 }) => { 171 - 172 - const [value, setValue] = useAtom(atom) 310 + const [value, setValue] = useAtom(atom); 173 311 174 312 return ( 175 313 <Slider.Root ··· 186 324 <Slider.Thumb className="shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" /> 187 325 </Slider.Root> 188 326 ); 189 - }; 327 + }; 328 + 329 + 330 + interface SliderPProps { 331 + value: number; 332 + min?: number; 333 + max?: number; 334 + step?: number; 335 + } 336 + 337 + 338 + export const SliderPrimitive: React.FC<SliderPProps> = ({ 339 + value, 340 + min = 0, 341 + max = 100, 342 + step = 1, 343 + }) => { 344 + 345 + return ( 346 + <Slider.Root 347 + className="relative flex items-center w-full h-4" 348 + value={[value]} 349 + min={min} 350 + max={max} 351 + step={step} 352 + onValueChange={(v: number[]) => {}} 353 + > 354 + <Slider.Track className="relative flex-grow h-4 bg-gray-300 dark:bg-gray-700 rounded-full"> 355 + <Slider.Range className="absolute h-full bg-gray-500 dark:bg-gray-400 rounded-l-full rounded-r-none" /> 356 + </Slider.Track> 357 + <Slider.Thumb className=" hidden shadow-[0_0_0_8px_var(--color-white)] dark:shadow-[0_0_0_8px_var(--color-gray-950)] block w-[3px] h-12 bg-gray-500 dark:bg-gray-400 rounded-md focus:outline-none" /> 358 + </Slider.Root> 359 + ); 360 + };
+98 -1
src/styles/app.css
··· 33 33 --color-gray-950: oklch(0.129 0.055 var(--safe-hue)); 34 34 } 35 35 36 + :root { 37 + --link-text-color: oklch(0.5962 0.1987 var(--safe-hue)); 38 + /* max chroma!!! use fallback*/ 39 + /*--link-text-color: oklch(0.6 0.37 var(--safe-hue));*/ 40 + } 41 + 36 42 @layer base { 37 43 html { 38 44 color-scheme: light dark; ··· 84 90 .dangerousFediContent { 85 91 & a[href]{ 86 92 text-decoration: none; 87 - color: rgb(29, 122, 242); 93 + color: var(--link-text-color); 88 94 word-break: break-all; 89 95 } 90 96 } ··· 275 281 &::before{ 276 282 background-color: var(--color-gray-500); 277 283 } 284 + } 285 + } 286 + } 287 + 288 + :root{ 289 + --thumb-size: 2rem; 290 + --root-size: 3.25rem; 291 + 292 + --switch-off-border: var(--color-gray-400); 293 + --switch-off-bg: var(--color-gray-200); 294 + --switch-off-thumb: var(--color-gray-400); 295 + 296 + 297 + --switch-on-bg: var(--color-gray-500); 298 + --switch-on-thumb: var(--color-gray-50); 299 + 300 + } 301 + @media (prefers-color-scheme: dark) { 302 + :root { 303 + --switch-off-border: var(--color-gray-500); 304 + --switch-off-bg: var(--color-gray-800); 305 + --switch-off-thumb: var(--color-gray-500); 306 + 307 + 308 + --switch-on-bg: var(--color-gray-400); 309 + --switch-on-thumb: var(--color-gray-700); 310 + } 311 + } 312 + 313 + .m3switch.root{ 314 + /*w-10 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-gray-500 transition-colors*/ 315 + /*width: 40px; 316 + height: 24px;*/ 317 + 318 + inline-size: var(--root-size); 319 + block-size: 2rem; 320 + border-radius: 99999px; 321 + 322 + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; 323 + transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */ 324 + transition-duration: var(--default-transition-duration); /* 150ms */ 325 + 326 + .m3switch.thumb{ 327 + /*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/ 328 + 329 + height: var(--thumb-size); 330 + width: var(--thumb-size); 331 + display: inline-block; 332 + border-radius: 9999px; 333 + 334 + transform-origin: center; 335 + 336 + transition-property: transform, translate, scale, rotate; 337 + transition-timing-function: var(--default-transition-timing-function); /* cubic-bezier(0.4, 0, 0.2, 1) */ 338 + transition-duration: var(--default-transition-duration); /* 150ms */ 339 + 340 + } 341 + 342 + &[aria-checked="true"] { 343 + box-shadow: inset 0px 0px 0px 1.8px transparent; 344 + background-color: var(--switch-on-bg); 345 + 346 + .m3switch.thumb{ 347 + /*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/ 348 + 349 + background-color: var(--switch-on-thumb); 350 + transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.72); 351 + &:active { 352 + transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88); 353 + } 354 + 355 + } 356 + &:active .m3switch.thumb{ 357 + transform: translate(calc((var(--root-size) / 2) - 50%),0) scale(0.88); 358 + } 359 + } 360 + 361 + &[aria-checked="false"] { 362 + box-shadow: inset 0px 0px 0px 1.8px var(--switch-off-border); 363 + background-color: var(--switch-off-bg); 364 + .m3switch.thumb{ 365 + /*block w-5 h-5 bg-white rounded-full shadow-sm transition-transform translate-x-[2px] data-[state=checked]:translate-x-[20px]*/ 366 + 367 + background-color: var(--switch-off-thumb); 368 + transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.5); 369 + &:active { 370 + transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88); 371 + } 372 + } 373 + &:active .m3switch.thumb{ 374 + transform: translate(calc(-1 * (var(--root-size) / 2) + 50%),0) scale(0.88); 278 375 } 279 376 } 280 377 }
+40
src/utils/atoms.ts
··· 2 2 import { atomWithStorage } from "jotai/utils"; 3 3 import { useEffect } from "react"; 4 4 5 + import { type ProfilePostsFilter } from "~/routes/profile.$did"; 6 + 5 7 export const store = createStore(); 6 8 7 9 export const quickAuthAtom = atomWithStorage<string | null>( ··· 59 61 {} 60 62 ); 61 63 64 + export type LikeRecord = { 65 + uri: string; // at://did/collection/rkey 66 + target: string; 67 + cid: string; 68 + }; 69 + 70 + export const internalLikedPostsAtom = atomWithStorage<Record<string, LikeRecord | null>>( 71 + "internal-liked-posts", 72 + {} 73 + ); 74 + 75 + export const profileChipsAtom = atom<Record<string, ProfilePostsFilter | null>>({}) 76 + 62 77 export const defaultconstellationURL = "constellation.microcosm.blue"; 63 78 export const constellationURLAtom = atomWithStorage<string>( 64 79 "constellationURL", ··· 77 92 defaultVideoCDN 78 93 ); 79 94 95 + export const defaultLycanURL = ""; 96 + export const lycanURLAtom = atomWithStorage<string>( 97 + "lycanURL", 98 + defaultLycanURL 99 + ); 100 + 80 101 export const defaulthue = 28; 81 102 export const hueAtom = atomWithStorage<number>("hue", defaulthue); 82 103 ··· 113 134 // console.log("atom get ", initial); 114 135 // document.documentElement.style.setProperty(cssVar, initial.toString()); 115 136 // } 137 + 138 + 139 + 140 + // fun stuff 141 + 142 + export const enableBitesAtom = atomWithStorage<boolean>( 143 + "enableBitesAtom", 144 + false 145 + ); 146 + 147 + export const enableBridgyTextAtom = atomWithStorage<boolean>( 148 + "enableBridgyTextAtom", 149 + false 150 + ); 151 + 152 + export const enableWafrnTextAtom = atomWithStorage<boolean>( 153 + "enableWafrnTextAtom", 154 + false 155 + );
+34
src/utils/likeMutationQueue.ts
··· 1 + import { useAtom } from "jotai"; 2 + import { useCallback } from "react"; 3 + 4 + import { type LikeRecord,useLikeMutationQueue as useLikeMutationQueueFromProvider } from "~/providers/LikeMutationQueueProvider"; 5 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 6 + 7 + import { internalLikedPostsAtom } from "./atoms"; 8 + 9 + export function useFastLike(target: string, cid: string) { 10 + const { agent } = useAuth(); 11 + const { fastState, fastToggle, backfillState } = useLikeMutationQueueFromProvider(); 12 + 13 + const liked = fastState(target); 14 + const toggle = () => fastToggle(target, cid); 15 + /** 16 + * 17 + * @deprecated dont use it yet, will cause infinite rerenders 18 + */ 19 + const backfill = () => agent?.did && backfillState(target, agent.did); 20 + 21 + return { liked, toggle, backfill }; 22 + } 23 + 24 + export function useFastSetLikesFromFeed() { 25 + const [_, setLikedPosts] = useAtom(internalLikedPostsAtom); 26 + 27 + const setFastState = useCallback( 28 + (target: string, record: LikeRecord | null) => 29 + setLikedPosts((prev) => ({ ...prev, [target]: record })), 30 + [setLikedPosts] 31 + ); 32 + 33 + return { setFastState }; 34 + }
+2 -2
src/utils/oauthClient.ts
··· 1 1 import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser'; 2 2 3 - // i tried making this https://pds-nd.whey.party but cors is annoying as fuck 4 - const handleResolverPDS = 'https://bsky.social'; 3 + import resolvers from '../../public/resolvers.json' with { type: 'json' }; 4 + const handleResolverPDS = resolvers.resolver || 'https://bsky.social'; 5 5 6 6 // eslint-disable-next-line @typescript-eslint/ban-ts-comment 7 7 // @ts-ignore this should be fine ? the vite plugin should generate this before errors
+403 -155
src/utils/useQuery.ts
··· 5 5 queryOptions, 6 6 useInfiniteQuery, 7 7 useQuery, 8 - type UseQueryResult} from "@tanstack/react-query"; 8 + type UseQueryResult, 9 + } from "@tanstack/react-query"; 9 10 import { useAtom } from "jotai"; 10 11 11 - import { constellationURLAtom, slingshotURLAtom } from "./atoms"; 12 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 13 + 14 + import { constellationURLAtom, lycanURLAtom, slingshotURLAtom } from "./atoms"; 12 15 13 - export function constructIdentityQuery(didorhandle?: string, slingshoturl?: string) { 16 + export function constructIdentityQuery( 17 + didorhandle?: string, 18 + slingshoturl?: string 19 + ) { 14 20 return queryOptions({ 15 21 queryKey: ["identity", didorhandle], 16 22 queryFn: async () => { 17 - if (!didorhandle) return undefined as undefined 23 + if (!didorhandle) return undefined as undefined; 18 24 const res = await fetch( 19 25 `https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}` 20 26 ); ··· 31 37 } 32 38 }, 33 39 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 34 - gcTime: /*0//*/5 * 60 * 1000, 40 + gcTime: /*0//*/ 5 * 60 * 1000, 35 41 }); 36 42 } 37 43 export function useQueryIdentity(didorhandle: string): UseQueryResult< ··· 43 49 }, 44 50 Error 45 51 >; 46 - export function useQueryIdentity(): UseQueryResult< 47 - undefined, 48 - Error 49 - > 50 - export function useQueryIdentity(didorhandle?: string): 51 - UseQueryResult< 52 - { 53 - did: string; 54 - handle: string; 55 - pds: string; 56 - signing_key: string; 57 - } | undefined, 58 - Error 59 - > 52 + export function useQueryIdentity(): UseQueryResult<undefined, Error>; 53 + export function useQueryIdentity(didorhandle?: string): UseQueryResult< 54 + | { 55 + did: string; 56 + handle: string; 57 + pds: string; 58 + signing_key: string; 59 + } 60 + | undefined, 61 + Error 62 + >; 60 63 export function useQueryIdentity(didorhandle?: string) { 61 - const [slingshoturl] = useAtom(slingshotURLAtom) 64 + const [slingshoturl] = useAtom(slingshotURLAtom); 62 65 return useQuery(constructIdentityQuery(didorhandle, slingshoturl)); 63 66 } 64 67 ··· 66 69 return queryOptions({ 67 70 queryKey: ["post", uri], 68 71 queryFn: async () => { 69 - if (!uri) return undefined as undefined 72 + if (!uri) return undefined as undefined; 70 73 const res = await fetch( 71 74 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 72 75 ); ··· 77 80 return undefined; 78 81 } 79 82 if (res.status === 400) return undefined; 80 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 83 + if ( 84 + data?.error === "InvalidRequest" && 85 + data.message?.includes("Could not find repo") 86 + ) { 81 87 return undefined; // cache โ€œnot foundโ€ 82 88 } 83 89 try { 84 90 if (!res.ok) throw new Error("Failed to fetch post"); 85 - return (data) as { 91 + return data as { 86 92 uri: string; 87 93 cid: string; 88 94 value: any; ··· 97 103 return failureCount < 2; 98 104 }, 99 105 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 100 - gcTime: /*0//*/5 * 60 * 1000, 106 + gcTime: /*0//*/ 5 * 60 * 1000, 101 107 }); 102 108 } 103 109 export function useQueryPost(uri: string): UseQueryResult< ··· 108 114 }, 109 115 Error 110 116 >; 111 - export function useQueryPost(): UseQueryResult< 112 - undefined, 113 - Error 114 - > 115 - export function useQueryPost(uri?: string): 116 - UseQueryResult< 117 - { 118 - uri: string; 119 - cid: string; 120 - value: ATPAPI.AppBskyFeedPost.Record; 121 - } | undefined, 122 - Error 123 - > 117 + export function useQueryPost(): UseQueryResult<undefined, Error>; 118 + export function useQueryPost(uri?: string): UseQueryResult< 119 + | { 120 + uri: string; 121 + cid: string; 122 + value: ATPAPI.AppBskyFeedPost.Record; 123 + } 124 + | undefined, 125 + Error 126 + >; 124 127 export function useQueryPost(uri?: string) { 125 - const [slingshoturl] = useAtom(slingshotURLAtom) 128 + const [slingshoturl] = useAtom(slingshotURLAtom); 126 129 return useQuery(constructPostQuery(uri, slingshoturl)); 127 130 } 128 131 ··· 130 133 return queryOptions({ 131 134 queryKey: ["profile", uri], 132 135 queryFn: async () => { 133 - if (!uri) return undefined as undefined 136 + if (!uri) return undefined as undefined; 134 137 const res = await fetch( 135 138 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 136 139 ); ··· 141 144 return undefined; 142 145 } 143 146 if (res.status === 400) return undefined; 144 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 147 + if ( 148 + data?.error === "InvalidRequest" && 149 + data.message?.includes("Could not find repo") 150 + ) { 145 151 return undefined; // cache โ€œnot foundโ€ 146 152 } 147 153 try { 148 154 if (!res.ok) throw new Error("Failed to fetch post"); 149 - return (data) as { 155 + return data as { 150 156 uri: string; 151 157 cid: string; 152 158 value: any; ··· 161 167 return failureCount < 2; 162 168 }, 163 169 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 164 - gcTime: /*0//*/5 * 60 * 1000, 170 + gcTime: /*0//*/ 5 * 60 * 1000, 165 171 }); 166 172 } 167 173 export function useQueryProfile(uri: string): UseQueryResult< ··· 172 178 }, 173 179 Error 174 180 >; 175 - export function useQueryProfile(): UseQueryResult< 176 - undefined, 177 - Error 178 - >; 179 - export function useQueryProfile(uri?: string): 180 - UseQueryResult< 181 - { 181 + export function useQueryProfile(): UseQueryResult<undefined, Error>; 182 + export function useQueryProfile(uri?: string): UseQueryResult< 183 + | { 182 184 uri: string; 183 185 cid: string; 184 186 value: ATPAPI.AppBskyActorProfile.Record; 185 - } | undefined, 186 - Error 187 - > 187 + } 188 + | undefined, 189 + Error 190 + >; 188 191 export function useQueryProfile(uri?: string) { 189 - const [slingshoturl] = useAtom(slingshotURLAtom) 192 + const [slingshoturl] = useAtom(slingshotURLAtom); 190 193 return useQuery(constructProfileQuery(uri, slingshoturl)); 191 194 } 192 195 ··· 222 225 // method: "/links/all", 223 226 // target: string 224 227 // ): QueryOptions<linksAllResponse, Error>; 225 - export function constructConstellationQuery(query?:{ 226 - constellation: string, 228 + export function constructConstellationQuery(query?: { 229 + constellation: string; 227 230 method: 228 231 | "/links" 229 232 | "/links/distinct-dids" 230 233 | "/links/count" 231 234 | "/links/count/distinct-dids" 232 235 | "/links/all" 233 - | "undefined", 234 - target: string, 235 - collection?: string, 236 - path?: string, 237 - cursor?: string, 238 - dids?: string[] 239 - } 240 - ) { 236 + | "undefined"; 237 + target: string; 238 + collection?: string; 239 + path?: string; 240 + cursor?: string; 241 + dids?: string[]; 242 + }) { 241 243 // : QueryOptions< 242 244 // | linksRecordsResponse 243 245 // | linksDidsResponse ··· 247 249 // Error 248 250 // > 249 251 return queryOptions({ 250 - queryKey: ["constellation", query?.method, query?.target, query?.collection, query?.path, query?.cursor, query?.dids] as const, 252 + queryKey: [ 253 + "constellation", 254 + query?.method, 255 + query?.target, 256 + query?.collection, 257 + query?.path, 258 + query?.cursor, 259 + query?.dids, 260 + ] as const, 251 261 queryFn: async () => { 252 - if (!query || query.method === "undefined") return undefined as undefined 253 - const method = query.method 254 - const target = query.target 255 - const collection = query?.collection 256 - const path = query?.path 257 - const cursor = query.cursor 258 - const dids = query?.dids 262 + if (!query || query.method === "undefined") return undefined as undefined; 263 + const method = query.method; 264 + const target = query.target; 265 + const collection = query?.collection; 266 + const path = query?.path; 267 + const cursor = query.cursor; 268 + const dids = query?.dids; 259 269 const res = await fetch( 260 270 `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}` 261 271 ); ··· 281 291 }, 282 292 // enforce short lifespan 283 293 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 284 - gcTime: /*0//*/5 * 60 * 1000, 294 + gcTime: /*0//*/ 5 * 60 * 1000, 285 295 }); 286 296 } 297 + // todo do more of these instead of overloads since overloads sucks so much apparently 298 + export function useQueryConstellationLinksCountDistinctDids(query?: { 299 + method: "/links/count/distinct-dids"; 300 + target: string; 301 + collection: string; 302 + path: string; 303 + cursor?: string; 304 + }): UseQueryResult<linksCountResponse, Error> | undefined { 305 + //if (!query) return; 306 + const [constellationurl] = useAtom(constellationURLAtom); 307 + const queryres = useQuery( 308 + constructConstellationQuery( 309 + query && { constellation: constellationurl, ...query } 310 + ) 311 + ) as unknown as UseQueryResult<linksCountResponse, Error>; 312 + if (!query) { 313 + return undefined as undefined; 314 + } 315 + return queryres as UseQueryResult<linksCountResponse, Error>; 316 + } 317 + 287 318 export function useQueryConstellation(query: { 288 319 method: "/links"; 289 320 target: string; ··· 346 377 > 347 378 | undefined { 348 379 //if (!query) return; 349 - const [constellationurl] = useAtom(constellationURLAtom) 380 + const [constellationurl] = useAtom(constellationURLAtom); 350 381 return useQuery( 351 - constructConstellationQuery(query && {constellation: constellationurl, ...query}) 382 + constructConstellationQuery( 383 + query && { constellation: constellationurl, ...query } 384 + ) 352 385 ); 353 386 } 354 387 ··· 392 425 }) { 393 426 return queryOptions({ 394 427 // The query key includes all dependencies to ensure it refetches when they change 395 - queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }], 428 + queryKey: [ 429 + "feedSkeleton", 430 + options?.feedUri, 431 + { isAuthed: options?.isAuthed, did: options?.agent?.did }, 432 + ], 396 433 queryFn: async () => { 397 - if (!options) return undefined as undefined 434 + if (!options) return undefined as undefined; 398 435 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 399 436 if (isAuthed) { 400 437 // Authenticated flow 401 438 if (!agent || !pdsUrl || !feedServiceDid) { 402 - throw new Error("Missing required info for authenticated feed fetch."); 439 + throw new Error( 440 + "Missing required info for authenticated feed fetch." 441 + ); 403 442 } 404 443 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 405 444 const res = await agent.fetchHandler(url, { ··· 409 448 "Content-Type": "application/json", 410 449 }, 411 450 }); 412 - if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 451 + if (!res.ok) 452 + throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 413 453 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 414 454 } else { 415 455 // Unauthenticated flow (using a public PDS/AppView) 416 456 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 417 457 const res = await fetch(url); 418 - if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 458 + if (!res.ok) 459 + throw new Error(`Public feed fetch failed: ${res.statusText}`); 419 460 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 420 461 } 421 462 }, ··· 433 474 return useQuery(constructFeedSkeletonQuery(options)); 434 475 } 435 476 436 - export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) { 477 + export function constructPreferencesQuery( 478 + agent?: ATPAPI.Agent | undefined, 479 + pdsUrl?: string | undefined 480 + ) { 437 481 return queryOptions({ 438 - queryKey: ['preferences', agent?.did], 482 + queryKey: ["preferences", agent?.did], 439 483 queryFn: async () => { 440 484 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available"); 441 485 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`; ··· 446 490 }); 447 491 } 448 492 export function useQueryPreferences(options: { 449 - agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined 493 + agent?: ATPAPI.Agent | undefined; 494 + pdsUrl?: string | undefined; 450 495 }) { 451 496 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl)); 452 497 } 453 498 454 - 455 - 456 499 export function constructArbitraryQuery(uri?: string, slingshoturl?: string) { 457 500 return queryOptions({ 458 501 queryKey: ["arbitrary", uri], 459 502 queryFn: async () => { 460 - if (!uri) return undefined as undefined 503 + if (!uri) return undefined as undefined; 461 504 const res = await fetch( 462 505 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 463 506 ); ··· 468 511 return undefined; 469 512 } 470 513 if (res.status === 400) return undefined; 471 - if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 514 + if ( 515 + data?.error === "InvalidRequest" && 516 + data.message?.includes("Could not find repo") 517 + ) { 472 518 return undefined; // cache โ€œnot foundโ€ 473 519 } 474 520 try { 475 521 if (!res.ok) throw new Error("Failed to fetch post"); 476 - return (data) as { 522 + return data as { 477 523 uri: string; 478 524 cid: string; 479 525 value: any; ··· 488 534 return failureCount < 2; 489 535 }, 490 536 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 491 - gcTime: /*0//*/5 * 60 * 1000, 537 + gcTime: /*0//*/ 5 * 60 * 1000, 492 538 }); 493 539 } 494 540 export function useQueryArbitrary(uri: string): UseQueryResult< ··· 499 545 }, 500 546 Error 501 547 >; 502 - export function useQueryArbitrary(): UseQueryResult< 503 - undefined, 504 - Error 505 - >; 548 + export function useQueryArbitrary(): UseQueryResult<undefined, Error>; 506 549 export function useQueryArbitrary(uri?: string): UseQueryResult< 507 - { 508 - uri: string; 509 - cid: string; 510 - value: any; 511 - } | undefined, 550 + | { 551 + uri: string; 552 + cid: string; 553 + value: any; 554 + } 555 + | undefined, 512 556 Error 513 557 >; 514 558 export function useQueryArbitrary(uri?: string) { 515 - const [slingshoturl] = useAtom(slingshotURLAtom) 559 + const [slingshoturl] = useAtom(slingshotURLAtom); 516 560 return useQuery(constructArbitraryQuery(uri, slingshoturl)); 517 561 } 518 562 519 - export function constructFallbackNothingQuery(){ 563 + export function constructFallbackNothingQuery() { 520 564 return queryOptions({ 521 565 queryKey: ["nothing"], 522 566 queryFn: async () => { 523 - return undefined 567 + return undefined; 524 568 }, 525 569 }); 526 570 } ··· 534 578 }[]; 535 579 }; 536 580 537 - export function constructAuthorFeedQuery(did: string, pdsUrl: string, collection: string = "app.bsky.feed.post") { 581 + export function constructAuthorFeedQuery( 582 + did: string, 583 + pdsUrl: string, 584 + collection: string = "app.bsky.feed.post" 585 + ) { 538 586 return queryOptions({ 539 - queryKey: ['authorFeed', did, collection], 587 + queryKey: ["authorFeed", did, collection], 540 588 queryFn: async ({ pageParam }: QueryFunctionContext) => { 541 589 const limit = 25; 542 - 590 + 543 591 const cursor = pageParam as string | undefined; 544 - const cursorParam = cursor ? `&cursor=${cursor}` : ''; 545 - 592 + const cursorParam = cursor ? `&cursor=${cursor}` : ""; 593 + 546 594 const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`; 547 - 595 + 548 596 const res = await fetch(url); 549 597 if (!res.ok) throw new Error("Failed to fetch author's posts"); 550 - 598 + 551 599 return res.json() as Promise<ListRecordsResponse>; 552 600 }, 553 601 }); 554 602 } 555 603 556 - export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined, collection?: string) { 557 - const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!, collection); 558 - 604 + export function useInfiniteQueryAuthorFeed( 605 + did: string | undefined, 606 + pdsUrl: string | undefined, 607 + collection?: string 608 + ) { 609 + const { queryKey, queryFn } = constructAuthorFeedQuery( 610 + did!, 611 + pdsUrl!, 612 + collection 613 + ); 614 + 559 615 return useInfiniteQuery({ 560 616 queryKey, 561 617 queryFn, ··· 576 632 // todo the hell is a unauthedfeedurl 577 633 unauthedfeedurl?: string; 578 634 }) { 579 - const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = options; 580 - 635 + const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = 636 + options; 637 + 581 638 return queryOptions({ 582 639 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }], 583 - 584 - queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 640 + 641 + queryFn: async ({ 642 + pageParam, 643 + }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 585 644 const cursorParam = pageParam ? `&cursor=${pageParam}` : ""; 586 - 645 + 587 646 if (isAuthed && !unauthedfeedurl) { 588 647 if (!agent || !pdsUrl || !feedServiceDid) { 589 - throw new Error("Missing required info for authenticated feed fetch."); 648 + throw new Error( 649 + "Missing required info for authenticated feed fetch." 650 + ); 590 651 } 591 652 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 592 653 const res = await agent.fetchHandler(url, { ··· 596 657 "Content-Type": "application/json", 597 658 }, 598 659 }); 599 - if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 660 + if (!res.ok) 661 + throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 600 662 return (await res.json()) as FeedSkeletonPage; 601 663 } else { 602 664 const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 603 665 const res = await fetch(url); 604 - if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 666 + if (!res.ok) 667 + throw new Error(`Public feed fetch failed: ${res.statusText}`); 605 668 return (await res.json()) as FeedSkeletonPage; 606 669 } 607 670 }, ··· 617 680 unauthedfeedurl?: string; 618 681 }) { 619 682 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 620 - 621 - return {...useInfiniteQuery({ 622 - queryKey, 623 - queryFn, 624 - initialPageParam: undefined as never, 625 - getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 626 - staleTime: Infinity, 627 - refetchOnWindowFocus: false, 628 - enabled: !!options.feedUri && (options.isAuthed ? (!!options.agent && !!options.pdsUrl || !!options.unauthedfeedurl) && !!options.feedServiceDid : true), 629 - }), queryKey: queryKey}; 630 - } 631 683 684 + return { 685 + ...useInfiniteQuery({ 686 + queryKey, 687 + queryFn, 688 + initialPageParam: undefined as never, 689 + getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 690 + staleTime: Infinity, 691 + refetchOnWindowFocus: false, 692 + enabled: 693 + !!options.feedUri && 694 + (options.isAuthed 695 + ? ((!!options.agent && !!options.pdsUrl) || 696 + !!options.unauthedfeedurl) && 697 + !!options.feedServiceDid 698 + : true), 699 + }), 700 + queryKey: queryKey, 701 + }; 702 + } 632 703 633 704 export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: { 634 - constellation: string, 635 - method: '/links' 636 - target?: string 637 - collection: string 638 - path: string 705 + constellation: string; 706 + method: "/links"; 707 + target?: string; 708 + collection: string; 709 + path: string; 710 + staleMult?: number; 639 711 }) { 712 + const safemult = query?.staleMult ?? 1; 640 713 // console.log( 641 714 // 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 642 715 // query, ··· 645 718 return infiniteQueryOptions({ 646 719 enabled: !!query?.target, 647 720 queryKey: [ 648 - 'reddwarf_constellation', 721 + "reddwarf_constellation", 649 722 query?.method, 650 723 query?.target, 651 724 query?.collection, 652 725 query?.path, 653 726 ] as const, 654 727 655 - queryFn: async ({pageParam}: {pageParam?: string}) => { 656 - if (!query || !query?.target) return undefined 728 + queryFn: async ({ pageParam }: { pageParam?: string }) => { 729 + if (!query || !query?.target) return undefined; 657 730 658 - const method = query.method 659 - const target = query.target 660 - const collection = query.collection 661 - const path = query.path 662 - const cursor = pageParam 731 + const method = query.method; 732 + const target = query.target; 733 + const collection = query.collection; 734 + const path = query.path; 735 + const cursor = pageParam; 663 736 664 737 const res = await fetch( 665 738 `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${ 666 - collection ? `&collection=${encodeURIComponent(collection)}` : '' 667 - }${path ? `&path=${encodeURIComponent(path)}` : ''}${ 668 - cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' 669 - }`, 670 - ) 739 + collection ? `&collection=${encodeURIComponent(collection)}` : "" 740 + }${path ? `&path=${encodeURIComponent(path)}` : ""}${ 741 + cursor ? `&cursor=${encodeURIComponent(cursor)}` : "" 742 + }` 743 + ); 671 744 672 - if (!res.ok) throw new Error('Failed to fetch') 745 + if (!res.ok) throw new Error("Failed to fetch"); 673 746 674 - return (await res.json()) as linksRecordsResponse 747 + return (await res.json()) as linksRecordsResponse; 675 748 }, 676 749 677 - getNextPageParam: lastPage => { 678 - return (lastPage as any)?.cursor ?? undefined 750 + getNextPageParam: (lastPage) => { 751 + return (lastPage as any)?.cursor ?? undefined; 679 752 }, 680 753 initialPageParam: undefined, 681 - staleTime: 5 * 60 * 1000, 682 - gcTime: 5 * 60 * 1000, 683 - }) 684 - } 754 + staleTime: 5 * 60 * 1000 * safemult, 755 + gcTime: 5 * 60 * 1000 * safemult, 756 + }); 757 + } 758 + 759 + export function useQueryLycanStatus() { 760 + const [lycanurl] = useAtom(lycanURLAtom); 761 + const { agent, status } = useAuth(); 762 + const { data: identity } = useQueryIdentity(agent?.did); 763 + return useQuery( 764 + constructLycanStatusCheckQuery({ 765 + agent: agent || undefined, 766 + isAuthed: status === "signedIn", 767 + pdsUrl: identity?.pds, 768 + feedServiceDid: "did:web:"+lycanurl, 769 + }) 770 + ); 771 + } 772 + 773 + export function constructLycanStatusCheckQuery(options: { 774 + agent?: ATPAPI.Agent; 775 + isAuthed: boolean; 776 + pdsUrl?: string; 777 + feedServiceDid?: string; 778 + }) { 779 + const { agent, isAuthed, pdsUrl, feedServiceDid } = options; 780 + 781 + return queryOptions({ 782 + queryKey: ["lycanStatus", { isAuthed, did: agent?.did }], 783 + 784 + queryFn: async () => { 785 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 786 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.getImportStatus`; 787 + const res = await agent.fetchHandler(url, { 788 + method: "GET", 789 + headers: { 790 + "atproto-proxy": `${feedServiceDid}#lycan`, 791 + "Content-Type": "application/json", 792 + }, 793 + }); 794 + if (!res.ok) 795 + throw new Error( 796 + `Authenticated lycan status fetch failed: ${res.statusText}` 797 + ); 798 + return (await res.json()) as statuschek; 799 + } 800 + return undefined; 801 + }, 802 + }); 803 + } 804 + 805 + type statuschek = { 806 + [key: string]: unknown; 807 + error?: "MethodNotImplemented"; 808 + message?: "Method Not Implemented"; 809 + status?: "finished" | "in_progress"; 810 + position?: string, 811 + progress?: number, 812 + 813 + }; 814 + 815 + //{"status":"in_progress","position":"2025-08-30T06:53:18Z","progress":0.0878319661441268} 816 + type importtype = { 817 + message?: "Import has already started" | "Import has been scheduled" 818 + } 819 + 820 + export function constructLycanRequestIndexQuery(options: { 821 + agent?: ATPAPI.Agent; 822 + isAuthed: boolean; 823 + pdsUrl?: string; 824 + feedServiceDid?: string; 825 + }) { 826 + const { agent, isAuthed, pdsUrl, feedServiceDid } = options; 827 + 828 + return queryOptions({ 829 + queryKey: ["lycanIndex", { isAuthed, did: agent?.did }], 830 + 831 + queryFn: async () => { 832 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 833 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.startImport`; 834 + const res = await agent.fetchHandler(url, { 835 + method: "POST", 836 + headers: { 837 + "atproto-proxy": `${feedServiceDid}#lycan`, 838 + "Content-Type": "application/json", 839 + }, 840 + }); 841 + if (!res.ok) 842 + throw new Error( 843 + `Authenticated lycan status fetch failed: ${res.statusText}` 844 + ); 845 + return await res.json() as importtype; 846 + } 847 + return undefined; 848 + }, 849 + }); 850 + } 851 + 852 + type LycanSearchPage = { 853 + terms: string[]; 854 + posts: string[]; 855 + cursor?: string; 856 + }; 857 + 858 + 859 + export function useInfiniteQueryLycanSearch(options: { query: string, type: "likes" | "pins" | "reposts" | "quotes"}) { 860 + 861 + 862 + const [lycanurl] = useAtom(lycanURLAtom); 863 + const { agent, status } = useAuth(); 864 + const { data: identity } = useQueryIdentity(agent?.did); 865 + 866 + const { queryKey, queryFn } = constructLycanSearchQuery({ 867 + agent: agent || undefined, 868 + isAuthed: status === "signedIn", 869 + pdsUrl: identity?.pds, 870 + feedServiceDid: "did:web:"+lycanurl, 871 + query: options.query, 872 + type: options.type, 873 + }) 874 + 875 + return { 876 + ...useInfiniteQuery({ 877 + queryKey, 878 + queryFn, 879 + initialPageParam: undefined as never, 880 + getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined, 881 + //staleTime: Infinity, 882 + refetchOnWindowFocus: false, 883 + // enabled: 884 + // !!options.feedUri && 885 + // (options.isAuthed 886 + // ? ((!!options.agent && !!options.pdsUrl) || 887 + // !!options.unauthedfeedurl) && 888 + // !!options.feedServiceDid 889 + // : true), 890 + }), 891 + queryKey: queryKey, 892 + }; 893 + } 894 + 895 + 896 + export function constructLycanSearchQuery(options: { 897 + agent?: ATPAPI.Agent; 898 + isAuthed: boolean; 899 + pdsUrl?: string; 900 + feedServiceDid?: string; 901 + type: "likes" | "pins" | "reposts" | "quotes"; 902 + query: string; 903 + }) { 904 + const { agent, isAuthed, pdsUrl, feedServiceDid, type, query } = options; 905 + 906 + return infiniteQueryOptions({ 907 + queryKey: ["lycanSearch", query, type, { isAuthed, did: agent?.did }], 908 + 909 + queryFn: async ({ 910 + pageParam, 911 + }: QueryFunctionContext): Promise<LycanSearchPage | undefined> => { 912 + if (isAuthed && agent && pdsUrl && feedServiceDid) { 913 + const url = `${pdsUrl}/xrpc/blue.feeds.lycan.searchPosts?query=${query}&collection=${type}${pageParam ? `&cursor=${pageParam}` : ""}`; 914 + const res = await agent.fetchHandler(url, { 915 + method: "GET", 916 + headers: { 917 + "atproto-proxy": `${feedServiceDid}#lycan`, 918 + "Content-Type": "application/json", 919 + }, 920 + }); 921 + if (!res.ok) 922 + throw new Error( 923 + `Authenticated lycan status fetch failed: ${res.statusText}` 924 + ); 925 + return (await res.json()) as LycanSearchPage; 926 + } 927 + return undefined; 928 + }, 929 + initialPageParam: undefined as never, 930 + getNextPageParam: (lastPage) => lastPage?.cursor as null | undefined, 931 + }); 932 + }
+5
vite.config.ts
··· 13 13 const PROD_URL = "https://reddwarf.app" 14 14 const DEV_URL = "https://local3768forumtest.whey.party" 15 15 16 + const PROD_HANDLE_RESOLVER_PDS = "https://pds-nd.whey.party" 17 + const DEV_HANDLE_RESOLVER_PDS = "https://bsky.social" 18 + 16 19 function shp(url: string): string { 17 20 return url.replace(/^https?:\/\//, ''); 18 21 } ··· 23 26 generateMetadataPlugin({ 24 27 prod: PROD_URL, 25 28 dev: DEV_URL, 29 + prodResolver: PROD_HANDLE_RESOLVER_PDS, 30 + devResolver: DEV_HANDLE_RESOLVER_PDS, 26 31 }), 27 32 TanStackRouterVite({ autoCodeSplitting: true }), 28 33 viteReact({