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:
Adrian Brzeziński 2023-04-28 23:36:24 +02:00 committed by GitHub
parent 1f30784930
commit 1f2e90bdf8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 327 additions and 50 deletions

View File

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

View File

@ -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)
}

View File

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

View File

@ -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)

View File

@ -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

View File

@ -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>

View File

@ -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()])
}

View File

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

View File

@ -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,
}
)
}

View File

@ -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])
}

View File

@ -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],

View File

@ -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])
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -50,5 +50,8 @@
"trade-layout": "交易佈局",
"trading-view": "Trading View",
"transaction-fail": "交易失敗",
"transaction-success": "交易成功"
"transaction-success": "交易成功",
"notifications": "通知",
"limit-order-filled": "限价单成交",
"sign-to-notifications": "登录通知中心以更改设置"
}

View File

@ -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

View File

@ -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'

View File

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