mirror of
https://github.com/RoboSats/robosats.git
synced 2024-09-11 14:05:51 +03:00
Web notifications
This commit is contained in:
parent
1532bf8c46
commit
d2c9183a7d
@ -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,25 +71,27 @@ const Notifications = ({
|
||||
}: NotificationsProps): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { garage, orderUpdatedAt } = useContext<UseGarageStoreType>(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%' };
|
||||
|
||||
const [message, setMessage] = useState<NotificationMessage>(emptyNotificationMessage);
|
||||
const { garage, orderUpdatedAt } = useContext<UseGarageStoreType>(GarageContext);
|
||||
const { federation } = useContext<UseFederationStoreType>(FederationContext);
|
||||
const [inFocus, setInFocus] = useState<boolean>(true);
|
||||
const [titleAnimation, setTitleAnimation] = useState<NodeJS.Timer | undefined>(undefined);
|
||||
const [show, setShow] = useState<boolean>(false);
|
||||
const [lastNoticiationCheck, setLastNoticiationCheck] = useState<string>(
|
||||
new Date().toISOString(),
|
||||
);
|
||||
const [notifications, setNotifications] = useState<NotificationMessage[]>([]);
|
||||
const [timer, setTimer] = useState<NodeJS.Timer | undefined>(() =>
|
||||
setInterval(() => null, defaultDelay),
|
||||
);
|
||||
|
||||
// Keep last values to trigger effects on change
|
||||
const [oldOrderStatus, setOldOrderStatus] = useState<number | undefined>(undefined);
|
||||
const [oldRewards, setOldRewards] = useState<number>(0);
|
||||
const [oldChatIndex, setOldChatIndex] = useState<number>(0);
|
||||
|
||||
const position = windowWidth > 60 ? { top: '4em', right: '0em' } : { top: '0.5em', left: '50%' };
|
||||
const basePageTitle = t('RoboSats - Simple and Private Bitcoin Exchange');
|
||||
|
||||
const moveToOrderPage = function (): void {
|
||||
navigate(`/order/${String(garage.getSlot()?.order?.id)}`);
|
||||
setShow(false);
|
||||
};
|
||||
// // Keep last values to trigger effects on change
|
||||
// const [oldOrderStatus, setOldOrderStatus] = useState<number | undefined>(undefined);
|
||||
// const [oldRewards, setOldRewards] = useState<number>(0);
|
||||
// const [oldChatIndex, setOldChatIndex] = useState<number>(0);
|
||||
|
||||
interface MessagesProps {
|
||||
bondLocked: NotificationMessage;
|
||||
@ -108,7 +112,6 @@ const Notifications = ({
|
||||
bondLocked: {
|
||||
title: t(`${garage.getSlot()?.order?.is_maker === true ? 'Maker' : 'Taker'} bond locked`),
|
||||
severity: 'info',
|
||||
onClick: moveToOrderPage,
|
||||
sound: audio.ding,
|
||||
timeout: 10000,
|
||||
pageTitle: `${t('✅ Bond!')} - ${basePageTitle}`,
|
||||
@ -116,7 +119,6 @@ const Notifications = ({
|
||||
escrowLocked: {
|
||||
title: t(`Order collateral locked`),
|
||||
severity: 'info',
|
||||
onClick: moveToOrderPage,
|
||||
sound: audio.ding,
|
||||
timeout: 10000,
|
||||
pageTitle: `${t('✅ Escrow!')} - ${basePageTitle}`,
|
||||
@ -124,7 +126,6 @@ const Notifications = ({
|
||||
taken: {
|
||||
title: t('Order has been taken!'),
|
||||
severity: 'success',
|
||||
onClick: moveToOrderPage,
|
||||
sound: audio.takerFound,
|
||||
timeout: 30000,
|
||||
pageTitle: `${t('🥳 Taken!')} - ${basePageTitle}`,
|
||||
@ -132,7 +133,6 @@ const Notifications = ({
|
||||
expired: {
|
||||
title: t('Order has expired'),
|
||||
severity: 'warning',
|
||||
onClick: moveToOrderPage,
|
||||
sound: audio.ding,
|
||||
timeout: 30000,
|
||||
pageTitle: `${t('😪 Expired!')} - ${basePageTitle}`,
|
||||
@ -140,7 +140,6 @@ const Notifications = ({
|
||||
chat: {
|
||||
title: t('Order chat is open'),
|
||||
severity: 'info',
|
||||
onClick: moveToOrderPage,
|
||||
sound: audio.chat,
|
||||
timeout: 30000,
|
||||
pageTitle: `${t('💬 Chat!')} - ${basePageTitle}`,
|
||||
@ -148,7 +147,6 @@ const Notifications = ({
|
||||
successful: {
|
||||
title: t('Trade finished successfully!'),
|
||||
severity: 'success',
|
||||
onClick: moveToOrderPage,
|
||||
sound: audio.successful,
|
||||
timeout: 10000,
|
||||
pageTitle: `${t('🙌 Funished!')} - ${basePageTitle}`,
|
||||
@ -156,7 +154,6 @@ const Notifications = ({
|
||||
routingFailed: {
|
||||
title: t('Lightning routing failed'),
|
||||
severity: 'warning',
|
||||
onClick: moveToOrderPage,
|
||||
sound: audio.ding,
|
||||
timeout: 20000,
|
||||
pageTitle: `${t('❗⚡ Routing Failed')} - ${basePageTitle}`,
|
||||
@ -164,7 +161,6 @@ const Notifications = ({
|
||||
dispute: {
|
||||
title: t('Order has been disputed'),
|
||||
severity: 'warning',
|
||||
onClick: moveToOrderPage,
|
||||
sound: audio.ding,
|
||||
timeout: 40000,
|
||||
pageTitle: `${t('⚖️ Disputed!')} - ${basePageTitle}`,
|
||||
@ -172,7 +168,6 @@ const Notifications = ({
|
||||
disputeWinner: {
|
||||
title: t('You won the dispute'),
|
||||
severity: 'success',
|
||||
onClick: moveToOrderPage,
|
||||
sound: audio.ding,
|
||||
timeout: 30000,
|
||||
pageTitle: `${t('👍 dispute')} - ${basePageTitle}`,
|
||||
@ -180,7 +175,6 @@ const Notifications = ({
|
||||
disputeLoser: {
|
||||
title: t('You lost the dispute'),
|
||||
severity: 'error',
|
||||
onClick: moveToOrderPage,
|
||||
sound: audio.ding,
|
||||
timeout: 30000,
|
||||
pageTitle: `${t('👎 dispute')} - ${basePageTitle}`,
|
||||
@ -199,39 +193,13 @@ const Notifications = ({
|
||||
chatMessage: {
|
||||
title: t('New chat message'),
|
||||
severity: 'info',
|
||||
onClick: moveToOrderPage,
|
||||
sound: audio.chat,
|
||||
timeout: 3000,
|
||||
pageTitle: `${t('💬 message!')} - ${basePageTitle}`,
|
||||
},
|
||||
};
|
||||
|
||||
const notify = function (message: NotificationMessage): void {
|
||||
if (message.title !== '') {
|
||||
setMessage(message);
|
||||
setShow(true);
|
||||
setTimeout(() => {
|
||||
setShow(false);
|
||||
}, message.timeout);
|
||||
if (message.sound != null) {
|
||||
void message.sound.play();
|
||||
}
|
||||
if (!inFocus) {
|
||||
setTitleAnimation(
|
||||
setInterval(function () {
|
||||
const title = document.title;
|
||||
document.title = title === basePageTitle ? message.pageTitle : basePageTitle;
|
||||
}, 1000),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = function (oldStatus: number | undefined, status: number): void {
|
||||
const order = garage.getSlot()?.order;
|
||||
|
||||
if (order === undefined || order === null) return;
|
||||
|
||||
const handleStatus = function (notification: RoboNotification, order: Order): void {
|
||||
let message = emptyNotificationMessage;
|
||||
|
||||
// Order status descriptions:
|
||||
@ -254,68 +222,86 @@ 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,
|
||||
});
|
||||
};
|
||||
|
||||
// Notify on order status change
|
||||
useEffect(() => {
|
||||
const order = garage.getSlot()?.order;
|
||||
if (order !== undefined && order !== null) {
|
||||
if (order.status !== oldOrderStatus) {
|
||||
handleStatusChange(oldOrderStatus, order.status);
|
||||
setOldOrderStatus(order.status);
|
||||
} else if (order.chat_last_index > oldChatIndex) {
|
||||
if (page !== 'order') {
|
||||
notify(Messages.chatMessage);
|
||||
}
|
||||
setOldChatIndex(order.chat_last_index);
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [orderUpdatedAt]);
|
||||
};
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Notify on rewards change
|
||||
useEffect(() => {
|
||||
if (rewards !== undefined) {
|
||||
if (rewards > oldRewards) {
|
||||
notify(Messages.rewards);
|
||||
}
|
||||
setOldRewards(rewards);
|
||||
}
|
||||
}, [rewards]);
|
||||
fetchNotifications();
|
||||
}, [orderUpdatedAt, rewards]);
|
||||
|
||||
// Set blinking page title and clear on visibility change > infocus
|
||||
useEffect(() => {
|
||||
@ -336,32 +322,38 @@ const Notifications = ({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StyledTooltip
|
||||
open={show}
|
||||
placement={windowWidth > 60 ? 'left' : 'bottom'}
|
||||
title={
|
||||
<Alert
|
||||
severity={message.severity}
|
||||
action={
|
||||
<IconButton
|
||||
color='inherit'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setShow(false);
|
||||
}}
|
||||
<>
|
||||
{notifications.map((notification) => (
|
||||
<StyledTooltip
|
||||
open
|
||||
placement={windowWidth > 60 ? 'left' : 'bottom'}
|
||||
title={
|
||||
<Alert
|
||||
severity={notification.severity}
|
||||
action={
|
||||
<IconButton
|
||||
color='inherit'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setNotifications((array) => {
|
||||
return array.filter((n) => n.title !== notification.title);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Close fontSize='inherit' />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<Close fontSize='inherit' />
|
||||
</IconButton>
|
||||
<div style={{ cursor: 'pointer' }} onClick={notification.onClick}>
|
||||
{notification.title}
|
||||
</div>
|
||||
</Alert>
|
||||
}
|
||||
>
|
||||
<div style={{ cursor: 'pointer' }} onClick={message.onClick}>
|
||||
{message.title}
|
||||
</div>
|
||||
</Alert>
|
||||
}
|
||||
>
|
||||
<div style={{ ...position, visibility: 'hidden', position: 'absolute' }} />
|
||||
</StyledTooltip>
|
||||
<div style={{ ...position, visibility: 'hidden', position: 'absolute' }} />
|
||||
</StyledTooltip>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -31,6 +31,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;
|
||||
@ -208,7 +215,7 @@ export class Coordinator {
|
||||
});
|
||||
};
|
||||
|
||||
generateAllMakerAvatars = async (data: [PublicOrder]): Promise<void> => {
|
||||
generateAllMakerAvatars = async (data: PublicOrder[]): Promise<void> => {
|
||||
for (const order of data) {
|
||||
void roboidentitiesClient.generateRobohash(order.maker_hash_id, 'small');
|
||||
}
|
||||
@ -230,7 +237,7 @@ export class Coordinator {
|
||||
order.coordinatorShortAlias = this.shortAlias;
|
||||
return order;
|
||||
});
|
||||
void this.generateAllMakerAvatars(data);
|
||||
void this.generateAllMakerAvatars(data as PublicOrder[]);
|
||||
onDataLoad();
|
||||
} else {
|
||||
this.book = [];
|
||||
@ -457,6 +464,27 @@ export class Coordinator {
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
fetchNotifications = async (
|
||||
garage: Garage,
|
||||
index: string,
|
||||
gteISODate: string,
|
||||
): Promise<RoboNotification[]> => {
|
||||
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;
|
||||
|
@ -10,10 +10,10 @@ export type { Exchange } from './Exchange.model';
|
||||
export type { Maker } from './Maker.model';
|
||||
export type { Order } from './Order.model';
|
||||
export type { Book, PublicOrder } from './Book.model';
|
||||
export type { Slot } from './Garage.model';
|
||||
export type { Slot } from './Slot.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';
|
||||
|
@ -10,7 +10,7 @@ export interface ApiClient {
|
||||
useProxy: boolean;
|
||||
post: (baseUrl: string, path: string, body: object, auth?: Auth) => Promise<object | undefined>;
|
||||
put: (baseUrl: string, path: string, body: object, auth?: Auth) => Promise<object | undefined>;
|
||||
get: (baseUrl: string, path: string, auth?: Auth) => Promise<object | undefined>;
|
||||
get: (baseUrl: string, path: string, auth?: Auth) => Promise<object | object[] | undefined>;
|
||||
delete: (baseUrl: string, path: string, auth?: Auth) => Promise<object | undefined>;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user