Hey is a decentralized and permissionless social media app built with Lens Protocol 馃尶
1import { useApolloClient } from "@apollo/client";
2import { HEY_TREASURY, NATIVE_TOKEN_SYMBOL } from "@hey/data/constants";
3import {
4 type AccountFragment,
5 type PostFragment,
6 type TippingAmountInput,
7 useBalancesBulkQuery,
8 useExecuteAccountActionMutation,
9 useExecutePostActionMutation
10} from "@hey/indexer";
11import type { ApolloClientError } from "@hey/types/errors";
12import type { ChangeEvent, RefObject } from "react";
13import { memo, useCallback, useRef, useState } from "react";
14import { toast } from "sonner";
15import TopUpButton from "@/components/Shared/Account/TopUp/Button";
16import LoginButton from "@/components/Shared/LoginButton";
17import Skeleton from "@/components/Shared/Skeleton";
18import { Button, Input, Spinner } from "@/components/Shared/UI";
19import cn from "@/helpers/cn";
20import errorToast from "@/helpers/errorToast";
21import usePreventScrollOnNumberInput from "@/hooks/usePreventScrollOnNumberInput";
22import useTransactionLifecycle from "@/hooks/useTransactionLifecycle";
23import { useAccountStore } from "@/store/persisted/useAccountStore";
24
25const submitButtonClassName = "w-full py-1.5 text-sm font-semibold";
26
27interface TipMenuProps {
28 closePopover: () => void;
29 post?: PostFragment;
30 account?: AccountFragment;
31}
32
33const TipMenu = ({ closePopover, post, account }: TipMenuProps) => {
34 const { currentAccount } = useAccountStore();
35 const [isSubmitting, setIsSubmitting] = useState(false);
36 const [amount, setAmount] = useState(1);
37 const [other, setOther] = useState(false);
38 const handleTransactionLifecycle = useTransactionLifecycle();
39 const { cache } = useApolloClient();
40 const inputRef = useRef<HTMLInputElement>(null);
41 usePreventScrollOnNumberInput(inputRef as RefObject<HTMLInputElement>);
42
43 const { data: balance, loading: balanceLoading } = useBalancesBulkQuery({
44 fetchPolicy: "no-cache",
45 pollInterval: 3000,
46 skip: !currentAccount?.address,
47 variables: {
48 request: { address: currentAccount?.address, includeNative: true }
49 }
50 });
51
52 const updateCache = () => {
53 if (post) {
54 if (!post.operations) {
55 return;
56 }
57
58 cache.modify({
59 fields: { hasTipped: () => true },
60 id: cache.identify(post.operations)
61 });
62 cache.modify({
63 fields: {
64 stats: (existingData) => ({
65 ...existingData,
66 tips: existingData.tips + 1
67 })
68 },
69 id: cache.identify(post)
70 });
71 }
72 };
73
74 const onCompleted = () => {
75 setIsSubmitting(false);
76 closePopover();
77 updateCache();
78 toast.success(`Tipped ${amount} ${NATIVE_TOKEN_SYMBOL}`);
79 };
80
81 const onError = useCallback((error: ApolloClientError) => {
82 setIsSubmitting(false);
83 errorToast(error);
84 }, []);
85
86 const cryptoRate = Number(amount);
87 const nativeBalance =
88 balance?.balancesBulk[0].__typename === "NativeAmount"
89 ? Number(balance.balancesBulk[0].value).toFixed(2)
90 : 0;
91 const canTip = Number(nativeBalance) >= cryptoRate;
92
93 const [executePostAction] = useExecutePostActionMutation({
94 onCompleted: async ({ executePostAction }) => {
95 if (executePostAction.__typename === "ExecutePostActionResponse") {
96 return onCompleted();
97 }
98
99 return await handleTransactionLifecycle({
100 onCompleted,
101 onError,
102 transactionData: executePostAction
103 });
104 },
105 onError
106 });
107
108 const [executeAccountAction] = useExecuteAccountActionMutation({
109 onCompleted: async ({ executeAccountAction }) => {
110 if (executeAccountAction.__typename === "ExecuteAccountActionResponse") {
111 return onCompleted();
112 }
113
114 return await handleTransactionLifecycle({
115 onCompleted,
116 onError,
117 transactionData: executeAccountAction
118 });
119 },
120 onError
121 });
122
123 const handleSetAmount = (amount: number) => {
124 setAmount(amount);
125 setOther(false);
126 };
127
128 const onOtherAmount = (event: ChangeEvent<HTMLInputElement>) => {
129 const value = Number(event.target.value);
130 setAmount(value);
131 };
132
133 const handleTip = async () => {
134 setIsSubmitting(true);
135
136 const tipping: TippingAmountInput = {
137 native: cryptoRate.toString(),
138 // 11 is a calculated value based on the referral pool of 20% and the Lens fee of 2.1% after the 1.5% lens fees cut
139 referrals: [{ address: HEY_TREASURY, percent: 11 }]
140 };
141
142 if (post) {
143 return executePostAction({
144 variables: { request: { action: { tipping }, post: post.id } }
145 });
146 }
147
148 if (account) {
149 return executeAccountAction({
150 variables: {
151 request: { account: account.address, action: { tipping } }
152 }
153 });
154 }
155 };
156
157 const amountDisabled = isSubmitting || !currentAccount;
158
159 if (!currentAccount) {
160 return <LoginButton className="m-5" title="Login to Tip" />;
161 }
162
163 return (
164 <div className="m-5 space-y-3">
165 <div className="space-y-2">
166 <div className="flex items-center space-x-1 text-gray-500 text-xs dark:text-gray-200">
167 <span>Balance:</span>
168 <span>
169 {nativeBalance ? (
170 `${nativeBalance} ${NATIVE_TOKEN_SYMBOL}`
171 ) : (
172 <Skeleton className="h-2.5 w-14 rounded-full" />
173 )}
174 </span>
175 </div>
176 </div>
177 <div className="space-x-4">
178 <Button
179 disabled={amountDisabled}
180 onClick={() => handleSetAmount(1)}
181 outline={amount !== 1}
182 size="sm"
183 >
184 $1
185 </Button>
186 <Button
187 disabled={amountDisabled}
188 onClick={() => handleSetAmount(2)}
189 outline={amount !== 2}
190 size="sm"
191 >
192 $2
193 </Button>
194 <Button
195 disabled={amountDisabled}
196 onClick={() => handleSetAmount(5)}
197 outline={amount !== 5}
198 size="sm"
199 >
200 $5
201 </Button>
202 <Button
203 disabled={amountDisabled}
204 onClick={() => {
205 handleSetAmount(other ? 1 : 10);
206 setOther(!other);
207 }}
208 outline={!other}
209 size="sm"
210 >
211 Other
212 </Button>
213 </div>
214 {other ? (
215 <div>
216 <Input
217 className="no-spinner"
218 max={1000}
219 min={0}
220 onChange={onOtherAmount}
221 placeholder="300"
222 ref={inputRef}
223 type="number"
224 value={amount}
225 />
226 </div>
227 ) : null}
228 {isSubmitting || balanceLoading ? (
229 <Button
230 className={cn("flex justify-center", submitButtonClassName)}
231 disabled
232 icon={<Spinner className="my-0.5" size="xs" />}
233 />
234 ) : canTip ? (
235 <Button
236 className={submitButtonClassName}
237 disabled={!amount || isSubmitting || !canTip}
238 onClick={handleTip}
239 >
240 <b>Tip ${amount}</b>
241 </Button>
242 ) : (
243 <TopUpButton
244 amountToTopUp={Math.ceil((amount - Number(nativeBalance)) * 20) / 20}
245 className="w-full"
246 />
247 )}
248 </div>
249 );
250};
251
252export default memo(TipMenu);