diff --git a/.github/workflows/u3-firebase-hosting-merge.yml b/.github/workflows/u3-firebase-hosting-merge.yml index 1aabecbd..5e29e5d1 100644 --- a/.github/workflows/u3-firebase-hosting-merge.yml +++ b/.github/workflows/u3-firebase-hosting-merge.yml @@ -7,6 +7,7 @@ name: Deploy u3 to Firebase Hosting on merge branches: - u3 - u3-dev + - u3-pwa jobs: prod_build_and_deploy: if: github.ref == 'refs/heads/u3' @@ -46,6 +47,7 @@ jobs: REACT_APP_CASTER_NFT_FIXED_PRICE_STRATEGY: "${{ vars.REACT_APP_CASTER_NFT_FIXED_PRICE_STRATEGY }}" REACT_APP_CASTER_NFT_CHAIN_ID: "${{ vars.REACT_APP_CASTER_NFT_CHAIN_ID }}" REACT_APP_CASTER_NFT_RECIPIENT_ADDRESS: "${{ vars.REACT_APP_CASTER_NFT_RECIPIENT_ADDRESS }}" + REACT_APP_VAPID_PUBLIC_KEY: "${{ vars.REACT_APP_VAPID_PUBLIC_KEY }}" - uses: FirebaseExtended/action-hosting-deploy@v0 with: repoToken: "${{ secrets.GITHUB_TOKEN }}" @@ -92,6 +94,7 @@ jobs: REACT_APP_CASTER_NFT_FIXED_PRICE_STRATEGY: "${{ vars.REACT_APP_CASTER_NFT_FIXED_PRICE_STRATEGY }}" REACT_APP_CASTER_NFT_CHAIN_ID: "${{ vars.REACT_APP_CASTER_NFT_CHAIN_ID }}" REACT_APP_CASTER_NFT_RECIPIENT_ADDRESS: "${{ vars.REACT_APP_CASTER_NFT_RECIPIENT_ADDRESS }}" + REACT_APP_VAPID_PUBLIC_KEY: "${{ vars.REACT_APP_VAPID_PUBLIC_KEY }}" - uses: FirebaseExtended/action-hosting-deploy@v0 with: repoToken: "${{ secrets.GITHUB_TOKEN }}" @@ -100,3 +103,50 @@ jobs: projectId: us3r-network target: u3-dev entryPoint: "./apps/u3/" + pwa_build_and_deploy: + if: github.ref == 'refs/heads/u3-pwa' + runs-on: ubuntu-latest + environment: + name: development + url: https://dev.u3.xyz + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: "20" + - run: | + cd apps/u3 + yarn install --ignore-engines + yarn build + env: + CI: false + REACT_APP_NAME: "U3 DEV" + NODE_OPTIONS: "--max_old_space_size=4096" + REACT_APP_API_BASE_URL: "${{ vars.REACT_APP_API_BASE_URL }}" + REACT_APP_S3_API_BASE_URL: "${{ vars.REACT_APP_S3_API_BASE_URL }}" + REACT_APP_US3R_UPLOAD_IMAGE_ENDPOINT: "${{ vars.REACT_APP_US3R_UPLOAD_IMAGE_ENDPOINT }}" + REACT_APP_CERAMIC_HOST: "${{ vars.REACT_APP_CERAMIC_HOST }}" + REACT_APP_CHROME_EXTENSION_URL: "${{ vars.REACT_APP_CHROME_EXTENSION_URL }}" + REACT_APP_API_SOCIAL_URL: "${{ vars.REACT_APP_API_SOCIAL_URL }}" + REACT_APP_XMTP_ENV: "${{ vars.REACT_APP_XMTP_ENV }}" + REACT_APP_LENS_ENV: "${{ vars.REACT_APP_LENS_ENV }}" + REACT_APP_FARCASTER_HUB_URL: "${{ vars.REACT_APP_FARCASTER_HUB_URL }}" + REACT_APP_FARCASTER_NETWORK: "${{ vars.REACT_APP_FARCASTER_NETWORK }}" + REACT_APP_NFT_STORAGE_API_KEY: "${{ vars.REACT_APP_NFT_STORAGE_API_KEY }}" + REACT_APP_DAPP_NFT_TO_MINT: "${{ vars.REACT_APP_DAPP_NFT_TO_MINT }}" + REACT_APP_DAPP_NFT_FIXED_PRICE_STRATEGY: "${{ vars.REACT_APP_DAPP_NFT_FIXED_PRICE_STRATEGY }}" + REACT_APP_DAPP_NFT_CHAIN_ID: "${{ vars.REACT_APP_DAPP_NFT_CHAIN_ID }}" + REACT_APP_DAPP_NFT_RECIPIENT_ADDRESS: "${{ vars.REACT_APP_DAPP_NFT_RECIPIENT_ADDRESS }}" + REACT_APP_CASTER_NFT_TO_MINT: "${{ vars.REACT_APP_CASTER_NFT_TO_MINT }}" + REACT_APP_CASTER_NFT_FIXED_PRICE_STRATEGY: "${{ vars.REACT_APP_CASTER_NFT_FIXED_PRICE_STRATEGY }}" + REACT_APP_CASTER_NFT_CHAIN_ID: "${{ vars.REACT_APP_CASTER_NFT_CHAIN_ID }}" + REACT_APP_CASTER_NFT_RECIPIENT_ADDRESS: "${{ vars.REACT_APP_CASTER_NFT_RECIPIENT_ADDRESS }}" + REACT_APP_VAPID_PUBLIC_KEY: "${{ vars.REACT_APP_VAPID_PUBLIC_KEY }}" + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: "${{ secrets.GITHUB_TOKEN }}" + firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_US3R_NETWORK }}" + channelId: live + projectId: us3r-network + target: u3-pwa + entryPoint: "./apps/u3/" diff --git a/apps/u3/.env.development b/apps/u3/.env.development index 6a2753a9..d692e4b0 100644 --- a/apps/u3/.env.development +++ b/apps/u3/.env.development @@ -60,3 +60,5 @@ REACT_APP_CASTER_NFT_RECIPIENT_ADDRESS = 0x885d0069e238C7929F0351689A9493fECad9 # REACT_APP_CASTER_NFT_FIXED_PRICE_STRATEGY = 0x04E2516A2c207E84a1839755675dfd8eF6302F0a # REACT_APP_CASTER_NFT_CHAIN_ID = 999 # REACT_APP_CASTER_NFT_RECIPIENT_ADDRESS = 0x885d0069e238C7929F0351689A9493fECad952Fe + +REACT_APP_VAPID_PUBLIC_KEY = BGMGngE6KTyCIbeBwtNLSObcu0IZM_QH8PVd4c4M5trYPonjjXGDM3aIhqFLIWFRyEF7XiHGB0CBcclsQTSWJoM \ No newline at end of file diff --git a/apps/u3/.firebaserc b/apps/u3/.firebaserc index 56c2060d..5e26d933 100644 --- a/apps/u3/.firebaserc +++ b/apps/u3/.firebaserc @@ -10,6 +10,9 @@ ], "u3-dev": [ "us3r-u3-dev" + ], + "u3-pwa": [ + "us3r-u3-pwa" ] } } diff --git a/apps/u3/.vscode/settings.json b/apps/u3/.vscode/settings.json index c6f336cf..6fdb18f0 100644 --- a/apps/u3/.vscode/settings.json +++ b/apps/u3/.vscode/settings.json @@ -21,6 +21,7 @@ "unfollowed", "unpinup", "upsert", + "Upvote", "viem", "Warpcast", "Whatsnew", diff --git a/apps/u3/firebase.json b/apps/u3/firebase.json index 1fb7bb29..8ed4d836 100644 --- a/apps/u3/firebase.json +++ b/apps/u3/firebase.json @@ -29,6 +29,21 @@ "destination": "/index.html" } ] + }, + { + "target": "u3-pwa", + "public": "build", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] } ] } diff --git a/apps/u3/package.json b/apps/u3/package.json index e9f389b0..761378c0 100644 --- a/apps/u3/package.json +++ b/apps/u3/package.json @@ -20,11 +20,13 @@ "@noble/ed25519": "^2.0.0", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", "@rainbow-me/rainbowkit": "^1.3.1", "@react-spring/web": "^9.6.1", @@ -122,7 +124,7 @@ }, "scripts": { "start": "craco start", - "build": "craco build", + "build": "craco build NODE_OPTIONS=--max-old-space-size=4096", "build:report": "craco build --report", "test": "craco test", "eject": "react-scripts eject", diff --git a/apps/u3/public/service-worker-dev.js b/apps/u3/public/service-worker-dev.js new file mode 100644 index 00000000..1ffc7a76 --- /dev/null +++ b/apps/u3/public/service-worker-dev.js @@ -0,0 +1,12 @@ +/* eslint-disable */ + +// listen for push event +self.addEventListener('push', (event) => { + let { title, body, icon } = event.data.json(); + if (!title || title === 'undefined') title = 'U3 - Your Web3 Gateway'; + if (!body) return; + self.registration.showNotification(title, { + body, + icon: icon || `logo192.png`, + }); +}); diff --git a/apps/u3/public/service-worker.js b/apps/u3/public/service-worker.js new file mode 100644 index 00000000..1ffc7a76 --- /dev/null +++ b/apps/u3/public/service-worker.js @@ -0,0 +1,12 @@ +/* eslint-disable */ + +// listen for push event +self.addEventListener('push', (event) => { + let { title, body, icon } = event.data.json(); + if (!title || title === 'undefined') title = 'U3 - Your Web3 Gateway'; + if (!body) return; + self.registration.showNotification(title, { + body, + icon: icon || `logo192.png`, + }); +}); diff --git a/apps/u3/src/components/common/icons/DegenTip.tsx b/apps/u3/src/components/common/icons/DegenTip.tsx new file mode 100644 index 00000000..1994409f --- /dev/null +++ b/apps/u3/src/components/common/icons/DegenTip.tsx @@ -0,0 +1,25 @@ +export default function DegenTip({ className }: { className: string }) { + return ( + + + + + ); +} diff --git a/apps/u3/src/components/notification/NotificationModal.tsx b/apps/u3/src/components/notification/NotificationModal.tsx index f88877f9..2fda6759 100644 --- a/apps/u3/src/components/notification/NotificationModal.tsx +++ b/apps/u3/src/components/notification/NotificationModal.tsx @@ -17,6 +17,7 @@ import LensIcon from '../common/icons/LensIcon'; import FarcasterIcon from '../common/icons/FarcasterIcon'; import Loading from '../common/loading/Loading'; import { useNav } from '../../contexts/NavCtx'; +// import { NotificationSettingsGroup } from './PushNotificationsToogleBtn'; export default function NotificationModal() { const { openNotificationModal, setOpenNotificationModal } = useNav(); @@ -27,6 +28,9 @@ export default function NotificationModal() {
Notifications + {/*
+ +
*/} setOpenNotificationModal(false)} />
{notifications && notifications.length > 0 && ( diff --git a/apps/u3/src/components/notification/PushNotificationsToogleBtn.tsx b/apps/u3/src/components/notification/PushNotificationsToogleBtn.tsx new file mode 100644 index 00000000..2fae5858 --- /dev/null +++ b/apps/u3/src/components/notification/PushNotificationsToogleBtn.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from 'react'; +import { toast } from 'react-toastify'; +import WebPushService from '@/utils/pwa/WebPushService'; +import { sendNotification } from '@/utils/pwa/notification'; +import { + NotificationSetting, + NotificationSettingType, +} from '@/services/notification/types/notification-settings'; +import useLogin from '@/hooks/shared/useLogin'; +import { + addNotificationSetting, + fethNotificationSettings, + updateNotificationSetting, +} from '@/services/notification/api/notification-settings'; +import { ApiRespCode } from '@/services/shared/types'; +import { useFarcasterCtx } from '@/contexts/social/FarcasterCtx'; +import Switch from '../common/switch/Switch'; +import ColorButton from '../common/button/ColorButton'; + +export function NotificationSettingsGroup() { + const { + currFid, + isConnected: isLoginFarcaster, + currUserInfo: farcasterUserInfo, + openFarcasterQR, + } = useFarcasterCtx(); + const { isLogin } = useLogin(); + const [settings, setSettings] = useState([]); + const [loadingTypes, setLoadingTypes] = useState( + [] + ); + const [settingsLoading, setSettingsLoading] = useState(true); + useEffect(() => { + if (isLogin) { + setSettingsLoading(true); + fethNotificationSettings() + .then((res) => { + setSettings(res?.data?.data || []); + }) + .catch((err) => { + console.log(err); + setSettings([]); + }) + .finally(() => { + setSettingsLoading(false); + }); + } else { + setSettings([]); + setSettingsLoading(false); + } + }, [isLogin]); + + const upsertSetting = async (setting: Partial) => { + if (!isLogin) return; + const index = settings.findIndex((s) => s.type === setting.type); + + try { + setLoadingTypes((prev) => { + if (prev.includes(setting.type)) return prev; + return [...prev, setting.type]; + }); + if (index >= 0 && settings[index]?.id) { + // update + const res = await updateNotificationSetting(settings[index].id, { + ...settings[index], + ...setting, + }); + if (res.data.code === ApiRespCode.SUCCESS) { + setSettings((prev) => { + return [ + ...prev.slice(0, index), + { + ...prev[index], + ...setting, + }, + ...prev.slice(index + 1), + ] as NotificationSetting[]; + }); + } + } else { + // add + const res = await addNotificationSetting({ + type: setting.type, + ...setting, + }); + if (res.data.code === ApiRespCode.SUCCESS) { + setSettings((prev) => { + return [...prev, res.data.data] as NotificationSetting[]; + }); + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + throw error; + } finally { + setLoadingTypes((prev) => { + if (prev.includes(setting.type)) { + return prev.filter((t) => t !== setting.type); + } + return prev; + }); + } + }; + + const webpushSubscribed = settings.some( + (setting) => + setting.type === NotificationSettingType.WEB_PUSH && + setting?.enable === true && + !!setting?.subscription + ); + + const webpushLoading = loadingTypes.includes( + NotificationSettingType.WEB_PUSH + ); + const webpushDisabled = settingsLoading || webpushLoading; + + const handlePushChange = async (checked: boolean) => { + try { + if (!checked) { + const payload = await WebPushService.unsubscribe(); + await upsertSetting({ + type: NotificationSettingType.WEB_PUSH, + fid: currFid ? String(currFid) : undefined, + enable: false, + subscription: payload ? JSON.stringify(payload) : undefined, + }); + } else { + if (!WebPushService.hasPermission()) { + await WebPushService.requestPermission(); + } + let subscription = await WebPushService.getSubscription(); + if (!subscription) { + subscription = await WebPushService.subscribe(); + } + + await upsertSetting({ + type: NotificationSettingType.WEB_PUSH, + fid: currFid ? String(currFid) : undefined, + enable: true, + subscription: JSON.stringify(subscription), + }); + sendNotification(`Subscribed to notifications`); + } + } catch (error) { + toast.error(error.message); + console.error(error); + } + }; + + return ( + //
+ //

Web Push

+ <> + + {(() => { + if (webpushLoading) { + if (webpushSubscribed) { + return 'Unsubscribing...'; + } + return 'Subscribing...'; + } + return 'Subscribe Notifications'; + })()} + + {/* {!(isLoginFarcaster && farcasterUserInfo) && ( + openFarcasterQR()} + > + Login Farcaster + + )} */} + + //
+ ); +} diff --git a/apps/u3/src/components/poster/gallery/GalleryItem.tsx b/apps/u3/src/components/poster/gallery/GalleryItem.tsx index 4117a1e4..3974e74e 100644 --- a/apps/u3/src/components/poster/gallery/GalleryItem.tsx +++ b/apps/u3/src/components/poster/gallery/GalleryItem.tsx @@ -44,7 +44,10 @@ export default function GalleryItem({ } = useCasterTokenInfoWithTokenId({ tokenId: Number(tokenId), }); - const saleStautsWithPoster = getSaleStatus(data.saleStart, data.saleEnd); + const saleStautsWithPoster = getSaleStatus( + Number(data.saleStart), + Number(data.saleEnd) + ); const saleStatus = data?.saleStart && data?.saleEnd ? saleStautsWithPoster diff --git a/apps/u3/src/components/social/Embed.tsx b/apps/u3/src/components/social/Embed.tsx index e8077447..0ba78e93 100644 --- a/apps/u3/src/components/social/Embed.tsx +++ b/apps/u3/src/components/social/Embed.tsx @@ -11,6 +11,7 @@ import { import dayjs from 'dayjs'; import { toHex } from 'viem'; import { toast } from 'react-toastify'; +import { Cross2Icon, CaretLeftIcon } from '@radix-ui/react-icons'; import { FarCast, FarCastEmbedMeta, @@ -21,6 +22,7 @@ import { getFarcasterEmbedCast, getFarcasterEmbedMetadata, postFrameActionApi, + postFrameActionRedirectApi, } from '../../services/social/api/farcaster'; import ModalImg from './ModalImg'; import U3ZoraMinter from './farcaster/U3ZoraMinter'; @@ -29,6 +31,8 @@ import ColorButton from '../common/button/ColorButton'; import { useFarcasterCtx } from '@/contexts/social/FarcasterCtx'; import { FARCASTER_NETWORK } from '@/constants/farcaster'; import useFarcasterCastId from '@/hooks/social/farcaster/useFarcasterCastId'; +import ModalContainerFixed from '../common/modal/ModalContainerFixed'; +import { cn } from '@/lib/utils'; const ValidFrameButtonValue = [ [0, 0, 0, 0].join(''), @@ -172,6 +176,7 @@ function EmbedCastFrame({ const castId: CastId = useFarcasterCastId({ cast }); const { encryptedSigner, isConnected, currFid } = useFarcasterCtx(); + const [frameRedirect, setFrameRedirect] = useState(''); const [frameData, setFrameData] = useState(data); const postFrameAction = useCallback( @@ -186,10 +191,9 @@ function EmbedCastFrame({ toast.error('no encryptedSigner'); return; } - const url = data.fcFramePostUrl || data.url; const trustedDataResult = await makeFrameAction( { - url: Buffer.from(url), + url: Buffer.from(data.url), buttonIndex: index, castId, }, @@ -202,10 +206,11 @@ function EmbedCastFrame({ if (trustedDataResult.isErr()) { throw new Error(trustedDataResult.error.message); } + const trustedDataValue = trustedDataResult.value; const untrustedData = { fid: currFid, - url, + url: frameData.fcFramePostUrl || frameData.url, messageHash: toHex(trustedDataValue.hash), network: FARCASTER_NETWORK, buttonIndex: index, @@ -220,49 +225,131 @@ function EmbedCastFrame({ ).toString('hex'), }; const postData = { + actionUrl: frameData.fcFramePostUrl || frameData.url, untrustedData, trustedData, }; - const resp = await postFrameActionApi(postData); - if (resp.data.code !== 0) { - toast.error(resp.data.msg); - return; + const buttonAction = frameData[`fcFrameButton${index}Action`] || 'post'; + console.log('buttonAction', buttonAction); + if (buttonAction === 'post') { + const resp = await postFrameActionApi(postData); + if (resp.data.code !== 0) { + toast.error(resp.data.msg); + return; + } + setFrameData(resp.data.data?.metadata); + } else if (buttonAction === 'post_redirect') { + const resp = await postFrameActionRedirectApi(postData); + if (resp.data.code !== 0) { + toast.error(resp.data.msg); + return; + } + setFrameRedirect(resp.data.data?.redirectUrl || ''); } - setFrameData(resp.data.data?.metadata); }, [frameData, currFid, encryptedSigner, castId] ); return ( -
-
- -
- {isConnected && ( -
- {[ - frameData.fcFrameButton1, - frameData.fcFrameButton2, - frameData.fcFrameButton3, - frameData.fcFrameButton4, - ].map((item, idx) => { - if (!item) return null; - return ( - { - e.stopPropagation(); - postFrameAction(idx + 1); - }} - > - {item} - - ); - })} + <> +
+
+
+ {isConnected && ( +
+ {[ + frameData.fcFrameButton1, + frameData.fcFrameButton2, + frameData.fcFrameButton3, + frameData.fcFrameButton4, + ].map((item, idx) => { + if (!item) return null; + return ( + { + e.stopPropagation(); + postFrameAction(idx + 1); + }} + > + {item} + + ); + })} +
+ )} +
+ {frameRedirect && ( + { + setFrameRedirect(''); + }} + /> )} -
+ + ); +} + +function EmbedCastFrameRedirect({ + url, + resetUrl, +}: { + url: string; + resetUrl: () => void; +}) { + return ( + { + resetUrl(); + }} + className="w-full md:w-[420px]" + > +
{ + e.stopPropagation(); + }} + > +
+

⚠️ Leaving u3

+ +
+

+ You are about to leave u3, please connect your wallet carefully and + take care of your funds. +

+
+ + +
+
+
); } diff --git a/apps/u3/src/components/social/farcaster/FCastTips.tsx b/apps/u3/src/components/social/farcaster/FCastTips.tsx index fdb53886..8fcfeaca 100644 --- a/apps/u3/src/components/social/farcaster/FCastTips.tsx +++ b/apps/u3/src/components/social/farcaster/FCastTips.tsx @@ -1,6 +1,8 @@ -import { useCallback, useState } from 'react'; +/* eslint-disable no-underscore-dangle */ +import { useCallback, useEffect, useState } from 'react'; import { Cross2Icon } from '@radix-ui/react-icons'; import { useAccount, useBalance, useNetwork } from 'wagmi'; +import { makeCastAdd } from '@farcaster/hub-web'; import { useConnectModal } from '@rainbow-me/rainbowkit'; import { prepareWriteContract, @@ -16,11 +18,20 @@ import { UserData } from '@/utils/social/farcaster/user-data'; import ModalContainer from '@/components/common/modal/ModalContainer'; import { cn } from '@/lib/utils'; import useLogin from '@/hooks/shared/useLogin'; -import { getUserinfoWithFid } from '@/services/social/api/farcaster'; +import { + getUserDegenTipAllowance, + getUserinfoWithFid, + notifyTipApi, +} from '@/services/social/api/farcaster'; import { shortPubKey } from '@/utils/shared/shortPubKey'; import Loading from '@/components/common/loading/Loading'; import { DegenABI, DegenAddress } from '@/services/social/abi/degen/contract'; import { FarCast } from '@/services/social/types'; +import DegenTip from '@/components/common/icons/DegenTip'; +import { useFarcasterCtx } from '@/contexts/social/FarcasterCtx'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { FARCASTER_NETWORK, FARCASTER_WEB_CLIENT } from '@/constants/farcaster'; +import { Checkbox } from '@/components/ui/checkbox'; export default function FCastTips({ userData, @@ -37,18 +48,63 @@ export default function FCastTips({ address: '', fname: '', }); + const { currFid, encryptedSigner } = useFarcasterCtx(); + const [allowance, setAllowance] = useState('0'); const [loading, setLoading] = useState(false); + const loadUserinfo = useCallback(async () => { try { setLoading(true); + const { data: allowanceData } = await getUserDegenTipAllowance(address); const { data } = await getUserinfoWithFid(userData.fid); setUserInfo(data.data); + setAllowance(allowanceData.data?.[0]?.tip_allowance || '0'); + setReplyTipAllowance(allowanceData.data?.[0]?.tip_allowance || '0'); + setReplyTipAmountTotal('0'); } catch (e) { console.error(e); } finally { setLoading(false); } - }, [userData]); + }, [userData, address]); + + const directReply = useCallback(async () => { + const allowanceValue = getReplyTipAmount(); + try { + const castToReply = ( + await makeCastAdd( + { + text: `${allowanceValue} $DEGEN`, + embeds: [], + embedsDeprecated: [], + mentions: [], + mentionsPositions: [], + parentCastId: { + hash: Buffer.from(cast.hash.data), + fid: Number(cast.fid), + }, + }, + { fid: currFid, network: FARCASTER_NETWORK }, + encryptedSigner + ) + )._unsafeUnwrap(); + const r = await FARCASTER_WEB_CLIENT.submitMessage(castToReply); + if (r.isErr()) { + throw new Error(r.error.message); + } + setReplyTipAmount(allowanceValue); + setReplyTipTimes(Number(getReplyTipTimes()) - 1); + setReplyTipAmountTotal( + ( + Number(getReplyTipAmountTotal()) + Number(getReplyTipAmount()) + ).toString() + ); + toast.success('allowance tip posted'); + } catch (error) { + console.error(error); + toast.success('allowance tip failed'); + } + }, [currFid, encryptedSigner, cast]); return ( <> @@ -56,6 +112,10 @@ export default function FCastTips({ className="flex items-center gap-2 font-[12px] cursor-pointer" onClick={(e) => { e.stopPropagation(); + const replyDirect = + getUseReplyTipDefault() && + Number(getReplyTipTimes()) > 0 && + Number(getReplyTipAmountTotal()) < Number(getReplyTipAllowance()); if (!isLoginU3) { loginU3(); return; @@ -64,11 +124,18 @@ export default function FCastTips({ openConnectModal(); return; } + + if (replyDirect) { + directReply(); + return; + } + loadUserinfo(); setOpenModal(true); }} > - 🎁 Tips + + Tips
{openModal && ( )} @@ -89,12 +158,16 @@ function TipsModal({ loading, userinfo, userData, + cast, + allowance, }: { open: boolean; setOpen: (open: boolean) => void; loading: boolean; userData: UserData; userinfo: { address: string; fname: string }; + cast: FarCast; + allowance: string; }) { return (
@@ -136,6 +209,8 @@ function TipsModal({ { setOpen(false); }} @@ -149,12 +224,17 @@ function TipsModal({ function TipTransaction({ fname, address, + cast, + allowance, successCallback, }: { fname: string; address: string; + cast: FarCast; + allowance: string; successCallback?: () => void; }) { + const { currFid, encryptedSigner } = useFarcasterCtx(); const tipsCount = [69, 420, 42069]; const { address: accountAddr } = useAccount(); const result = useBalance({ @@ -165,7 +245,11 @@ function TipTransaction({ }); const network = useNetwork(); const [tipAmount, setTipAmount] = useState(tipsCount[1]); + const [allowanceValue, setAllowanceValue] = useState(''); const [transactionHash, setTransactionHash] = useState(''); + const [tab, setTab] = useState('TabReply'); + const [count, setCount] = useState(0); + const tipAction = useCallback(async () => { const left = result?.data?.formatted?.toString() || '0'; if (Number(left) < tipAmount) { @@ -188,9 +272,17 @@ function TipTransaction({ hash: degenTxHash.hash, chainId: base.id, }); + const castHash = Buffer.from(cast.hash.data).toString('hex'); console.log('degenTxReceipt', degenTxReceipt); if (degenTxReceipt.status === 'success') { setTransactionHash(degenTxHash.hash); + // notify + await notifyTipApi({ + fromFid: currFid, + amount: tipAmount, + txHash: degenTxHash.hash, + castHash, + }); toast.success('tip success'); successCallback?.(); } else { @@ -200,51 +292,241 @@ function TipTransaction({ } catch (e) { toast.error(e.message.split('\n')[0]); } - }, [address, tipAmount, result]); + }, [address, tipAmount, result, cast]); + + const allowanceAction = useCallback(async () => { + try { + const castToReply = ( + await makeCastAdd( + { + text: `${allowanceValue} $DEGEN`, + embeds: [], + embedsDeprecated: [], + mentions: [], + mentionsPositions: [], + parentCastId: { + hash: Buffer.from(cast.hash.data), + fid: Number(cast.fid), + }, + // parentUrl, + }, + { fid: currFid, network: FARCASTER_NETWORK }, + encryptedSigner + ) + )._unsafeUnwrap(); + const r = await FARCASTER_WEB_CLIENT.submitMessage(castToReply); + if (r.isErr()) { + throw new Error(r.error.message); + } + setReplyTipAmount(allowanceValue); + setReplyTipAmountTotal(allowanceValue); + setReplyTipTimes(5); + toast.success('allowance tip posted'); + successCallback?.(); + } catch (error) { + console.error(error); + toast.success('allowance tip failed'); + } + }, [allowanceValue, currFid, encryptedSigner]); + + useEffect(() => { + if (Number(allowance) > 0) { + setTab('TabReply'); + } + }, [allowance]); + + const useAllowance = getUseReplyTipDefault(); + + const allowanceNum = Number.isNaN(Number(allowance)) ? 0 : Number(allowance); return ( -
- {/*
$Degen: {result?.data?.formatted?.toString() || '0'}
*/} -
- {tipsCount.map((item) => { - return ( -
{ + if (v === 'TabTransaction') { + localStorage.setItem('tipTab', 'TabTransaction'); + } + setTab(v); + }} + className="h-60" + > + + + Allowance + + + Token + + + +
+
+ {tipsCount.map((item) => { + const isAllowance = allowanceNum >= item; + return ( +
{ + if (isAllowance) setAllowanceValue(`${item}`); + }} + > + ${item} +
+ ); + })} +
+
+ or +
+ { + setAllowanceValue(e.target.value); + }} + /> + $DEGEN +
+
+
+ +
+
+ { + if (v) { + setUseReplyTipDefault(); + } else { + setUseReplyTipDefault('false'); + } + setCount(count + 1); + }} + /> +

+ Use as default for next 5 tips +

+
+
+
+ +
+
+ {tipsCount.map((item) => { + return ( +
{ + setTipAmount(item); + }} + > + ${item} +
+ ); + })} +
+
+ or +
+ { + setTipAmount(Number(e.target.value)); + }} + /> + $DEGEN
- ); - })} -
-
- or - { - setTipAmount(Number(e.target.value)); - }} - /> - $DEGEN -
- -

- to @{fname} (0x{shortPubKey(address, { len: 4 })}) -

-
+
+
+ +
+
+

+ to @{fname} (0x{shortPubKey(address, { len: 4 })}) +

+
+
+ + ); } + +function setReplyTipAllowance(allowance: string) { + localStorage.setItem('tipAllowance', allowance); +} + +function getReplyTipAllowance() { + return localStorage.getItem('tipAllowance') || '0'; +} + +function setReplyTipAmount(num: string) { + localStorage.setItem('tipReplyAmount', num); +} + +function setReplyTipAmountTotal(num: string) { + localStorage.setItem('tipReplyAmountTotal', num); +} + +function getReplyTipAmountTotal() { + return localStorage.getItem('tipReplyAmountTotal') || '0'; +} + +function getReplyTipAmount() { + return localStorage.getItem('tipReplyAmount') || '0'; +} + +function setReplyTipTimes(times: number) { + localStorage.setItem('tipReplyTimes', times.toString()); +} + +function getReplyTipTimes() { + return localStorage.getItem('tipReplyTimes') || '0'; +} + +function setUseReplyTipDefault(value: string = 'true') { + localStorage.setItem('useReplyTipDefault', value); +} + +function getUseReplyTipDefault() { + return localStorage.getItem('useReplyTipDefault') === 'true'; +} diff --git a/apps/u3/src/components/ui/checkbox.tsx b/apps/u3/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..45fdfc22 --- /dev/null +++ b/apps/u3/src/components/ui/checkbox.tsx @@ -0,0 +1,32 @@ +/* eslint-disable react/prop-types */ + +'use client'; + +import * as React from 'react'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { CheckIcon } from '@radix-ui/react-icons'; + +import { cn } from '@/lib/utils'; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/apps/u3/src/components/ui/tabs.tsx b/apps/u3/src/components/ui/tabs.tsx new file mode 100644 index 00000000..24c3dbad --- /dev/null +++ b/apps/u3/src/components/ui/tabs.tsx @@ -0,0 +1,57 @@ +/* eslint-disable react/prop-types */ + +'use client'; + +import * as React from 'react'; +import * as TabsPrimitive from '@radix-ui/react-tabs'; + +import { cn } from '@/lib/utils'; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/u3/src/container/Notification.tsx b/apps/u3/src/container/Notification.tsx index 7ef232b0..717a6469 100644 --- a/apps/u3/src/container/Notification.tsx +++ b/apps/u3/src/container/Notification.tsx @@ -5,6 +5,8 @@ import { useNotificationStore, } from '@/contexts/notification/NotificationStoreCtx'; import NotificationList from '@/components/notification/ui/NotificationList'; +import { NotificationSettingsGroup } from '@/components/notification/PushNotificationsToogleBtn'; +// import isInstalledPwa from '@/utils/shared/isInstalledPwa'; export default function Notification() { const isAuthenticated = useIsAuthenticated(); @@ -26,13 +28,22 @@ function NotificationPage() { const { notifications, loading, hasMore, loadMore, farcasterUserData } = useNotificationStore(); + // const isPwa = isInstalledPwa(); return ( - + <> + {/* {isPwa && ( */} +
+ +
+ {/* )} */} + + + ); } diff --git a/apps/u3/src/hooks/poster/useCasterTokenInfoWithTokenId.ts b/apps/u3/src/hooks/poster/useCasterTokenInfoWithTokenId.ts index 16aaefc7..cb442356 100644 --- a/apps/u3/src/hooks/poster/useCasterTokenInfoWithTokenId.ts +++ b/apps/u3/src/hooks/poster/useCasterTokenInfoWithTokenId.ts @@ -95,7 +95,7 @@ export default function useCasterTokenInfoWithTokenId({ const { saleStart, saleEnd } = tokenInfo; const saleStatus = useMemo( - () => getSaleStatus(String(saleStart), String(saleEnd)), + () => getSaleStatus(Number(saleStart), Number(saleEnd)), [saleStart, saleEnd] ); diff --git a/apps/u3/src/index.tsx b/apps/u3/src/index.tsx index a8d0d1be..d65b35f8 100644 --- a/apps/u3/src/index.tsx +++ b/apps/u3/src/index.tsx @@ -32,7 +32,10 @@ root.render( // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. // Learn more about service workers: https://cra.link/PWA -serviceWorkerRegistration.unregister(); +// serviceWorkerRegistration.unregister(); +serviceWorkerRegistration.register({ + bypassNodeEnvProduction: true, +}); // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) diff --git a/apps/u3/src/service-worker.ts b/apps/u3/src/service-worker.ts index 9060969d..60589072 100644 --- a/apps/u3/src/service-worker.ts +++ b/apps/u3/src/service-worker.ts @@ -81,3 +81,25 @@ self.addEventListener('message', (event) => { }); // Any other custom service worker logic can go here. +// listen for push event +self.addEventListener('push', (event) => { + const { title, body, icon } = event.data.json(); + if (!body) return; + const defaultTitle = 'U3 - Your Web3 Gateway'; + self.registration.showNotification(title || defaultTitle, { + body, + icon: icon || `${process.env.PUBLIC_URL}/logo192.png`, + }); +}); + +// const CALL_BACK_INTERVAL = 1000 * 60 * 60; +// self.addEventListener('activate', () => { +// console.log('activate interval subscribe......'); +// setInterval(() => { +// console.log('fire notification!'); +// self.registration.showNotification('U3', { +// body: 'Checkout new content on Farcaster', +// icon: `${process.env.PUBLIC_URL}/logo192.png`, +// }); +// }, CALL_BACK_INTERVAL); +// }); diff --git a/apps/u3/src/serviceWorkerRegistration.ts b/apps/u3/src/serviceWorkerRegistration.ts index 61915edc..6615b877 100644 --- a/apps/u3/src/serviceWorkerRegistration.ts +++ b/apps/u3/src/serviceWorkerRegistration.ts @@ -23,10 +23,12 @@ const isLocalhost = Boolean( type Config = { onSuccess?: (registration: ServiceWorkerRegistration) => void; onUpdate?: (registration: ServiceWorkerRegistration) => void; + bypassNodeEnvProduction?: boolean; }; export function register(config?: Config) { - if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + // if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + if (nodeEnvProductionCheck(config) && 'serviceWorker' in navigator) { // The URL constructor is available in all browsers that support SW. const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); if (publicUrl.origin !== window.location.origin) { @@ -37,7 +39,8 @@ export function register(config?: Config) { } window.addEventListener('load', () => { - const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + // const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + const swUrl = getServiceWorkerUrl(); if (isLocalhost) { // This is running on localhost. Let's check if a service worker still exists or not. @@ -144,3 +147,17 @@ export function unregister() { }); } } + +function nodeEnvProductionCheck(config) { + if (config && config.bypassNodeEnvProduction) { + return config.bypassNodeEnvProduction; + } + return process.env.NODE_ENV === 'production'; +} + +function getServiceWorkerUrl() { + if (process.env.NODE_ENV === 'production') { + return `${process.env.PUBLIC_URL}/service-worker.js`; + } + return `/service-worker-dev.js`; +} diff --git a/apps/u3/src/services/notification/api/notification-settings.ts b/apps/u3/src/services/notification/api/notification-settings.ts new file mode 100644 index 00000000..3b29a1f9 --- /dev/null +++ b/apps/u3/src/services/notification/api/notification-settings.ts @@ -0,0 +1,65 @@ +import request, { RequestPromise } from '../../shared/api/request'; +import { ApiResp } from '@/services/shared/types'; +import { + NotificationSetting, + NotificationSettingType, +} from '../types/notification-settings'; + +export function fethNotificationSettings(): RequestPromise< + ApiResp +> { + return request({ + url: `/notifications/settings`, + method: 'get', + headers: { + needToken: true, + }, + }); +} + +export function addNotificationSetting(params: { + type: NotificationSettingType; + fid?: string; + subscription?: string; +}): RequestPromise> { + return request({ + url: `/notifications/settings`, + method: 'post', + headers: { + needToken: true, + }, + data: params, + }); +} + +export function updateNotificationSetting( + id: number, + data: { + id: number; + type: NotificationSettingType; + enabled?: boolean; + fid?: string; + subscription?: string; + } +): RequestPromise> { + return request({ + url: `/notifications/settings/${id}`, + method: 'post', + headers: { + needToken: true, + }, + data, + }); +} + +export function deleteNotificationSetting( + id: number +): RequestPromise> { + return request({ + url: `/notifications/setting/${id}`, + method: 'delete', + headers: { + needToken: true, + }, + }); +} diff --git a/apps/u3/src/services/notification/types/notification-settings.ts b/apps/u3/src/services/notification/types/notification-settings.ts new file mode 100644 index 00000000..9ae32842 --- /dev/null +++ b/apps/u3/src/services/notification/types/notification-settings.ts @@ -0,0 +1,12 @@ +export enum NotificationSettingType { + WEB_PUSH = 'WEB_PUSH', +} +export type NotificationSetting = { + id: number; + type: NotificationSettingType; + enable?: boolean; + fid?: string; + subscription?: string; + createdAt?: Date; + updatedAt?: Date; +}; diff --git a/apps/u3/src/services/social/api/farcaster.ts b/apps/u3/src/services/social/api/farcaster.ts index f954487a..2070a410 100644 --- a/apps/u3/src/services/social/api/farcaster.ts +++ b/apps/u3/src/services/social/api/farcaster.ts @@ -515,6 +515,13 @@ export function getUserinfoWithFid(fid: string) { }); } +export function getUserDegenTipAllowance(addr: string) { + return axios({ + url: `${REACT_APP_API_SOCIAL_URL}/3r-farcaster/degen-tip/allowance?address=${addr}`, + method: 'get', + }); +} + export function postFrameActionApi(data: any) { return axios({ url: `${REACT_APP_API_SOCIAL_URL}/3r-farcaster/frame-action/proxy`, @@ -522,3 +529,24 @@ export function postFrameActionApi(data: any) { data, }); } + +export function postFrameActionRedirectApi(data: any) { + return axios({ + url: `${REACT_APP_API_SOCIAL_URL}/3r-farcaster/frame-action-redirect/proxy`, + method: 'post', + data, + }); +} + +export function notifyTipApi(data: { + txHash: string; + amount: number; + fromFid: number; + castHash: string; +}) { + return axios({ + url: `${REACT_APP_API_SOCIAL_URL}/3r-bot/tip/notify`, + method: 'post', + data, + }); +} diff --git a/apps/u3/src/utils/pwa/WebPushService.ts b/apps/u3/src/utils/pwa/WebPushService.ts new file mode 100644 index 00000000..84fc5d35 --- /dev/null +++ b/apps/u3/src/utils/pwa/WebPushService.ts @@ -0,0 +1,47 @@ +class WebPushService { + static hasPermission() { + return Notification.permission === 'granted'; + } + + static async requestPermission() { + return Notification.requestPermission(); + } + + static async getSubscription() { + return navigator.serviceWorker.ready.then(async (registration) => { + return registration.pushManager.getSubscription(); + }); + } + + static async subscribe() { + const applicationServerKey = process.env.REACT_APP_VAPID_PUBLIC_KEY; + if (!applicationServerKey) { + throw new Error('VAPID public key not found'); + } + const registration = await navigator.serviceWorker.ready; + if (!registration) { + throw new Error('Service Worker not ready'); + } + if (!('pushManager' in registration)) { + throw new Error("PushManager isn't available"); + } + if (!('subscribe' in registration.pushManager)) { + throw new Error('subscribe method not available'); + } + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey, + }); + return subscription; + } + + static async unsubscribe() { + const subscription = await this.getSubscription(); + if (subscription) { + await subscription.unsubscribe(); + } + return subscription; + } +} + +export default WebPushService; diff --git a/apps/u3/src/utils/pwa/notification.ts b/apps/u3/src/utils/pwa/notification.ts new file mode 100644 index 00000000..2d98ebf1 --- /dev/null +++ b/apps/u3/src/utils/pwa/notification.ts @@ -0,0 +1,41 @@ +export const requestPermission = async () => { + if (!('Notification' in window)) { + throw new Error('Notification not supported'); + } + const permission = await window.Notification.requestPermission(); + if (permission !== 'granted') { + throw new Error('Permission not granted for Notification'); + } +}; + +export const sendNotification = async (body) => { + console.log('sendNotification', body); + if (Notification.permission === 'granted') { + showNotification(body); + } else if (Notification.permission !== 'denied') { + const permission = await Notification.requestPermission(); + + if (permission === 'granted') { + showNotification(body); + } + } +}; + +const showNotification = async (body) => { + const registration = await navigator.serviceWorker.getRegistration(); + const title = 'U3 - Your Web3 Gateway'; + + const payload = { + body, + icon: `${process.env.PUBLIC_URL}/logo192.png`, + }; + + if ('showNotification' in registration) { + console.log('showNotification in registration', title, payload); + registration.showNotification(title, payload); + } else { + console.log('showNotification NOT in registration', title, payload); + // eslint-disable-next-line no-new + new Notification(title, payload); + } +}; diff --git a/apps/u3/src/utils/shared/time.ts b/apps/u3/src/utils/shared/time.ts index 428714a4..2131dfc5 100644 --- a/apps/u3/src/utils/shared/time.ts +++ b/apps/u3/src/utils/shared/time.ts @@ -24,10 +24,10 @@ export const defaultFormatDate = (date: string | number | Date) => export const defaultFormatFromNow = (date: string | number | Date) => dayjs(date).fromNow(); -export const isSecondTimestamp = (timestamp) => { - return timestamp.toString().length === 10; +export const isSecondTimestamp = (timestamp: number) => { + return timestamp && timestamp.toString().length === 10; }; -export const isMillisecondTimestamp = (timestamp) => { - return timestamp.toString().length === 13; +export const isMillisecondTimestamp = (timestamp: number) => { + return timestamp && timestamp.toString().length === 13; }; diff --git a/apps/u3/src/utils/shared/zora.ts b/apps/u3/src/utils/shared/zora.ts index a9c62cea..198e3c35 100644 --- a/apps/u3/src/utils/shared/zora.ts +++ b/apps/u3/src/utils/shared/zora.ts @@ -162,15 +162,11 @@ export enum SaleStatus { InProgress = 1, Ended = 2, } -export const getSaleStatus = (saleStart: string, saleEnd: string) => { +export const getSaleStatus = (saleStart: number, saleEnd: number) => { const nowMillisecondTimestamp = Date.now(); const nowSecondTimestamp = Math.floor(nowMillisecondTimestamp / 1000); - const compareFn = ( - now: string | number, - start: string | number, - end: string | number - ) => { + const compareFn = (now: number, start: number, end: number) => { if (Number(now) < Number(start)) { return SaleStatus.NotStarted; } @@ -179,6 +175,7 @@ export const getSaleStatus = (saleStart: string, saleEnd: string) => { } return SaleStatus.InProgress; }; + if (isSecondTimestamp(saleStart) && isSecondTimestamp(saleEnd)) { return compareFn(nowSecondTimestamp, saleStart, saleEnd); } @@ -195,11 +192,11 @@ export const getSaleStatus = (saleStart: string, saleEnd: string) => { return compareFn(nowMillisecondTimestamp, Number(saleStart) * 100, saleEnd); } - if (saleStart.length > 13) { + if (String(saleStart).length > 13) { return SaleStatus.NotStarted; } - if (saleEnd.length > 13) { + if (String(saleEnd).length > 13) { return SaleStatus.InProgress; }