notifications settings + websockets (#139)
* notification settings * fix * websockets * fix url * websockets * fix * fix * fix api url * fix hook * reconnect sockets * fix url * fix double connection move back hooks to btn * fix * fix dependncy array
This commit is contained in:
parent
1f30784930
commit
1f2e90bdf8
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-base">{t('settings:notifications')}</h2>
|
||||
</div>
|
||||
{isAuth ? (
|
||||
<div className="flex items-center justify-between border-t border-th-bkg-3 p-4">
|
||||
<p>{t('settings:limit-order-filled')}</p>
|
||||
<Switch
|
||||
checked={!!data?.fillsNotifications}
|
||||
onChange={() =>
|
||||
handleSettingChange(
|
||||
'fillsNotifications',
|
||||
!data?.fillsNotifications
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative top-1/2 flex -translate-y-1/2 flex-col justify-center px-6 pb-20">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<h3 className="mb-1 text-base">
|
||||
{t('settings:sign-to-notifications')}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationSettings
|
|
@ -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 = () => {
|
|||
<div className="col-span-12 border-b border-th-bkg-3 pt-8 lg:col-span-10 lg:col-start-2 xl:col-span-8 xl:col-start-3">
|
||||
<SoundSettings />
|
||||
</div>
|
||||
<div className="col-span-12 border-b border-th-bkg-3 pt-8 lg:col-span-10 lg:col-start-2 xl:col-span-8 xl:col-start-3">
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
<div className="col-span-12 pt-8 lg:col-span-10 lg:col-start-2 xl:col-span-8 xl:col-start-3">
|
||||
<PreferredExplorerSettings />
|
||||
</div>
|
||||
|
|
|
@ -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()])
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
}
|
|
@ -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<WebSocket | null>(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<Notification[]>(
|
||||
['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])
|
||||
}
|
|
@ -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],
|
||||
|
|
|
@ -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])
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -50,5 +50,8 @@
|
|||
"trade-layout": "交易佈局",
|
||||
"trading-view": "Trading View",
|
||||
"transaction-fail": "交易失敗",
|
||||
"transaction-success": "交易成功"
|
||||
"transaction-success": "交易成功",
|
||||
"notifications": "通知",
|
||||
"limit-order-filled": "限价单成交",
|
||||
"sign-to-notifications": "登录通知中心以更改设置"
|
||||
}
|
|
@ -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<ICookieStore>((set, get) => ({
|
||||
|
@ -36,6 +37,12 @@ const CookieStore = create<ICookieStore>((set, get) => ({
|
|||
state.currentToken = token
|
||||
})
|
||||
},
|
||||
resetCurrentToken: async () => {
|
||||
const set = get().set
|
||||
set((state) => {
|
||||
state.currentToken = ''
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
export default CookieStore
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue