a tool for shared writing and social publishing
at feature/recommend 173 lines 5.2 kB view raw
1"use client"; 2 3import { useState } from "react"; 4import useSWR, { mutate } from "swr"; 5import { create, windowScheduler } from "@yornaath/batshit"; 6import { RecommendTinyEmpty, RecommendTinyFilled } from "./Icons/RecommendTiny"; 7import { 8 recommendAction, 9 unrecommendAction, 10} from "app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction"; 11import { callRPC } from "app/api/rpc/client"; 12import { useSmoker, useToaster } from "./Toast"; 13import { OAuthErrorMessage, isOAuthSessionError } from "./OAuthError"; 14import { ButtonSecondary } from "./Buttons"; 15import { Separator } from "./Layout"; 16 17// Create a batcher for recommendation checks 18// Batches requests made within 10ms window 19const recommendationBatcher = create({ 20 fetcher: async (documentUris: string[]) => { 21 const response = await callRPC("get_user_recommendations", { 22 documentUris, 23 }); 24 return response.result; 25 }, 26 resolver: (results, documentUri) => results[documentUri] ?? false, 27 scheduler: windowScheduler(10), 28}); 29 30const getRecommendationKey = (documentUri: string) => 31 `recommendation:${documentUri}`; 32 33function useUserRecommendation(documentUri: string) { 34 const { data: hasRecommended, isLoading } = useSWR( 35 getRecommendationKey(documentUri), 36 () => recommendationBatcher.fetch(documentUri), 37 ); 38 39 return { 40 hasRecommended: hasRecommended ?? false, 41 isLoading, 42 }; 43} 44 45function mutateRecommendation(documentUri: string, hasRecommended: boolean) { 46 mutate(getRecommendationKey(documentUri), hasRecommended, { 47 revalidate: false, 48 }); 49} 50 51/** 52 * RecommendButton that fetches the user's recommendation status asynchronously. 53 * Uses SWR with batched requests for efficient fetching when many buttons are rendered. 54 */ 55export function RecommendButton(props: { 56 documentUri: string; 57 recommendsCount: number; 58 className?: string; 59 expanded?: boolean; 60}) { 61 const { hasRecommended, isLoading } = useUserRecommendation( 62 props.documentUri, 63 ); 64 const [count, setCount] = useState(props.recommendsCount); 65 const [isPending, setIsPending] = useState(false); 66 const [optimisticRecommended, setOptimisticRecommended] = useState< 67 boolean | null 68 >(null); 69 const toaster = useToaster(); 70 const smoker = useSmoker(); 71 72 // Use optimistic state if set, otherwise use fetched state 73 const displayRecommended = 74 optimisticRecommended !== null ? optimisticRecommended : hasRecommended; 75 76 const handleClick = async (e: React.MouseEvent) => { 77 if (isPending || isLoading) return; 78 79 const currentlyRecommended = displayRecommended; 80 setIsPending(true); 81 setOptimisticRecommended(!currentlyRecommended); 82 setCount((c) => (currentlyRecommended ? c - 1 : c + 1)); 83 84 if (!currentlyRecommended) { 85 smoker({ 86 position: { 87 x: e.clientX, 88 y: e.clientY - 16, 89 }, 90 text: <div className="text-xs">Recc'd!</div>, 91 }); 92 } 93 94 const result = currentlyRecommended 95 ? await unrecommendAction({ document: props.documentUri }) 96 : await recommendAction({ document: props.documentUri }); 97 if (!result.success) { 98 // Revert optimistic update 99 setOptimisticRecommended(null); 100 setCount((c) => (currentlyRecommended ? c + 1 : c - 1)); 101 setIsPending(false); 102 103 toaster({ 104 content: isOAuthSessionError(result.error) ? ( 105 <OAuthErrorMessage error={result.error} /> 106 ) : ( 107 "oh no! error!" 108 ), 109 type: "error", 110 }); 111 return; 112 } 113 114 // Update the SWR cache to match the new state 115 mutateRecommendation(props.documentUri, !currentlyRecommended); 116 setOptimisticRecommended(null); 117 setIsPending(false); 118 }; 119 120 if (props.expanded) 121 return ( 122 <ButtonSecondary 123 onClick={(e) => { 124 e.preventDefault(); 125 e.stopPropagation(); 126 handleClick(e); 127 }} 128 > 129 {displayRecommended ? ( 130 <RecommendTinyFilled className="text-accent-contrast" /> 131 ) : ( 132 <RecommendTinyEmpty /> 133 )} 134 <div className="flex gap-2 items-center"> 135 {count > 0 && ( 136 <> 137 <span 138 className={`${displayRecommended && "text-accent-contrast"}`} 139 > 140 {count} 141 </span> 142 <Separator classname="h-4! text-accent-contrast!" /> 143 </> 144 )} 145 {displayRecommended ? "Recommended!" : "Recommend"} 146 </div> 147 </ButtonSecondary> 148 ); 149 150 return ( 151 <button 152 onClick={(e) => { 153 e.preventDefault(); 154 e.stopPropagation(); 155 handleClick(e); 156 }} 157 disabled={isPending || isLoading} 158 className={`recommendButton relative flex gap-1 items-center hover:text-accent-contrast ${props.className || ""}`} 159 aria-label={displayRecommended ? "Remove recommend" : "Recommend"} 160 > 161 {displayRecommended ? ( 162 <RecommendTinyFilled className="text-accent-contrast" /> 163 ) : ( 164 <RecommendTinyEmpty /> 165 )} 166 {count > 0 && ( 167 <span className={`${displayRecommended && "text-accent-contrast"}`}> 168 {count} 169 </span> 170 )} 171 </button> 172 ); 173}