diff --git a/src/manifest.json b/src/manifest.json index e8211dd..3c765d3 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -24,7 +24,8 @@ "tabs", "activeTab", "storage", - "webRequest" + "webRequest", + "notifications" ], "content_scripts": [ { diff --git a/src/pointsdk/index.d.ts b/src/pointsdk/index.d.ts index 8cd89cf..6468bf0 100644 --- a/src/pointsdk/index.d.ts +++ b/src/pointsdk/index.d.ts @@ -187,3 +187,22 @@ export type Network = { currency_code: string; tokens?: Token[]; }; + +export type PointNotification = { + id: number; + block_number: string; + timestamp: number; + identity: string; + address: string; + contract: string; + event: string; + arguments: Record; + viewed: boolean; + log: Record; +}; + +export type BlockRange = { + from: number; + to: number; + latest?: number; +}; diff --git a/src/pointsdk/sdk.ts b/src/pointsdk/sdk.ts index 5398273..52a2b38 100644 --- a/src/pointsdk/sdk.ts +++ b/src/pointsdk/sdk.ts @@ -22,7 +22,9 @@ import { SubscriptionMessages, SubscriptionEvent, SubscriptionParams, - IdentityData + IdentityData, + PointNotification, + BlockRange } from './index.d'; const getSdk = (host: string, version: string, swal: any): PointType => { @@ -781,6 +783,20 @@ const getSdk = (host: string, version: string, swal: any): PointType => { api.get(`identity/ownerToIdentity/${owner}`, args), me: () => api.get('identity/isIdentityRegistered/') }, + notifications: { + unread: () => api.get('notifications/unread'), + scan: ({from, to, latest}: Partial) => { + const endpoint = 'notifications/scan'; + const query = new URLSearchParams(); + if (from) query.append('from', String(from)); + if (to) query.append('to', String(to)); + if (latest) query.append('latest', String(latest)); + const queryStr = query.toString(); + const url = queryStr ? `${endpoint}?${queryStr}` : endpoint; + return api.get(url); + }, + markRead: (id: number) => api.get(`notifications/read/${id}`) + }, ...(host === 'https://point' && !window.top.IS_GATEWAY ? { point: { diff --git a/src/popup/App.tsx b/src/popup/App.tsx index 20d59f5..3d4eac6 100644 --- a/src/popup/App.tsx +++ b/src/popup/App.tsx @@ -1,14 +1,28 @@ import React, {FunctionComponent} from 'react'; +import {MemoryRouter as Router, Routes, Route} from 'react-router-dom'; +import UIThemeProvider from 'pointsdk/theme/UIThemeProvider'; import {BlockchainContext, useBlockchain} from './context/blockchain'; +import {NotificationsProvider} from './context/notifications'; import Layout from './components/Layout'; +import Notifications from './components/Notifications'; const App: FunctionComponent = () => { const blockchainContext = useBlockchain(); return ( - + + + + + } /> + } /> + + + + ); }; + export default App; diff --git a/src/popup/components/BackArrow.tsx b/src/popup/components/BackArrow.tsx new file mode 100644 index 0000000..396022a --- /dev/null +++ b/src/popup/components/BackArrow.tsx @@ -0,0 +1,15 @@ +import React, {FunctionComponent} from 'react'; +import {Link} from 'react-router-dom'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; + +type Props = { + to: string; +}; + +const BackArrow: FunctionComponent = ({to}: Props) => ( + + + +); + +export default BackArrow; diff --git a/src/popup/components/Layout.tsx b/src/popup/components/Layout.tsx index e40c1b3..f263774 100644 --- a/src/popup/components/Layout.tsx +++ b/src/popup/components/Layout.tsx @@ -5,7 +5,6 @@ import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import Divider from '@mui/material/Divider'; import {BlockchainContext} from '../context/blockchain'; -import UIThemeProvider from 'pointsdk/theme/UIThemeProvider'; import NetworkSwitcher from './NetworkSwitcher'; import UserData from './UserData'; import Links from './Links'; @@ -17,41 +16,39 @@ const Layout: FunctionComponent = () => { const {loading} = useContext(BlockchainContext); return ( - - - {' '} - - point-logo - - PointSDK - - - {loading ? ( - - - Loading... - - ) : ( - - - - - - - - - - - - )} + + {' '} + + point-logo + + PointSDK + - + {loading ? ( + + + Loading... + + ) : ( + + + + + + + + + + + + )} + ); }; diff --git a/src/popup/components/Links.tsx b/src/popup/components/Links.tsx index d0fc874..52a73af 100644 --- a/src/popup/components/Links.tsx +++ b/src/popup/components/Links.tsx @@ -1,11 +1,18 @@ import React, {FunctionComponent} from 'react'; +import {useNavigate} from 'react-router-dom'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; import HomeIcon from '@mui/icons-material/Home'; import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; +import NotificationsIcon from '@mui/icons-material/Notifications'; +import Badge from '@mui/material/Badge'; import browser from 'webextension-polyfill'; +import {useNotifications} from '../context/notifications'; const Links: FunctionComponent = () => { + const navigate = useNavigate(); + const {notifications} = useNotifications(); + const go = async (url: string) => { // Open new tab with the desired URL. await browser.tabs.create({url}); @@ -19,7 +26,7 @@ const Links: FunctionComponent = () => { Quick Links - + { My Wallet + navigate('/notifications')} + > + + + + + Notifications + + ); diff --git a/src/popup/components/Notification.tsx b/src/popup/components/Notification.tsx new file mode 100644 index 0000000..0f7df82 --- /dev/null +++ b/src/popup/components/Notification.tsx @@ -0,0 +1,120 @@ +import React, {FunctionComponent} from 'react'; +import browser from 'webextension-polyfill'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import CloseIcon from '@mui/icons-material/Close'; +import {PointNotification} from 'pointsdk/pointsdk/index.d'; + +type ParsedNotification = { + title: string; + from: string; + date: string; + link: string; +}; + +const socialActionToTile: Record string> = { + POST_LIKE: (id: string | number) => `Post #${id} got a new like`, + POST_LIKE_DELETE: (id: string | number) => `Post #${id} got an unlike`, + POST_DISLIKE: (id: string | number) => `Post #${id} got a new dislike`, + POST_CREATE: (id: string | number) => `New post #${id}`, + POST_EDIT: (id: string | number) => `Post #${id} has been edited`, + POST_DELETE: (id: string | number) => `Post #${id} has been deleted`, + POST_FLAG: (id: string | number) => `Post #${id} has been flagged`, + POST_COMMENT_CREATE: (id: string | number) => `New comment on post #${id}`, + POST_COMMENT_DELETE: (id: string | number) => `Comment deleted on post #${id}`, + COMMENT_EDIT: (id: string | number) => `Comment edited on post #${id}`, + COMMENT_DELETE: (id: string | number) => `Comment deleted on post #${id}` +}; + +function parseNotification(n: PointNotification): ParsedNotification { + const data = n.arguments; + if (n.identity === 'social.point' && n.event === 'StateChange') { + return { + from: n.identity, + title: socialActionToTile[data.changeAction as string] + ? socialActionToTile[data.changeAction as string]!(data.id as string) + : `New ${n.event}`, + date: new Date(n.timestamp).toLocaleDateString(), + link: String(data.changeAction).includes('DELETE') + ? '' + : `https://social.point/post/${data.id as string}` + }; + } + + if (n.identity === 'email.point' && n.event === 'RecipientAdded') { + return { + from: n.identity, + title: 'You got email', + date: new Date(n.timestamp).toLocaleDateString(), + link: `https://email.point/show?id=${data.id as string}` + }; + } + + return { + from: n.identity, + title: `New ${n.event}`, + date: new Date(n.timestamp).toLocaleDateString(), + link: '' + }; +} + +type Props = { + n: PointNotification; + onMarkRead: (id: number) => void; +}; + +const Notification: FunctionComponent = ({n, onMarkRead}: Props) => { + const {title, from, date, link} = parseNotification(n); + + const go = async (url: string) => { + // Open new tab with the desired URL. + await browser.tabs.create({url}); + // Close the extension popup window. + window.close(); + }; + + return ( + + + + {title} + + + + + + App: {from} + + + On: {date} + + + {link ? ( + + ) : null} + + ); +}; + +export default Notification; diff --git a/src/popup/components/Notifications.tsx b/src/popup/components/Notifications.tsx new file mode 100644 index 0000000..834719d --- /dev/null +++ b/src/popup/components/Notifications.tsx @@ -0,0 +1,47 @@ +import React, {FunctionComponent} from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import CircularProgress from '@mui/material/CircularProgress'; +import Alert from '@mui/material/Alert'; +import {useNotifications} from '../context/notifications'; +import BackArrow from './BackArrow'; +import Notification from './Notification'; + +const Notifications: FunctionComponent = () => { + const {notifications, loading, error, markRead, dismissError} = useNotifications(); + + return ( + + + + + Notifications{' '} + {notifications.length > 0 ? ({notifications.length}) : ''} + + + + {loading || error ? ( + + {loading ? : null} + {error ? ( + + {error} + + ) : null} + + ) : null} + + {!loading && !error && notifications.length === 0 ? ( + + You have no notifications. + + ) : null} + + {notifications.map(n => ( + + ))} + + ); +}; + +export default Notifications; diff --git a/src/popup/context/notifications.tsx b/src/popup/context/notifications.tsx new file mode 100644 index 0000000..5e0103f --- /dev/null +++ b/src/popup/context/notifications.tsx @@ -0,0 +1,117 @@ +import React, { + createContext, + useContext, + useEffect, + useState, + useMemo, + useCallback, + ReactNode +} from 'react'; +import browser from 'webextension-polyfill'; +import {PointNotification, BlockRange} from 'pointsdk/pointsdk/index.d'; + +type NotificationsCtxType = { + notifications: PointNotification[]; + loading: boolean; + error: string; + markRead: (id: number) => void; + dismissError: () => void; +}; + +const NotificationsCtx = createContext({ + notifications: [], + loading: false, + error: '', + markRead: () => {}, + dismissError: () => {} +}); +NotificationsCtx.displayName = 'NotificationsCtx'; + +export const NotificationsProvider = ({children}: {children: ReactNode}) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [notifications, setNotifications] = useState([]); + const [finishFetching, setFinishFetching] = useState(false); + + useEffect(() => { + async function fetchPastEvents(opts: Partial = {}) { + console.log('Request to fetch past events', opts); + try { + const {data} = (await window.point.notifications.scan(opts)) as { + data: {from: number; to: number; latest: number; logs: PointNotification[]}; + }; + if (data.logs && data.logs.length > 0) { + setNotifications(prev => [...prev, ...data.logs]); + } + if (data.latest > data.to) { + await fetchPastEvents({from: data.to + 1, latest: data.latest}); + } else { + setFinishFetching(true); + } + } catch (err) { + console.error(err); + } + } + // TODO: remove the hardcoded `latest` value to scan up to the actual latest block. + void fetchPastEvents({latest: 4_053_000}); + }, []); + + useEffect(() => { + async function fetchUnread() { + setLoading(true); + try { + const {data} = (await window.point.notifications.unread()) as { + data: PointNotification[]; + }; + setNotifications(prev => [...prev, ...data]); + } catch (err) { + console.error(err); + setError('Unable to fetch notifications.'); + } finally { + setLoading(false); + } + } + void fetchUnread(); + }, []); + + useEffect(() => { + const count = notifications.length; + if (finishFetching && count > 0) { + void browser.notifications.create({ + type: 'basic', + iconUrl: '../../assets/icons/icon-48.png', + title: 'Point Notifications', + message: `You have ${count} new notification${count > 1 ? 's' : ''}.` + }); + } + }, [notifications, finishFetching]); + + const markRead = useCallback(async (id: number) => { + try { + await window.point.notifications.markRead(id); + setNotifications(prev => prev.filter(n => n.id !== id)); + } catch (err) { + console.error(err); + setError('Unable to mark notification as read.'); + } + }, []); + + const dismissError = useCallback(() => { + setError(''); + }, []); + + const value = useMemo( + () => ({notifications, loading, error, markRead, dismissError}), + [notifications, loading, error, markRead, dismissError] + ); + + return {children}; +}; + +export function useNotifications() { + const ctx = useContext(NotificationsCtx); + if (!ctx) { + throw new Error('useNotifications must be used within a '); + } + return ctx; +}