diff --git a/electron/main.js b/electron/main.js index 70a282a11..df500f4f9 100644 --- a/electron/main.js +++ b/electron/main.js @@ -60,6 +60,10 @@ let tray, nextAppProcess, nextAppProcessPid; +function showNotification(title, body) { + new Notification({ title, body }).show(); +} + async function beforeQuit() { if (operateDaemonPid) { try { @@ -220,12 +224,8 @@ const createMainWindow = () => { mainWindow.setSize(width, height); }); - ipcMain.on('notify-agent-running', () => { - if (!mainWindow.isVisible()) { - new Notification({ - title: 'Your agent is now running!', - }).show(); - } + ipcMain.on('show-notification', (title, description) => { + showNotification(title, description || undefined); }); mainWindow.webContents.on('did-fail-load', () => { diff --git a/electron/preload.js b/electron/preload.js index 3c30e0f16..47a516449 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -5,5 +5,6 @@ contextBridge.exposeInMainWorld('electronAPI', { closeApp: () => ipcRenderer.send('close-app'), minimizeApp: () => ipcRenderer.send('minimize-app'), setAppHeight: (height) => ipcRenderer.send('set-height', height), - notifyAgentRunning: () => ipcRenderer.send('notify-agent-running'), + showNotification: (title, description) => + ipcRenderer.send('show-notification', title, description), }); diff --git a/frontend/components/Main/MainHeader.tsx b/frontend/components/Main/MainHeader.tsx index 8992052d1..6a980a5e0 100644 --- a/frontend/components/Main/MainHeader.tsx +++ b/frontend/components/Main/MainHeader.tsx @@ -1,7 +1,6 @@ import { InfoCircleOutlined } from '@ant-design/icons'; import { Badge, Button, Flex, Popover, Typography } from 'antd'; import { formatUnits } from 'ethers/lib/utils'; -import get from 'lodash/get'; import Image from 'next/image'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -9,6 +8,7 @@ import { Chain, DeploymentStatus } from '@/client'; import { setTrayIcon } from '@/common-util'; import { COLOR, LOW_BALANCE, SERVICE_TEMPLATES } from '@/constants'; import { useBalance, useServiceTemplates } from '@/hooks'; +import { useElectronApi } from '@/hooks/useElectronApi'; import { useServices } from '@/hooks/useServices'; import { useWallet } from '@/hooks/useWallet'; import { ServicesService } from '@/service'; @@ -25,13 +25,9 @@ enum ServiceButtonLoadingState { NotLoading, } -const notifyAgentRunning = () => { - const fn = get(window, 'electronAPI.notifyAgentRunning') ?? (() => null); - return fn(); -}; - export const MainHeader = () => { const { services, serviceStatus, setServiceStatus } = useServices(); + const { showNotification } = useElectronApi(); const { getServiceTemplates } = useServiceTemplates(); const { wallets, masterSafeAddress } = useWallet(); const { @@ -118,7 +114,7 @@ export const MainHeader = () => { setServiceStatus(DeploymentStatus.DEPLOYED); setIsBalancePollingPaused(false); setServiceButtonState(ServiceButtonLoadingState.NotLoading); - notifyAgentRunning(); + showNotification?.('Your agent is now running!'); }); } catch (error) { setIsBalancePollingPaused(false); @@ -130,6 +126,7 @@ export const MainHeader = () => { setIsBalancePollingPaused, setServiceStatus, wallets, + showNotification, ]); const handlePause = useCallback(() => { diff --git a/frontend/components/Main/MainRewards.tsx b/frontend/components/Main/MainRewards.tsx index 9e1b56f26..829464c2e 100644 --- a/frontend/components/Main/MainRewards.tsx +++ b/frontend/components/Main/MainRewards.tsx @@ -1,12 +1,17 @@ -import { Col, Flex, Row, Skeleton, Tag, Typography } from 'antd'; +import { Button, Col, Flex, Modal, Row, Skeleton, Tag, Typography } from 'antd'; +import Image from 'next/image'; +import { useCallback, useEffect, useState } from 'react'; import styled from 'styled-components'; import { balanceFormat } from '@/common-util'; import { COLOR } from '@/constants'; import { useBalance } from '@/hooks'; +import { useElectronApi } from '@/hooks/useElectronApi'; import { useReward } from '@/hooks/useReward'; -const { Text } = Typography; +import { ConfettiAnimation } from '../common/ConfettiAnimation'; + +const { Text, Title } = Typography; const RewardsRow = styled(Row)` margin: 0 -24px; @@ -70,8 +75,106 @@ const DisplayRewards = () => { ); }; +const NotifyRewards = () => { + const { isEligibleForRewards, availableRewardsForEpochEth } = useReward(); + const { totalOlasBalance } = useBalance(); + const { showNotification } = useElectronApi(); + + const [canShowNotification, setCanShowNotification] = useState(false); + + useEffect(() => { + // TODO: Implement this once state persistence is available + const hasAlreadyNotified = true; + + if (!isEligibleForRewards) return; + if (hasAlreadyNotified) return; + if (!availableRewardsForEpochEth) return; + + setCanShowNotification(true); + }, [isEligibleForRewards, availableRewardsForEpochEth, showNotification]); + + // hook to show app notification + useEffect(() => { + if (!canShowNotification) return; + + showNotification?.( + 'Your agent earned its first staking rewards!', + `Congratulations! Your agent just got the first reward for you! Your current balance: ${availableRewardsForEpochEth} OLAS`, + ); + }, [canShowNotification, availableRewardsForEpochEth, showNotification]); + + const closeNotificationModal = useCallback(() => { + setCanShowNotification(false); + // TODO: add setter for hasAlreadyNotified + }, []); + + if (!canShowNotification) return null; + + return ( + + + Share on + Share on twitter + + , + ]} + > + + + + OLAS logo + + + + Your agent just earned the first reward! + + + + + Congratulations! Your agent just earned the first + + {` ${balanceFormat(availableRewardsForEpochEth, 2)} OLAS `} + + for you! + + + + Your current balance: + {` ${balanceFormat(totalOlasBalance, 2)} OLAS `} + + + Keep it running to get even more! + + + ); +}; + export const MainRewards = () => ( <> + ); diff --git a/frontend/components/common/ConfettiAnimation.jsx b/frontend/components/common/ConfettiAnimation.jsx new file mode 100644 index 000000000..9e02463ff --- /dev/null +++ b/frontend/components/common/ConfettiAnimation.jsx @@ -0,0 +1,43 @@ +import { useCallback, useRef } from 'react'; +import ReactCanvasConfetti from 'react-canvas-confetti'; +import { useInterval } from 'usehooks-ts'; + +const canvasStyles = { + position: 'fixed', + pointerEvents: 'none', + width: '100%', + height: '100%', + top: 0, + left: 0, +}; + +export const ConfettiAnimation = () => { + const animationInstance = useRef(null); + + const makeShot = useCallback((particleRatio, opts) => { + if (!animationInstance.current) return; + + animationInstance.current({ + ...opts, + origin: { y: 0.45 }, + particleCount: Math.floor(200 * particleRatio), + }); + }, []); + + const fire = useCallback(() => { + makeShot(0.25, { spread: 26, startVelocity: 55 }); + makeShot(0.2, { spread: 60 }); + makeShot(0.35, { spread: 80, decay: 0.91, scalar: 0.8 }); + makeShot(0.1, { spread: 100, startVelocity: 25, decay: 0.92, scalar: 1.2 }); + makeShot(0.1, { spread: 100, startVelocity: 45 }); + }, [makeShot]); + + const getInstance = useCallback((instance) => { + animationInstance.current = instance; + }, []); + + // Fire confetti every 2.5 seconds + useInterval(() => fire(), 2500); + + return ; +}; diff --git a/frontend/context/ElectronApiProvider.tsx b/frontend/context/ElectronApiProvider.tsx index 7c88272f9..586c84a8b 100644 --- a/frontend/context/ElectronApiProvider.tsx +++ b/frontend/context/ElectronApiProvider.tsx @@ -5,12 +5,14 @@ type ElectronApiContextProps = { setAppHeight?: (height: number) => void; closeApp?: () => void; minimizeApp?: () => void; + showNotification?: (title: string, body?: string) => void; }; export const ElectronApiContext = createContext({ setAppHeight: undefined, closeApp: undefined, minimizeApp: undefined, + showNotification: undefined, }); const getElectronApiFunction = (functionNameInWindow: string) => { @@ -33,6 +35,7 @@ export const ElectronApiProvider = ({ children }: PropsWithChildren) => { setAppHeight: getElectronApiFunction('setAppHeight'), closeApp: getElectronApiFunction('closeApp'), minimizeApp: getElectronApiFunction('minimizeApp'), + showNotification: getElectronApiFunction('showNotification'), }} > {children} diff --git a/frontend/hooks/useElectronApi.tsx b/frontend/hooks/useElectronApi.tsx index 5dff88fc6..daec352bd 100644 --- a/frontend/hooks/useElectronApi.tsx +++ b/frontend/hooks/useElectronApi.tsx @@ -3,12 +3,13 @@ import { useContext } from 'react'; import { ElectronApiContext } from '@/context/ElectronApiProvider'; export const useElectronApi = () => { - const { setAppHeight, closeApp, minimizeApp } = + const { setAppHeight, closeApp, minimizeApp, showNotification } = useContext(ElectronApiContext); return { setAppHeight, closeApp, minimizeApp, + showNotification, }; }; diff --git a/frontend/package.json b/frontend/package.json index f97a7e7e6..d3770f4f1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "next": "^14.2.3", "react": "^18", "react-dom": "^18", + "react-canvas-confetti": "1.2.1", "sass": "^1.72.0", "styled-components": "^6.1.8", "usehooks-ts": "^2.14.0" diff --git a/frontend/public/splash-robot-head.png b/frontend/public/splash-robot-head.png new file mode 100644 index 000000000..d43fb2873 Binary files /dev/null and b/frontend/public/splash-robot-head.png differ diff --git a/frontend/public/twitter.svg b/frontend/public/twitter.svg new file mode 100644 index 000000000..f2ddba13f --- /dev/null +++ b/frontend/public/twitter.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/styles/globals.scss b/frontend/styles/globals.scss index a2b493f58..f2a62c14f 100644 --- a/frontend/styles/globals.scss +++ b/frontend/styles/globals.scss @@ -85,6 +85,14 @@ button, input, select, textarea, .ant-input-suffix { margin-bottom: auto !important; } +.mt-8 { + margin-top: 8px !important; +} + +.mt-12 { + margin-top: 12px !important; +} + .mx-auto { margin-left: auto !important; margin-right: auto !important; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index c79d3d245..c2f3c23ee 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1349,6 +1349,11 @@ dependencies: "@babel/types" "^7.20.7" +"@types/canvas-confetti@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@types/canvas-confetti/-/canvas-confetti-1.4.0.tgz#22127a1a9ed9d456e626d6e2b9a4d3b0a240e18b" + integrity sha512-Neq4mvVecrHmTdyo98EY5bnKCjkZGQ6Ma7VyOrxIcMHEZPmt4kfquccqfBMrpNrdryMHgk3oGQi7XtpZacltnw== + "@types/graceful-fs@^4.1.3": version "4.1.9" resolved "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz" @@ -2152,6 +2157,11 @@ caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001587: resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001587.tgz" integrity sha512-HMFNotUmLXn71BQxg8cijvqxnIAofforZOwGsxyXJ0qugTdspUF4sPSJ2vhgprHCB996tIDzEq1ubumPDV8ULA== +canvas-confetti@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/canvas-confetti/-/canvas-confetti-1.4.0.tgz#840f6db4a566f8f32abe28c00dcd82acf39c92bd" + integrity sha512-S18o4Y9PqI/uabdlT/jI3MY7XBJjNxnfapFIkjkMwpz6qNxLFZOm2b22OMf4ZYDL9lpNWI+Ih4fEMVPwO1KHFQ== + chalk@^2.4.2: version "2.4.2" resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz" @@ -5209,6 +5219,14 @@ rc-virtual-list@^3.11.1, rc-virtual-list@^3.5.1, rc-virtual-list@^3.5.2: rc-resize-observer "^1.0.0" rc-util "^5.36.0" +react-canvas-confetti@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/react-canvas-confetti/-/react-canvas-confetti-1.2.1.tgz#22ac64cbc478cf57cb5f61c130322e359b35389d" + integrity sha512-onNjQNkhQjrB2JVCeRpJW7o8VlBNJvo3Eht9zlQlofNLVvvOd1+PzBLU5Ev8n5sFBkTbKJmIUVG0eZnkAl5cpg== + dependencies: + "@types/canvas-confetti" "1.4.0" + canvas-confetti "1.4.0" + react-dom@^18: version "18.2.0" resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz"