a tool for shared writing and social publishing
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}