From 8f70b57dd57aae91b3a211dd336ee43f01310ffd Mon Sep 17 00:00:00 2001 From: KoalaSat Date: Fri, 28 Jun 2024 14:34:16 +0200 Subject: [PATCH] Web notifications --- .../src/components/Notifications/index.tsx | 182 ++++++++++++------ frontend/src/models/Coordinator.model.ts | 32 ++- frontend/src/models/index.ts | 2 +- frontend/src/services/api/index.ts | 2 +- 4 files changed, 152 insertions(+), 66 deletions(-) diff --git a/frontend/src/components/Notifications/index.tsx b/frontend/src/components/Notifications/index.tsx index 415177d0..7e68effe 100644 --- a/frontend/src/components/Notifications/index.tsx +++ b/frontend/src/components/Notifications/index.tsx @@ -12,6 +12,8 @@ import { useNavigate } from 'react-router-dom'; import Close from '@mui/icons-material/Close'; import { type Page } from '../../basic/NavBar'; import { GarageContext, type UseGarageStoreType } from '../../contexts/GarageContext'; +import { Order, RoboNotification, Slot } from '../../models'; +import { UseFederationStoreType, FederationContext } from '../../contexts/FederationContext'; interface NotificationsProps { rewards: number | undefined; @@ -23,8 +25,8 @@ interface NotificationsProps { interface NotificationMessage { title: string; severity: 'error' | 'warning' | 'info' | 'success'; - onClick: () => void; - sound: HTMLAudioElement | undefined; + onClick?: () => void; + sound?: HTMLAudioElement; timeout: number; pageTitle: string; } @@ -69,13 +71,28 @@ const Notifications = ({ }: NotificationsProps): JSX.Element => { const { t } = useTranslation(); const navigate = useNavigate(); +<<<<<<< HEAD const { garage, slotUpdatedAt } = useContext(GarageContext); +======= + const basePageTitle = t('RoboSats - Simple and Private Bitcoin Exchange'); + const defaultDelay = 5000; + const position = windowWidth > 60 ? { top: '4em', right: '0em' } : { top: '0.5em', left: '50%' }; +>>>>>>> 29f784e9 (Web notifications) - const [message, setMessage] = useState(emptyNotificationMessage); + const { garage, orderUpdatedAt } = useContext(GarageContext); + const { federation } = useContext(FederationContext); const [inFocus, setInFocus] = useState(true); const [titleAnimation, setTitleAnimation] = useState(undefined); const [show, setShow] = useState(false); + const [lastNoticiationCheck, setLastNoticiationCheck] = useState( + new Date().toISOString(), + ); + const [notifications, setNotifications] = useState([]); + const [timer, setTimer] = useState(() => + setInterval(() => null, defaultDelay), + ); +<<<<<<< HEAD // Keep last values to trigger effects on change const [oldOrderStatus, setOldOrderStatus] = useState(undefined); const [oldRewards, setOldRewards] = useState(0); @@ -88,6 +105,12 @@ const Notifications = ({ navigate(`/order/${String(garage.getSlot()?.activeOrder?.id)}`); setShow(false); }; +======= + // // Keep last values to trigger effects on change + // const [oldOrderStatus, setOldOrderStatus] = useState(undefined); + // const [oldRewards, setOldRewards] = useState(0); + // const [oldChatIndex, setOldChatIndex] = useState(0); +>>>>>>> 29f784e9 (Web notifications) interface MessagesProps { bondLocked: NotificationMessage; @@ -110,7 +133,6 @@ const Notifications = ({ `${garage.getSlot()?.activeOrder?.is_maker === true ? 'Maker' : 'Taker'} bond locked`, ), severity: 'info', - onClick: moveToOrderPage, sound: audio.ding, timeout: 10000, pageTitle: `${t('✅ Bond!')} - ${basePageTitle}`, @@ -118,7 +140,6 @@ const Notifications = ({ escrowLocked: { title: t(`Order collateral locked`), severity: 'info', - onClick: moveToOrderPage, sound: audio.ding, timeout: 10000, pageTitle: `${t('✅ Escrow!')} - ${basePageTitle}`, @@ -126,7 +147,6 @@ const Notifications = ({ taken: { title: t('Order has been taken!'), severity: 'success', - onClick: moveToOrderPage, sound: audio.takerFound, timeout: 30000, pageTitle: `${t('🥳 Taken!')} - ${basePageTitle}`, @@ -134,7 +154,6 @@ const Notifications = ({ expired: { title: t('Order has expired'), severity: 'warning', - onClick: moveToOrderPage, sound: audio.ding, timeout: 30000, pageTitle: `${t('😪 Expired!')} - ${basePageTitle}`, @@ -142,7 +161,6 @@ const Notifications = ({ chat: { title: t('Order chat is open'), severity: 'info', - onClick: moveToOrderPage, sound: audio.chat, timeout: 30000, pageTitle: `${t('💬 Chat!')} - ${basePageTitle}`, @@ -150,7 +168,6 @@ const Notifications = ({ successful: { title: t('Trade finished successfully!'), severity: 'success', - onClick: moveToOrderPage, sound: audio.successful, timeout: 10000, pageTitle: `${t('🙌 Funished!')} - ${basePageTitle}`, @@ -158,7 +175,6 @@ const Notifications = ({ routingFailed: { title: t('Lightning routing failed'), severity: 'warning', - onClick: moveToOrderPage, sound: audio.ding, timeout: 20000, pageTitle: `${t('❗⚡ Routing Failed')} - ${basePageTitle}`, @@ -166,7 +182,6 @@ const Notifications = ({ dispute: { title: t('Order has been disputed'), severity: 'warning', - onClick: moveToOrderPage, sound: audio.ding, timeout: 40000, pageTitle: `${t('⚖️ Disputed!')} - ${basePageTitle}`, @@ -174,7 +189,6 @@ const Notifications = ({ disputeWinner: { title: t('You won the dispute'), severity: 'success', - onClick: moveToOrderPage, sound: audio.ding, timeout: 30000, pageTitle: `${t('👍 dispute')} - ${basePageTitle}`, @@ -182,7 +196,6 @@ const Notifications = ({ disputeLoser: { title: t('You lost the dispute'), severity: 'error', - onClick: moveToOrderPage, sound: audio.ding, timeout: 30000, pageTitle: `${t('👎 dispute')} - ${basePageTitle}`, @@ -201,13 +214,13 @@ const Notifications = ({ chatMessage: { title: t('New chat message'), severity: 'info', - onClick: moveToOrderPage, sound: audio.chat, timeout: 3000, pageTitle: `${t('💬 message!')} - ${basePageTitle}`, }, }; +<<<<<<< HEAD const notify = function (message: NotificationMessage): void { if (message.title !== '') { setMessage(message); @@ -234,6 +247,9 @@ const Notifications = ({ if (order === undefined || order === null) return; +======= + const handleStatus = function (notification: RoboNotification, order: Order): void { +>>>>>>> 29f784e9 (Web notifications) let message = emptyNotificationMessage; // Order status descriptions: @@ -256,43 +272,50 @@ const Notifications = ({ // 17: 'Maker lost dispute' // 18: 'Taker lost dispute' - if (status === 5 && oldStatus !== 5) { + const defaultOnClick = () => { + navigate(`/order/${order.shortAlias}/${order.id}`); + setShow(false); + }; + + if (notification.order_status === 5) { message = Messages.expired; - } else if (oldStatus === undefined) { - message = emptyNotificationMessage; - } else if (order.is_maker && status > 0 && oldStatus === 0) { + } else if (notification.order_status === 1) { message = Messages.bondLocked; - } else if (order.is_taker && status > 5 && oldStatus <= 5) { + } else if (order.is_taker && notification.order_status === 6) { message = Messages.bondLocked; - } else if (order.is_maker && status > 5 && oldStatus <= 5) { + } else if (order.is_maker && notification.order_status === 6) { message = Messages.taken; - } else if (order.is_seller && status > 7 && oldStatus < 7) { + } else if (order.is_seller && notification.order_status > 7) { message = Messages.escrowLocked; - } else if ([9, 10].includes(status) && oldStatus < 9) { + } else if ([9, 10].includes(notification.order_status)) { message = Messages.chat; - } else if (order.is_seller && [13, 14, 15].includes(status) && oldStatus < 13) { + } else if (order.is_seller && [13, 14, 15].includes(notification.order_status)) { message = Messages.successful; - } else if (order.is_buyer && status === 14 && oldStatus !== 14) { + } else if (order.is_buyer && notification.order_status === 14) { message = Messages.successful; - } else if (order.is_buyer && status === 15 && oldStatus < 14) { + } else if (order.is_buyer && notification.order_status === 15) { message = Messages.routingFailed; - } else if (status === 11 && oldStatus < 11) { + } else if (notification.order_status === 11) { message = Messages.dispute; } else if ( - ((order.is_maker && status === 18) || (order.is_taker && status === 17)) && - oldStatus < 17 + (order.is_maker && notification.order_status === 18) || + (order.is_taker && notification.order_status === 17) ) { message = Messages.disputeWinner; } else if ( - ((order.is_maker && status === 17) || (order.is_taker && status === 18)) && - oldStatus < 17 + (order.is_maker && notification.order_status === 17) || + (order.is_taker && notification.order_status === 18) ) { message = Messages.disputeLoser; } - notify(message); + notify({ + ...message, + onClick: message.onClick ?? defaultOnClick, + }); }; +<<<<<<< HEAD // Notify on order status change useEffect(() => { const order = garage.getSlot()?.activeOrder; @@ -308,16 +331,45 @@ const Notifications = ({ } } }, [slotUpdatedAt]); - - // Notify on rewards change - useEffect(() => { - if (rewards !== undefined) { - if (rewards > oldRewards) { - notify(Messages.rewards); +======= + const notify: (message: NotificationMessage) => void = (message) => { + if (message.title !== '') { + setShow(true); + setTimeout(() => { + setShow(false); + }, message.timeout); + void audio.ding.play(); + if (!inFocus) { + setTitleAnimation( + setInterval(() => { + const title = document.title; + document.title = title === basePageTitle ? message.pageTitle : basePageTitle; + }, 1000), + ); } - setOldRewards(rewards); } - }, [rewards]); + }; + + const fetchNotifications: () => void = () => { + clearInterval(timer); + Object.values(garage.slots).forEach((slot: Slot) => { + const coordinator = federation.getCoordinator(slot.activeShortAlias); + coordinator + .fetchNotifications(garage, slot.token, lastNoticiationCheck) + .then((data: RoboNotification[]) => { + data.forEach((notification) => handleStatus(notification, slot.order)); + }) + .finally(() => { + setLastNoticiationCheck(new Date().toISOString()); + setTimer(setTimeout(fetchNotifications, defaultDelay)); + }); + }); + }; +>>>>>>> 29f784e9 (Web notifications) + + useEffect(() => { + fetchNotifications(); + }, [orderUpdatedAt, rewards]); // Set blinking page title and clear on visibility change > infocus useEffect(() => { @@ -338,32 +390,38 @@ const Notifications = ({ }, []); return ( - 60 ? 'left' : 'bottom'} - title={ - { - setShow(false); - }} + <> + {notifications.map((notification) => ( + 60 ? 'left' : 'bottom'} + title={ + { + setNotifications((array) => { + return array.filter((n) => n.title !== notification.title); + }); + }} + > + + + } > - - +
+ {notification.title} +
+
} > -
- {message.title} -
-
- } - > -
- +
+ + ))} + ); }; diff --git a/frontend/src/models/Coordinator.model.ts b/frontend/src/models/Coordinator.model.ts index 0215a300..4ec97e01 100644 --- a/frontend/src/models/Coordinator.model.ts +++ b/frontend/src/models/Coordinator.model.ts @@ -22,6 +22,13 @@ export interface Version { patch: number; } +export interface RoboNotification { + title: string; + description: string; + order_status: number; + order_id: number; +} + export interface Badges { isFounder?: boolean | undefined; donatesToDevFund: number; @@ -198,7 +205,7 @@ export class Coordinator { }); }; - generateAllMakerAvatars = async (data: [PublicOrder]): Promise => { + generateAllMakerAvatars = async (data: PublicOrder[]): Promise => { for (const order of data) { void roboidentitiesClient.generateRobohash(order.maker_hash_id, 'small'); } @@ -220,7 +227,7 @@ export class Coordinator { order.coordinatorShortAlias = this.shortAlias; return order; }); - void this.generateAllMakerAvatars(data); + void this.generateAllMakerAvatars(data as PublicOrder[]); onDataLoad(); } else { this.book = []; @@ -321,6 +328,27 @@ export class Coordinator { return { url: String(this[network][origin]), basePath: '' }; } }; + + fetchNotifications = async ( + garage: Garage, + index: string, + gteISODate: string, + ): Promise => { + if (!this.enabled) return []; + + const slot = garage.getSlot(index); + const robot = slot?.getRobot(); + + if (!(slot?.token != null) || !(robot?.encPrivKey != null)) return []; + + const data = await apiClient.get( + this.url, + `${this.basePath}/api/notifications/?created_at=${gteISODate}`, + { tokenSHA256: robot.tokenSHA256 }, + ); + + return data as RoboNotification[]; + }; } export default Coordinator; diff --git a/frontend/src/models/index.ts b/frontend/src/models/index.ts index dbc1b6bd..3404265d 100644 --- a/frontend/src/models/index.ts +++ b/frontend/src/models/index.ts @@ -13,7 +13,7 @@ export type { Maker } from './Maker.model'; export type { Book, PublicOrder } from './Book.model'; export type { Language } from './Settings.model'; export type { Favorites } from './Favorites.model'; -export type { Contact, Info, Version, Origin } from './Coordinator.model'; +export type { Contact, Info, Version, Origin, RoboNotification } from './Coordinator.model'; export { defaultMaker } from './Maker.model'; export { defaultExchange } from './Exchange.model'; diff --git a/frontend/src/services/api/index.ts b/frontend/src/services/api/index.ts index 21b8effd..d5e47eab 100644 --- a/frontend/src/services/api/index.ts +++ b/frontend/src/services/api/index.ts @@ -10,7 +10,7 @@ export interface ApiClient { useProxy: boolean; post: (baseUrl: string, path: string, body: object, auth?: Auth) => Promise; put: (baseUrl: string, path: string, body: object, auth?: Auth) => Promise; - get: (baseUrl: string, path: string, auth?: Auth) => Promise; + get: (baseUrl: string, path: string, auth?: Auth) => Promise; delete: (baseUrl: string, path: string, auth?: Auth) => Promise; }