diff --git a/apis/notifications/notificationSettings.ts b/apis/notifications/notificationSettings.ts new file mode 100644 index 00000000..cf3c4684 --- /dev/null +++ b/apis/notifications/notificationSettings.ts @@ -0,0 +1,27 @@ +import { NOTIFICATION_API } from 'utils/constants' + +export type NotificationSettings = { + fillsNotifications: boolean +} + +export const fetchNotificationSettings = async ( + wallet: string, + token: string +) => { + const data = await fetch( + `${NOTIFICATION_API}notifications/user/getSettings`, + { + headers: { + authorization: token, + publickey: wallet, + }, + } + ) + const body = await data.json() + + if (body.error) { + throw { error: body.error, status: data.status } + } + + return body as NotificationSettings +} diff --git a/apis/notifications.ts b/apis/notifications/notifications.ts similarity index 88% rename from apis/notifications.ts rename to apis/notifications/notifications.ts index e8a6e633..524c94ab 100644 --- a/apis/notifications.ts +++ b/apis/notifications/notifications.ts @@ -19,5 +19,5 @@ export const fetchNotifications = async (wallet: string, token: string) => { if (body.error) { throw { error: body.error, status: data.status } } - return body as Notification[] + return (body as Notification[]).sort((a, b) => b.id - a.id) } diff --git a/apis/notifications/websocket.ts b/apis/notifications/websocket.ts new file mode 100644 index 00000000..46d9db7f --- /dev/null +++ b/apis/notifications/websocket.ts @@ -0,0 +1,62 @@ +import { NOTIFICATION_API_WEBSOCKET } from 'utils/constants' + +export class NotificationsWebSocket { + ws: WebSocket | null = null + token: string + publicKey: string + pingInterval: NodeJS.Timer | null + retryCount = 0 + maxRetries = 2 + + constructor(token: string, publicKey: string) { + this.token = token + this.publicKey = publicKey + this.pingInterval = null + } + + connect() { + const wsUrl = new URL(NOTIFICATION_API_WEBSOCKET) + wsUrl.searchParams.append('authorization', this.token) + wsUrl.searchParams.append('publickey', this.publicKey) + this.ws = new WebSocket(wsUrl) + + this.ws.addEventListener('open', () => { + console.log('Notifications WebSocket opened') + // Send a ping message to the server every 10 seconds + const interval = setInterval(() => { + if (this.ws?.readyState === this.ws?.OPEN) { + this.ws?.send('ping') + } + }, 30000) + this.pingInterval = interval + }) + + this.ws.addEventListener('close', (event: CloseEvent) => { + console.log('Notifications WebSocket closed') + this.handleClearSocketInterval() + //1000 close form clinet + //1008 unauthorized + if ( + event.code !== 1000 && + event.code !== 1008 && + this.retryCount < this.maxRetries + ) { + this.retryCount++ + setTimeout(() => { + this.connect() + }, 5000) + } + }) + + this.ws.addEventListener('error', (event) => { + console.log('WebSocket error:', event) + }) + return this + } + handleClearSocketInterval() { + if (this.pingInterval) { + clearInterval(this.pingInterval) + this.pingInterval = null + } + } +} diff --git a/components/notifications/NotificationsButton.tsx b/components/notifications/NotificationsButton.tsx index aa45f570..072ab5d3 100644 --- a/components/notifications/NotificationsButton.tsx +++ b/components/notifications/NotificationsButton.tsx @@ -1,14 +1,15 @@ -import { useCookies } from 'hooks/notifications/useCookies' import { useNotifications } from 'hooks/notifications/useNotifications' import { useMemo, useState } from 'react' import NotificationsDrawer from './NotificationsDrawer' -import { useToaster } from 'hooks/notifications/useToaster' import { BellIcon } from '@heroicons/react/20/solid' import { useIsAuthorized } from 'hooks/notifications/useIsAuthorized' +import { useCookies } from 'hooks/notifications/useCookies' +import { useNotificationSocket } from 'hooks/notifications/useNotificationSocket' const NotificationsButton = () => { useCookies() - useToaster() + useNotificationSocket() + const { data, isFetching } = useNotifications() const isAuth = useIsAuthorized() const [showDraw, setShowDraw] = useState(false) diff --git a/components/settings/NotificationSettings.tsx b/components/settings/NotificationSettings.tsx new file mode 100644 index 00000000..b39ca1cb --- /dev/null +++ b/components/settings/NotificationSettings.tsx @@ -0,0 +1,68 @@ +import Switch from '@components/forms/Switch' +import { useHeaders } from 'hooks/notifications/useHeaders' +import { useIsAuthorized } from 'hooks/notifications/useIsAuthorized' +import { useNotificationSettings } from 'hooks/notifications/useNotificationSettings' +import { useTranslation } from 'next-i18next' +import { NOTIFICATION_API } from 'utils/constants' + +export const INITIAL_SOUND_SETTINGS = { + 'recent-trades': false, + 'swap-success': false, + 'transaction-success': false, + 'transaction-fail': false, +} + +const NotificationSettings = () => { + const { t } = useTranslation(['common', 'settings']) + const { data, refetch } = useNotificationSettings() + const headers = useHeaders() + const isAuth = useIsAuthorized() + + const handleSettingChange = async (key: string, val: boolean) => { + if (data) { + const newSettings = { + ...data, + [key]: val, + } + await fetch(`${NOTIFICATION_API}notifications/user/editSettings`, { + method: 'POST', + headers: headers.headers, + body: JSON.stringify({ + ...newSettings, + }), + }) + refetch() + } + } + return ( + <> +
+

{t('settings:notifications')}

+
+ {isAuth ? ( +
+

{t('settings:limit-order-filled')}

+ + handleSettingChange( + 'fillsNotifications', + !data?.fillsNotifications + ) + } + /> +
+ ) : ( +
+
+

+ {t('settings:sign-to-notifications')} +

+
+
+ )} + + ) +} + +export default NotificationSettings diff --git a/components/settings/SettingsPage.tsx b/components/settings/SettingsPage.tsx index 741a1246..acae85e0 100644 --- a/components/settings/SettingsPage.tsx +++ b/components/settings/SettingsPage.tsx @@ -1,5 +1,6 @@ import AnimationSettings from './AnimationSettings' import DisplaySettings from './DisplaySettings' +import NotificationSettings from './NotificationSettings' import PreferredExplorerSettings from './PreferredExplorerSettings' import RpcSettings from './RpcSettings' import SoundSettings from './SoundSettings' @@ -19,6 +20,9 @@ const SettingsPage = () => {
+
+ +
diff --git a/hooks/notifications/useCookies.ts b/hooks/notifications/useCookies.ts index 65efa4d6..f9ab37ec 100644 --- a/hooks/notifications/useCookies.ts +++ b/hooks/notifications/useCookies.ts @@ -10,24 +10,34 @@ type Error = { } export function useCookies() { - const wallet = useWallet() + const { publicKey, disconnecting } = useWallet() const updateCookie = NotificationCookieStore((s) => s.updateCookie) const removeCookie = NotificationCookieStore((s) => s.removeCookie) + const resetCurrentToken = NotificationCookieStore((s) => s.resetCurrentToken) const token = NotificationCookieStore((s) => s.currentToken) const { error } = useNotifications() const errorResp = error as Error useEffect(() => { - updateCookie(wallet.publicKey?.toBase58()) - }, [wallet.publicKey?.toBase58()]) + updateCookie(publicKey?.toBase58()) + }, [publicKey?.toBase58()]) useEffect(() => { - if (errorResp?.status === 401 && wallet.publicKey && token) { - removeCookie(wallet.publicKey?.toBase58()) + if (disconnecting) { + resetCurrentToken() + } + return () => { + resetCurrentToken() + } + }, [disconnecting]) + + useEffect(() => { + if (errorResp?.status === 401 && publicKey && token) { + removeCookie(publicKey?.toBase58()) notify({ title: errorResp.error, type: 'error', }) } - }, [errorResp, wallet.publicKey?.toBase58()]) + }, [errorResp, publicKey?.toBase58()]) } diff --git a/hooks/notifications/useIsAuthorized.ts b/hooks/notifications/useIsAuthorized.ts index 00d0bc28..d86ad9d4 100644 --- a/hooks/notifications/useIsAuthorized.ts +++ b/hooks/notifications/useIsAuthorized.ts @@ -3,13 +3,17 @@ import { useNotifications } from './useNotifications' import NotificationCookieStore from '@store/notificationCookieStore' export function useIsAuthorized() { - const wallet = useWallet() + const { publicKey, connected } = useWallet() const { error, isFetched, isLoading } = useNotifications() - const walletPubKey = wallet.publicKey?.toBase58() const token = NotificationCookieStore((s) => s.currentToken) const isAuthorized = - walletPubKey && token && !error && isFetched && !isLoading + publicKey?.toBase58() && + token && + !error && + isFetched && + !isLoading && + connected return isAuthorized } diff --git a/hooks/notifications/useNotificationSettings.ts b/hooks/notifications/useNotificationSettings.ts new file mode 100644 index 00000000..ca29d3d0 --- /dev/null +++ b/hooks/notifications/useNotificationSettings.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query' +import NotificationCookieStore from '@store/notificationCookieStore' +import { useWallet } from '@solana/wallet-adapter-react' +import { fetchNotificationSettings } from 'apis/notifications/notificationSettings' +import { useIsAuthorized } from './useIsAuthorized' + +export function useNotificationSettings() { + const { publicKey } = useWallet() + const walletPubKey = publicKey?.toBase58() + const token = NotificationCookieStore((s) => s.currentToken) + const isAuth = useIsAuthorized() + + const criteria = walletPubKey && token && isAuth + + return useQuery( + ['notificationSettings', criteria], + () => fetchNotificationSettings(walletPubKey!, token!), + { + enabled: !!isAuth, + retry: 1, + staleTime: 86400000, + } + ) +} diff --git a/hooks/notifications/useNotificationSocket.ts b/hooks/notifications/useNotificationSocket.ts new file mode 100644 index 00000000..af4259e5 --- /dev/null +++ b/hooks/notifications/useNotificationSocket.ts @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react' +import { notify } from 'utils/notifications' +import { useIsAuthorized } from './useIsAuthorized' +import { useWallet } from '@solana/wallet-adapter-react' +import NotificationCookieStore from '@store/notificationCookieStore' +import { useQueryClient } from '@tanstack/react-query' +import { Notification } from 'apis/notifications/notifications' +import { tryParse } from 'utils/formatting' +import { NotificationsWebSocket } from 'apis/notifications/websocket' + +export function useNotificationSocket() { + const isAuth = useIsAuthorized() + const { publicKey } = useWallet() + const token = NotificationCookieStore((s) => s.currentToken) + + const queryClient = useQueryClient() + const criteria = publicKey?.toBase58() && token + + const [socket, setSocket] = useState(null) + + useEffect(() => { + if (socket && socket?.readyState === socket?.OPEN) { + socket!.close(1000, 'hook') + } + + let ws: WebSocket | null = null + if (isAuth && publicKey && token) { + const notificationWs = new NotificationsWebSocket( + token, + publicKey.toBase58() + ).connect() + ws = notificationWs.ws! + + ws.addEventListener('message', (event) => { + const data = tryParse(event.data) + if (data.eventType === 'newNotification') { + const newNotification = data.payload as Notification + //we notify user about new data + notify({ + title: newNotification.title, + description: newNotification.content, + type: 'info', + }) + //we push new data to our notifications data + queryClient.setQueryData( + ['notifications', criteria], + (prevData) => { + if (!prevData) { + return [] + } + // Modify prevData with newData and return the updated data + return [newNotification, ...prevData] + } + ) + } + }) + + setSocket(ws) + } + + // Clean up the WebSocket connection on unmount + return () => { + if (ws?.readyState === ws?.OPEN) { + ws?.close(1000, 'hook') + } + + if (socket?.readyState === socket?.OPEN) { + socket?.close(1000, 'hook') + } + } + }, [isAuth, token]) +} diff --git a/hooks/notifications/useNotifications.ts b/hooks/notifications/useNotifications.ts index 53bd5185..390ea278 100644 --- a/hooks/notifications/useNotifications.ts +++ b/hooks/notifications/useNotifications.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { fetchNotifications } from 'apis/notifications' +import { fetchNotifications } from 'apis/notifications/notifications' import NotificationCookieStore from '@store/notificationCookieStore' import { useWallet } from '@solana/wallet-adapter-react' @@ -7,10 +7,10 @@ import { useWallet } from '@solana/wallet-adapter-react' const refetchMs = 600000 export function useNotifications() { - const wallet = useWallet() - const walletPubKey = wallet.publicKey?.toBase58() + const { publicKey } = useWallet() + const walletPubKey = publicKey?.toBase58() const token = NotificationCookieStore((s) => s.currentToken) - const criteria = `${walletPubKey}${token}` + const criteria = walletPubKey && token return useQuery( ['notifications', criteria], diff --git a/hooks/notifications/useToaster.ts b/hooks/notifications/useToaster.ts deleted file mode 100644 index cc386e83..00000000 --- a/hooks/notifications/useToaster.ts +++ /dev/null @@ -1,28 +0,0 @@ -import usePrevious from '@components/shared/usePrevious' -import { useNotifications } from './useNotifications' -import { useEffect } from 'react' -import { Notification } from '../../apis/notifications' -import { notify } from 'utils/notifications' - -export function useToaster() { - const { data } = useNotifications() - const previousData = usePrevious(data) - - useEffect(() => { - if (data && data.length && previousData) { - const oldIds = previousData.map((item: Notification) => item.id) - const newObjects = data.filter( - (item: Notification) => !oldIds.includes(item.id) - ) - if (newObjects.length) { - newObjects.map((x) => - notify({ - title: 'New message', - description: x.title, - type: 'info', - }) - ) - } - } - }, [data, previousData]) -} diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index d3fad0cb..226dcaa0 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -50,5 +50,8 @@ "transaction-fail": "Transaction Fail", "transaction-success": "Transaction Success", "trade-chart": "Trade Chart", - "trading-view": "Trading View" + "trading-view": "Trading View", + "notifications": "Notifications", + "limit-order-filled": "Limit order filled", + "sign-to-notifications": "Sign to notifications center to change settings" } \ No newline at end of file diff --git a/public/locales/es/settings.json b/public/locales/es/settings.json index d3fad0cb..226dcaa0 100644 --- a/public/locales/es/settings.json +++ b/public/locales/es/settings.json @@ -50,5 +50,8 @@ "transaction-fail": "Transaction Fail", "transaction-success": "Transaction Success", "trade-chart": "Trade Chart", - "trading-view": "Trading View" + "trading-view": "Trading View", + "notifications": "Notifications", + "limit-order-filled": "Limit order filled", + "sign-to-notifications": "Sign to notifications center to change settings" } \ No newline at end of file diff --git a/public/locales/ru/settings.json b/public/locales/ru/settings.json index d3fad0cb..226dcaa0 100644 --- a/public/locales/ru/settings.json +++ b/public/locales/ru/settings.json @@ -50,5 +50,8 @@ "transaction-fail": "Transaction Fail", "transaction-success": "Transaction Success", "trade-chart": "Trade Chart", - "trading-view": "Trading View" + "trading-view": "Trading View", + "notifications": "Notifications", + "limit-order-filled": "Limit order filled", + "sign-to-notifications": "Sign to notifications center to change settings" } \ No newline at end of file diff --git a/public/locales/zh/settings.json b/public/locales/zh/settings.json index d3fad0cb..226dcaa0 100644 --- a/public/locales/zh/settings.json +++ b/public/locales/zh/settings.json @@ -50,5 +50,8 @@ "transaction-fail": "Transaction Fail", "transaction-success": "Transaction Success", "trade-chart": "Trade Chart", - "trading-view": "Trading View" + "trading-view": "Trading View", + "notifications": "Notifications", + "limit-order-filled": "Limit order filled", + "sign-to-notifications": "Sign to notifications center to change settings" } \ No newline at end of file diff --git a/public/locales/zh_tw/settings.json b/public/locales/zh_tw/settings.json index e6121787..2c1c1f81 100644 --- a/public/locales/zh_tw/settings.json +++ b/public/locales/zh_tw/settings.json @@ -50,5 +50,8 @@ "trade-layout": "交易佈局", "trading-view": "Trading View", "transaction-fail": "交易失敗", - "transaction-success": "交易成功" + "transaction-success": "交易成功", + "notifications": "通知", + "limit-order-filled": "限价单成交", + "sign-to-notifications": "登录通知中心以更改设置" } \ No newline at end of file diff --git a/store/notificationCookieStore.ts b/store/notificationCookieStore.ts index a6eb96f6..daad523d 100644 --- a/store/notificationCookieStore.ts +++ b/store/notificationCookieStore.ts @@ -8,6 +8,7 @@ type ICookieStore = { updateCookie: (wallet?: string) => void removeCookie: (wallet: string) => void setCookie: (wallet: string, token: string) => void + resetCurrentToken: () => void } const CookieStore = create((set, get) => ({ @@ -36,6 +37,12 @@ const CookieStore = create((set, get) => ({ state.currentToken = token }) }, + resetCurrentToken: async () => { + const set = get().set + set((state) => { + state.currentToken = '' + }) + }, })) export default CookieStore diff --git a/utils/constants.ts b/utils/constants.ts index 46dfca4d..f994483a 100644 --- a/utils/constants.ts +++ b/utils/constants.ts @@ -91,3 +91,5 @@ export const JUPITER_API_MAINNET = 'https://token.jup.ag/strict' export const JUPITER_API_DEVNET = 'https://api.jup.ag/api/tokens/devnet' export const NOTIFICATION_API = 'https://notifications-api.herokuapp.com/' +export const NOTIFICATION_API_WEBSOCKET = + 'wss://notifications-api.herokuapp.com/ws' diff --git a/utils/formatting.ts b/utils/formatting.ts index 1503fdc4..c392bf8e 100644 --- a/utils/formatting.ts +++ b/utils/formatting.ts @@ -13,3 +13,12 @@ export const formatYAxis = (value: number) => { ? numberCompacter.format(value) : formatNumericValue(value, 4) } + +export const tryParse = (val: string) => { + try { + const json = JSON.parse(val) + return json + } catch (e) { + return val + } +}