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
+ }
+}