a tool for shared writing and social publishing

add local like data fetching w/ batcher

+142 -55
-1
actions/getIdentityData.ts
··· 42 42 .eq("confirmed", true) 43 43 .single() 44 44 : null; 45 - console.log(auth_res); 46 45 if (!auth_res?.data?.identities) return null; 47 46 if (auth_res.data.identities.atp_did) { 48 47 //I should create a relationship table so I can do this in the above query
+40
app/api/rpc/[command]/get_user_recommendations.ts
··· 1 + import { z } from "zod"; 2 + import { makeRoute } from "../lib"; 3 + import type { Env } from "./route"; 4 + import { getIdentityData } from "actions/getIdentityData"; 5 + 6 + export type GetUserRecommendationsReturnType = Awaited< 7 + ReturnType<(typeof get_user_recommendations)["handler"]> 8 + >; 9 + 10 + export const get_user_recommendations = makeRoute({ 11 + route: "get_user_recommendations", 12 + input: z.object({ 13 + documentUris: z.array(z.string()), 14 + }), 15 + handler: async ({ documentUris }, { supabase }: Pick<Env, "supabase">) => { 16 + const identity = await getIdentityData(); 17 + const currentUserDid = identity?.atp_did; 18 + 19 + if (!currentUserDid || documentUris.length === 0) { 20 + return { 21 + result: {} as Record<string, boolean>, 22 + }; 23 + } 24 + 25 + const { data: recommendations } = await supabase 26 + .from("recommends_on_documents") 27 + .select("document") 28 + .eq("recommender_did", currentUserDid) 29 + .in("document", documentUris); 30 + 31 + const recommendedSet = new Set(recommendations?.map((r) => r.document)); 32 + 33 + const result: Record<string, boolean> = {}; 34 + for (const uri of documentUris) { 35 + result[uri] = recommendedSet.has(uri); 36 + } 37 + 38 + return { result }; 39 + }, 40 + });
+2
app/api/rpc/[command]/route.ts
··· 14 14 import { search_publication_names } from "./search_publication_names"; 15 15 import { search_publication_documents } from "./search_publication_documents"; 16 16 import { get_profile_data } from "./get_profile_data"; 17 + import { get_user_recommendations } from "./get_user_recommendations"; 17 18 18 19 let supabase = createClient<Database>( 19 20 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, ··· 41 42 search_publication_names, 42 43 search_publication_documents, 43 44 get_profile_data, 45 + get_user_recommendations, 44 46 ]; 45 47 export async function POST( 46 48 req: Request,
-3
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 72 72 commentsCount={getCommentCount(document.comments_on_documents, pageId)} 73 73 quotesCount={getQuoteCount(document.quotesAndMentions, pageId)} 74 74 recommendsCount={document.recommendsCount} 75 - hasRecommended={document.hasRecommended} 76 75 /> 77 76 <CanvasContent 78 77 blocks={blocks} ··· 212 211 quotesCount: number | undefined; 213 212 commentsCount: number | undefined; 214 213 recommendsCount: number; 215 - hasRecommended: boolean; 216 214 }) => { 217 215 let isMobile = useIsMobile(); 218 216 return ( ··· 221 219 quotesCount={props.quotesCount || 0} 222 220 commentsCount={props.commentsCount || 0} 223 221 recommendsCount={props.recommendsCount} 224 - hasRecommended={props.hasRecommended} 225 222 showComments={props.preferences.showComments !== false} 226 223 showMentions={props.preferences.showMentions !== false} 227 224 pageId={props.pageId}
-4
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 107 107 quotesCount: number; 108 108 commentsCount: number; 109 109 recommendsCount: number; 110 - hasRecommended: boolean; 111 110 className?: string; 112 111 showComments: boolean; 113 112 showMentions: boolean; ··· 138 137 <RecommendButton 139 138 documentUri={document_uri} 140 139 recommendsCount={props.recommendsCount} 141 - hasRecommended={props.hasRecommended} 142 140 /> 143 141 144 142 {props.quotesCount === 0 || props.showMentions === false ? null : ( ··· 177 175 quotesCount: number; 178 176 commentsCount: number; 179 177 recommendsCount: number; 180 - hasRecommended: boolean; 181 178 className?: string; 182 179 showComments: boolean; 183 180 showMentions: boolean; ··· 238 235 <RecommendButton 239 236 documentUri={document_uri} 240 237 recommendsCount={props.recommendsCount} 241 - hasRecommended={props.hasRecommended} 242 238 /> 243 239 {props.quotesCount === 0 || !props.showMentions ? null : ( 244 240 <button
+4 -3
app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts
··· 21 21 console.log("recommend action..."); 22 22 let identity = await getIdentityData(); 23 23 if (!identity || !identity.atp_did) { 24 - console.log("recommended"); 25 - 26 24 return { 27 25 success: false, 28 26 error: { 29 27 type: "oauth_session_expired", 30 28 message: "Not authenticated", 29 + did: "", 31 30 }, 32 31 }; 33 32 } ··· 58 57 record, 59 58 ); 60 59 61 - await supabaseServerClient.from("recommends_on_documents").upsert({ 60 + let res = await supabaseServerClient.from("recommends_on_documents").upsert({ 62 61 uri: uri.toString(), 63 62 document: args.document, 64 63 recommender_did: credentialSession.did!, ··· 67 66 ...record, 68 67 } as unknown as Json, 69 68 }); 69 + console.log(res); 70 70 71 71 return { 72 72 success: true, ··· 84 84 error: { 85 85 type: "oauth_session_expired", 86 86 message: "Not authenticated", 87 + did: "", 87 88 }, 88 89 }; 89 90 }
-1
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 92 92 } 93 93 quotesCount={getQuoteCount(document.quotesAndMentions, pageId) || 0} 94 94 recommendsCount={document.recommendsCount} 95 - hasRecommended={document.hasRecommended} 96 95 /> 97 96 {!hasPageBackground && <div className={`spacer h-8 w-full`} />} 98 97 </PageWrapper>
-1
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 92 92 getCommentCount(document?.comments_on_documents || []) || 0 93 93 } 94 94 recommendsCount={document?.recommendsCount || 0} 95 - hasRecommended={document?.hasRecommended || false} 96 95 /> 97 96 </> 98 97 }
-17
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 8 8 } from "src/utils/normalizeRecords"; 9 9 import { PubLeafletPublication, SiteStandardPublication } from "lexicons/api"; 10 10 import { documentUriFilter } from "src/utils/uriHelpers"; 11 - import { getIdentityData } from "actions/getIdentityData"; 12 11 13 12 export async function getPostPageData(did: string, rkey: string) { 14 - const identity = await getIdentityData(); 15 - const currentUserDid = identity?.atp_did; 16 - 17 13 let { data: documents } = await supabaseServerClient 18 14 .from("documents") 19 15 .select( ··· 36 32 let document = documents?.[0]; 37 33 38 34 if (!document) return null; 39 - 40 - // Check if current user has recommended this document 41 - let hasRecommended = false; 42 - if (currentUserDid) { 43 - const { data: userRecommend } = await supabaseServerClient 44 - .from("recommends_on_documents") 45 - .select("uri") 46 - .eq("document", document.uri) 47 - .eq("recommender_did", currentUserDid) 48 - .limit(1); 49 - hasRecommended = (userRecommend?.length ?? 0) > 0; 50 - } 51 35 52 36 // Normalize the document record - this is the primary way consumers should access document data 53 37 const normalizedDocument = normalizeDocumentRecord( ··· 178 162 leafletId: document.leaflets_in_publications[0]?.leaflet || null, 179 163 // Recommends data 180 164 recommendsCount, 181 - hasRecommended, 182 165 }; 183 166 } 184 167
-1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 138 138 quotesCount={doc.mentionsCount} 139 139 commentsCount={doc.commentsCount} 140 140 recommendsCount={doc.recommendsCount} 141 - hasRecommended={false} 142 141 documentUri={doc.uri} 143 142 tags={doc.record.tags || []} 144 143 showComments={pubRecord?.preferences?.showComments !== false}
-1
app/lish/[did]/[publication]/page.tsx
··· 170 170 quotesCount={quotes} 171 171 commentsCount={comments} 172 172 recommendsCount={recommends} 173 - hasRecommended={false} 174 173 documentUri={doc.documents.uri} 175 174 tags={tags} 176 175 postUrl={`${getPublicationURL(publication)}/${uri.rkey}`}
-2
components/InteractionsPreview.tsx
··· 13 13 quotesCount: number; 14 14 commentsCount: number; 15 15 recommendsCount: number; 16 - hasRecommended: boolean; 17 16 documentUri: string; 18 17 tags?: string[]; 19 18 postUrl: string; ··· 43 42 <RecommendButton 44 43 documentUri={props.documentUri} 45 44 recommendsCount={props.recommendsCount} 46 - hasRecommended={props.hasRecommended} 47 45 /> 48 46 49 47 {!props.showMentions || props.quotesCount === 0 ? null : (
-1
components/PostListing.tsx
··· 105 105 quotesCount={quotes} 106 106 commentsCount={comments} 107 107 recommendsCount={recommends} 108 - hasRecommended={false} 109 108 documentUri={props.documents.uri} 110 109 tags={tags} 111 110 showComments={pubRecord?.preferences?.showComments !== false}
+79 -19
components/RecommendButton.tsx
··· 1 1 "use client"; 2 2 3 3 import { useState } from "react"; 4 + import useSWR, { mutate } from "swr"; 5 + import { create, windowScheduler } from "@yornaath/batshit"; 4 6 import { RecommendTinyEmpty, RecommendTinyFilled } from "./Icons/RecommendTiny"; 5 7 import { 6 8 recommendAction, 7 9 unrecommendAction, 8 10 } from "app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction"; 11 + import { callRPC } from "app/api/rpc/client"; 12 + import { useToaster } from "./Toast"; 13 + import { OAuthErrorMessage, isOAuthSessionError } from "./OAuthError"; 14 + 15 + // Create a batcher for recommendation checks 16 + // Batches requests made within 10ms window 17 + const recommendationBatcher = create({ 18 + fetcher: async (documentUris: string[]) => { 19 + const response = await callRPC("get_user_recommendations", { documentUris }); 20 + return response.result; 21 + }, 22 + resolver: (results, documentUri) => results[documentUri] ?? false, 23 + scheduler: windowScheduler(10), 24 + }); 25 + 26 + const getRecommendationKey = (documentUri: string) => 27 + `recommendation:${documentUri}`; 28 + 29 + function useUserRecommendation(documentUri: string) { 30 + const { data: hasRecommended, isLoading } = useSWR( 31 + getRecommendationKey(documentUri), 32 + () => recommendationBatcher.fetch(documentUri), 33 + ); 9 34 35 + return { 36 + hasRecommended: hasRecommended ?? false, 37 + isLoading, 38 + }; 39 + } 40 + 41 + function mutateRecommendation(documentUri: string, hasRecommended: boolean) { 42 + mutate(getRecommendationKey(documentUri), hasRecommended, { 43 + revalidate: false, 44 + }); 45 + } 46 + 47 + /** 48 + * RecommendButton that fetches the user's recommendation status asynchronously. 49 + * Uses SWR with batched requests for efficient fetching when many buttons are rendered. 50 + */ 10 51 export function RecommendButton(props: { 11 52 documentUri: string; 12 53 recommendsCount: number; 13 - hasRecommended: boolean; 14 54 className?: string; 15 55 showCount?: boolean; 16 56 }) { 17 - const [hasRecommended, setHasRecommended] = useState(props.hasRecommended); 57 + const { hasRecommended, isLoading } = useUserRecommendation(props.documentUri); 18 58 const [count, setCount] = useState(props.recommendsCount); 19 59 const [isPending, setIsPending] = useState(false); 60 + const [optimisticRecommended, setOptimisticRecommended] = useState< 61 + boolean | null 62 + >(null); 63 + const toaster = useToaster(); 64 + 65 + // Use optimistic state if set, otherwise use fetched state 66 + const displayRecommended = 67 + optimisticRecommended !== null ? optimisticRecommended : hasRecommended; 20 68 21 69 const handleClick = async () => { 22 - if (isPending) return; 70 + if (isPending || isLoading) return; 23 71 24 - const currentlyRecommended = hasRecommended; 72 + const currentlyRecommended = displayRecommended; 25 73 setIsPending(true); 26 - setHasRecommended(!currentlyRecommended); 74 + setOptimisticRecommended(!currentlyRecommended); 27 75 setCount((c) => (currentlyRecommended ? c - 1 : c + 1)); 28 76 29 - try { 30 - if (currentlyRecommended) { 31 - await unrecommendAction({ document: props.documentUri }); 32 - } else { 33 - await recommendAction({ document: props.documentUri }); 34 - } 35 - } catch (error) { 36 - // Revert on error 37 - setHasRecommended(currentlyRecommended); 77 + const result = currentlyRecommended 78 + ? await unrecommendAction({ document: props.documentUri }) 79 + : await recommendAction({ document: props.documentUri }); 80 + 81 + if (!result.success) { 82 + // Revert optimistic update 83 + setOptimisticRecommended(null); 38 84 setCount((c) => (currentlyRecommended ? c + 1 : c - 1)); 39 - } finally { 40 85 setIsPending(false); 86 + 87 + toaster({ 88 + content: isOAuthSessionError(result.error) ? ( 89 + <OAuthErrorMessage error={result.error} /> 90 + ) : ( 91 + "Failed to update recommendation" 92 + ), 93 + type: "error", 94 + }); 95 + return; 41 96 } 97 + 98 + // Update the SWR cache to match the new state 99 + mutateRecommendation(props.documentUri, !currentlyRecommended); 100 + setOptimisticRecommended(null); 101 + setIsPending(false); 42 102 }; 43 103 44 104 const showCount = props.showCount !== false; ··· 50 110 e.stopPropagation(); 51 111 handleClick(); 52 112 }} 53 - disabled={isPending} 113 + disabled={isPending || isLoading} 54 114 className={`recommendButton flex gap-1 items-center hover:text-accent-contrast ${props.className || ""}`} 55 - aria-label={hasRecommended ? "Remove recommend" : "Recommend"} 115 + aria-label={displayRecommended ? "Remove recommend" : "Recommend"} 56 116 > 57 - {hasRecommended ? ( 117 + {displayRecommended ? ( 58 118 <RecommendTinyFilled className="text-accent-contrast" /> 59 119 ) : ( 60 120 <RecommendTinyEmpty /> 61 121 )} 62 122 {showCount && count > 0 && ( 63 - <span className={`${hasRecommended && "text-accent-contrast"}`}> 123 + <span className={`${displayRecommended && "text-accent-contrast"}`}> 64 124 {count} 65 125 </span> 66 126 )}
-1
contexts/DocumentContext.tsx
··· 22 22 | "mentions" 23 23 | "leafletId" 24 24 | "recommendsCount" 25 - | "hasRecommended" 26 25 >; 27 26 28 27 const DocumentContext = createContext<DocumentContextValue | null>(null);
+16
package-lock.json
··· 38 38 "@vercel/analytics": "^1.5.0", 39 39 "@vercel/functions": "^2.2.12", 40 40 "@vercel/sdk": "^1.11.4", 41 + "@yornaath/batshit": "^0.14.0", 41 42 "babel-plugin-react-compiler": "^19.1.0-rc.1", 42 43 "base64-js": "^1.5.1", 43 44 "colorjs.io": "^0.5.2", ··· 8499 8500 "optional": true 8500 8501 } 8501 8502 } 8503 + }, 8504 + "node_modules/@yornaath/batshit": { 8505 + "version": "0.14.0", 8506 + "resolved": "https://registry.npmjs.org/@yornaath/batshit/-/batshit-0.14.0.tgz", 8507 + "integrity": "sha512-0I+xMi5JoRs3+qVXXhk2AmsEl43MwrG+L+VW+nqw/qQqMFtgRPszLaxhJCfsBKnjfJ0gJzTI1Q9Q9+y903HyHQ==", 8508 + "license": "MIT", 8509 + "dependencies": { 8510 + "@yornaath/batshit-devtools": "^1.7.1" 8511 + } 8512 + }, 8513 + "node_modules/@yornaath/batshit-devtools": { 8514 + "version": "1.7.1", 8515 + "resolved": "https://registry.npmjs.org/@yornaath/batshit-devtools/-/batshit-devtools-1.7.1.tgz", 8516 + "integrity": "sha512-AyttV1Njj5ug+XqEWY1smV45dTWMlWKtj1B8jcFYgBKUFyUlF/qEhD+iP1E5UaRYW6hQRYD9T2WNDwFTrOMWzQ==", 8517 + "license": "MIT" 8502 8518 }, 8503 8519 "node_modules/abort-controller": { 8504 8520 "version": "3.0.0",
+1
package.json
··· 49 49 "@vercel/analytics": "^1.5.0", 50 50 "@vercel/functions": "^2.2.12", 51 51 "@vercel/sdk": "^1.11.4", 52 + "@yornaath/batshit": "^0.14.0", 52 53 "babel-plugin-react-compiler": "^19.1.0-rc.1", 53 54 "base64-js": "^1.5.1", 54 55 "colorjs.io": "^0.5.2",