diff --git a/components/Application/JoinGuildsPublic/JoinGuildsPublic.tsx b/components/Application/JoinGuildsPublic/JoinGuildsPublic.tsx index f3f9c87..e890ba5 100644 --- a/components/Application/JoinGuildsPublic/JoinGuildsPublic.tsx +++ b/components/Application/JoinGuildsPublic/JoinGuildsPublic.tsx @@ -22,12 +22,15 @@ export const JoinGuildsPublic: React.FC = () => { }) useEffect(() => { - authentication.socket.on('guilds', (data: SocketData) => { - handleSocketData({ data, setItems }) - }) + authentication?.socket?.on( + 'guilds', + (data: SocketData) => { + handleSocketData({ data, setItems }) + } + ) return () => { - authentication.socket.off('guilds') + authentication?.socket?.off('guilds') } }, [authentication.socket, setItems]) diff --git a/components/Authentication/Authentication.tsx b/components/Authentication/Authentication.tsx index 39cd626..20dc94a 100644 --- a/components/Authentication/Authentication.tsx +++ b/components/Authentication/Authentication.tsx @@ -72,7 +72,7 @@ export const Authentication: React.FC = (props) => { } else { try { const { data } = await api.post('/users/signin', formData) - const authentication = new AuthenticationClass(data) + const authentication = new AuthenticationClass(data, true) authentication.signin() await router.push('/application') return null diff --git a/components/Authentication/AuthenticationSocialMedia.tsx b/components/Authentication/AuthenticationSocialMedia.tsx index 5c39e26..cbe1e15 100644 --- a/components/Authentication/AuthenticationSocialMedia.tsx +++ b/components/Authentication/AuthenticationSocialMedia.tsx @@ -24,7 +24,7 @@ export const AuthenticationSocialMedia: React.FC = () => { useEffect(() => { const data = router.query if (isTokens(data)) { - const authentication = new Authentication(data) + const authentication = new Authentication(data, true) authentication.signin() router.push('/application').catch(() => {}) } diff --git a/contexts/Channels.tsx b/contexts/Channels.tsx index ed886c7..e154cff 100644 --- a/contexts/Channels.tsx +++ b/contexts/Channels.tsx @@ -6,6 +6,7 @@ import { useAuthentication } from '../tools/authentication' import { Channel, ChannelWithDefaultChannelId } from '../models/Channel' import { GuildsChannelsPath } from '../components/Application' import { handleSocketData, SocketData } from '../tools/handleSocketData' +import { CacheKey, CHANNELS_CACHE_KEY } from '../tools/cache' export interface Channels { channels: Channel[] @@ -27,6 +28,8 @@ export const ChannelsProvider: React.FC< const router = useRouter() const { authentication } = useAuthentication() + const cacheKey: CacheKey = `${path.guildId}-${CHANNELS_CACHE_KEY}` + const { items: channels, hasMore, @@ -35,14 +38,15 @@ export const ChannelsProvider: React.FC< setItems } = usePagination({ api: authentication.api, - url: `/guilds/${path.guildId}/channels` + url: `/guilds/${path.guildId}/channels`, + cacheKey }) useEffect(() => { - authentication.socket.on( + authentication?.socket?.on( 'channels', async (data: SocketData) => { - handleSocketData({ data, setItems }) + handleSocketData({ data, setItems, cacheKey }) if (data.action === 'delete') { await router.push( `/application/${path.guildId}/${data.item.defaultChannelId}` @@ -52,9 +56,9 @@ export const ChannelsProvider: React.FC< ) return () => { - authentication.socket.off('channels') + authentication?.socket?.off('channels') } - }, [authentication.socket, path.guildId, router, setItems]) + }, [authentication.socket, path.guildId, router, setItems, cacheKey]) useEffect(() => { resetPagination() diff --git a/contexts/GuildMember.tsx b/contexts/GuildMember.tsx index 4d7ab01..40f7b19 100644 --- a/contexts/GuildMember.tsx +++ b/contexts/GuildMember.tsx @@ -47,7 +47,7 @@ export const GuildMemberProvider: React.FC< }, [path, authentication.api]) useEffect(() => { - authentication.socket.on( + authentication?.socket?.on( 'guilds', async (data: SocketData) => { if (data.item.id === path.guildId) { @@ -72,7 +72,7 @@ export const GuildMemberProvider: React.FC< ) return () => { - authentication.socket.off('guilds') + authentication?.socket?.off('guilds') } }, [authentication.socket, path.guildId, router]) diff --git a/contexts/Guilds.tsx b/contexts/Guilds.tsx index 6a6cf4d..6c7b792 100644 --- a/contexts/Guilds.tsx +++ b/contexts/Guilds.tsx @@ -4,6 +4,7 @@ import { NextPage, usePagination } from '../hooks/usePagination' import { useAuthentication } from '../tools/authentication' import { GuildWithDefaultChannelId } from '../models/Guild' import { handleSocketData, SocketData } from '../tools/handleSocketData' +import { GUILDS_CACHE_KEY } from '../tools/cache' export interface Guilds { guilds: GuildWithDefaultChannelId[] @@ -29,19 +30,20 @@ export const GuildsProvider: React.FC> = ( setItems } = usePagination({ api: authentication.api, - url: '/guilds' + url: '/guilds', + cacheKey: GUILDS_CACHE_KEY }) useEffect(() => { - authentication.socket.on( + authentication?.socket?.on( 'guilds', (data: SocketData) => { - handleSocketData({ data, setItems }) + handleSocketData({ data, setItems, cacheKey: GUILDS_CACHE_KEY }) } ) return () => { - authentication.socket.off('guilds') + authentication?.socket?.off('guilds') } }, [authentication.socket, setItems]) diff --git a/contexts/Members.tsx b/contexts/Members.tsx index 5a9cac9..ac8bc15 100644 --- a/contexts/Members.tsx +++ b/contexts/Members.tsx @@ -6,6 +6,7 @@ import { MemberWithPublicUser } from '../models/Member' import { GuildsChannelsPath } from '../components/Application' import { handleSocketData, SocketData } from '../tools/handleSocketData' import { User } from '../models/User' +import { CacheKey, MEMBERS_CACHE_KEY } from '../tools/cache' export interface Members { members: MemberWithPublicUser[] @@ -27,6 +28,8 @@ export const MembersProviders: React.FC< const { authentication } = useAuthentication() + const cacheKey: CacheKey = `${path.guildId}-${MEMBERS_CACHE_KEY}` + const { items: members, hasMore, @@ -35,18 +38,19 @@ export const MembersProviders: React.FC< setItems } = usePagination({ api: authentication.api, - url: `/guilds/${path.guildId}/members` + url: `/guilds/${path.guildId}/members`, + cacheKey }) useEffect(() => { - authentication.socket.on( + authentication?.socket?.on( 'members', (data: SocketData) => { - handleSocketData({ data, setItems }) + handleSocketData({ data, setItems, cacheKey }) } ) - authentication.socket.on('users', (data: SocketData) => { + authentication?.socket?.on('users', (data: SocketData) => { setItems((oldItems) => { const newItems = [...oldItems] switch (data.action) { @@ -65,10 +69,10 @@ export const MembersProviders: React.FC< }) return () => { - authentication.socket.off('members') - authentication.socket.off('users') + authentication?.socket?.off('members') + authentication?.socket?.off('users') } - }, [authentication.socket, setItems]) + }, [authentication.socket, setItems, cacheKey]) useEffect(() => { resetPagination() diff --git a/contexts/Messages.tsx b/contexts/Messages.tsx index 4b35a3f..dca08bf 100644 --- a/contexts/Messages.tsx +++ b/contexts/Messages.tsx @@ -5,6 +5,7 @@ import { useAuthentication } from '../tools/authentication' import { MessageWithMember } from '../models/Message' import { GuildsChannelsPath } from '../components/Application' import { handleSocketData, SocketData } from '../tools/handleSocketData' +import { CacheKey, MESSAGES_CACHE_KEY } from '../tools/cache' export interface Messages { messages: MessageWithMember[] @@ -25,6 +26,8 @@ export const MessagesProvider: React.FC< const { path, children } = props const { authentication, user } = useAuthentication() + const cacheKey: CacheKey = `${path.channelId}-${MESSAGES_CACHE_KEY}` + const { items: messages, hasMore, @@ -34,11 +37,12 @@ export const MessagesProvider: React.FC< } = usePagination({ api: authentication.api, url: `/channels/${path.channelId}/messages`, - inverse: true + inverse: true, + cacheKey }) useEffect(() => { - authentication.socket.on( + authentication?.socket?.on( 'messages', (data: SocketData) => { if (data.item.channelId === path.channelId) { @@ -48,7 +52,7 @@ export const MessagesProvider: React.FC< const isAtBottom = messagesDiv.scrollHeight - messagesDiv.scrollTop <= messagesDiv.clientHeight - handleSocketData({ data, setItems }) + handleSocketData({ data, setItems, cacheKey }) if ( data.action === 'create' && (isAtBottom || data.item.member.userId === user.id) @@ -60,9 +64,9 @@ export const MessagesProvider: React.FC< ) return () => { - authentication.socket.off('messages') + authentication?.socket?.off('messages') } - }, [authentication.socket, setItems, path, user.id]) + }, [authentication.socket, setItems, path, user.id, cacheKey]) useEffect(() => { resetPagination() diff --git a/hooks/usePagination.ts b/hooks/usePagination.ts index 7834f61..30bf42e 100644 --- a/hooks/usePagination.ts +++ b/hooks/usePagination.ts @@ -2,6 +2,11 @@ import { useState, useRef, useCallback } from 'react' import { AxiosInstance } from 'axios' import { FetchState } from './useFetchState' +import { + CacheKey, + getPaginationCache, + savePaginationCache +} from '../tools/cache' export interface Query { [key: string]: string @@ -13,6 +18,11 @@ export interface UsePaginationOptions { api: AxiosInstance url: string inverse?: boolean + cacheKey?: CacheKey +} + +export interface PaginationItem { + id: number } export type SetItems = React.Dispatch> @@ -25,16 +35,25 @@ export interface UsePaginationResult { setItems: SetItems } -export const usePagination = ( +export const usePagination = ( options: UsePaginationOptions ): UsePaginationResult => { - const { api, url, inverse = false } = options + const { api, url, inverse = false, cacheKey } = options const [items, setItems] = useState([]) const [hasMore, setHasMore] = useState(true) const fetchState = useRef('idle') const afterId = useRef(null) + const updateAfterId = (newItems: T[]): void => { + if (!inverse) { + afterId.current = + newItems.length > 0 ? newItems[newItems.length - 1].id : null + } else { + afterId.current = newItems.length > 0 ? newItems[0].id : null + } + } + const nextPageAsync: NextPageAsync = useCallback( async (query) => { if (fetchState.current !== 'idle') { @@ -49,19 +68,22 @@ export const usePagination = ( const { data: newItems } = await api.get( `${url}?${searchParameters.toString()}` ) - if (!inverse) { - afterId.current = - newItems.length > 0 ? newItems[newItems.length - 1].id : null - } else { - afterId.current = newItems.length > 0 ? newItems[0].id : null - } + updateAfterId(newItems) setItems((oldItems) => { - return inverse ? [...newItems, ...oldItems] : [...oldItems, ...newItems] + const updatedItems = inverse + ? [...newItems, ...oldItems] + : [...oldItems, ...newItems] + if (cacheKey != null) { + savePaginationCache(cacheKey, updatedItems) + } + return updatedItems }) setHasMore(newItems.length > 0) fetchState.current = 'idle' }, - [api, url, inverse] + + // eslint-disable-next-line react-hooks/exhaustive-deps -- We don't want infinite loops with updateAfterId + [api, url, inverse, cacheKey] ) const nextPage: NextPage = useCallback( @@ -80,9 +102,17 @@ export const usePagination = ( ) const resetPagination = useCallback((): void => { - afterId.current = null - setItems([]) - }, []) + if (cacheKey == null) { + afterId.current = null + setItems([]) + } else { + const newItems = getPaginationCache(cacheKey) + setItems(newItems) + updateAfterId(newItems) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps -- We don't want infinite loops with updateAfterId + }, [cacheKey]) return { items, hasMore, nextPage, resetPagination, setItems } } diff --git a/next.config.js b/next.config.js index b4d51ff..c54edc8 100644 --- a/next.config.js +++ b/next.config.js @@ -7,7 +7,7 @@ const nextTranslate = require('next-translate') /** @type {import("next").NextConfig} */ module.exports = nextTranslate( nextPWA({ - reactStrictMode: true, + reactStrictMode: false, images: { domains: [ 'api.thream.divlo.fr', diff --git a/pages/_app.tsx b/pages/_app.tsx index 88b9c98..914cb46 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -28,6 +28,9 @@ const Application = ({ Component, pageProps }: AppProps): JSX.Element => { } window.addEventListener('resize', appHeight) appHeight() + return () => { + window.removeEventListener('resize', appHeight) + } }, [lang]) return ( diff --git a/tools/authentication/Authentication.ts b/tools/authentication/Authentication.ts index 9058fb4..a00868d 100644 --- a/tools/authentication/Authentication.ts +++ b/tools/authentication/Authentication.ts @@ -9,27 +9,36 @@ import { fetchRefreshToken } from './authenticationFromServerSide' export class Authentication { public tokens: Tokens public accessTokenAge: number - public socket: Socket + public socket?: Socket public api: AxiosInstance - constructor(tokens: Tokens) { + constructor(tokens: Tokens, disableSocketIO: boolean = false) { this.tokens = tokens this.accessTokenAge = Date.now() - this.socket = io(API_URL, { - auth: { token: `Bearer ${tokens.accessToken}` } - }) - this.socket.on('connect_error', (error) => { - if (error.message.startsWith('Unauthorized')) { - fetchRefreshToken(this.tokens.refreshToken) - .then(({ accessToken }) => { - this.setAccessToken(accessToken) - }) - .catch(async () => { - this.signout() - return await Promise.reject(error) - }) - } - }) + if (disableSocketIO || typeof window === 'undefined') { + this.socket = undefined + } else { + this.socket = io(API_URL, { + auth: { token: `Bearer ${tokens.accessToken}` } + }) + this.socket.on('connect', () => { + console.log( + `Connected to socket with clientId: ${this.socket?.id ?? 'undefined'}` + ) + }) + this.socket.on('connect_error', (error) => { + if (error.message.startsWith('Unauthorized')) { + fetchRefreshToken(this.tokens.refreshToken) + .then(({ accessToken }) => { + this.setAccessToken(accessToken) + }) + .catch(async () => { + this.signout() + return await Promise.reject(error) + }) + } + }) + } this.api = axios.create({ baseURL: API_URL, headers: { @@ -83,13 +92,14 @@ export class Authentication { this.tokens.accessToken = accessToken this.accessTokenAge = Date.now() const token = `${this.tokens.type} ${this.tokens.accessToken}` - if (typeof this.socket.auth !== 'function') { + if (typeof this?.socket?.auth !== 'function' && this.socket != null) { this.socket.auth.token = token } } public signout(): void { cookies.remove('refreshToken') + window.localStorage.clear() window.location.href = '/authentication/signin' } diff --git a/tools/authentication/AuthenticationContext.tsx b/tools/authentication/AuthenticationContext.tsx index b62f85c..208f2af 100644 --- a/tools/authentication/AuthenticationContext.tsx +++ b/tools/authentication/AuthenticationContext.tsx @@ -25,7 +25,8 @@ export const AuthenticationProvider: React.FC< const [user, setUser] = useState(props.authentication.user) const authentication = useMemo(() => { - return new Authentication(props.authentication.tokens) + const disableSocketIO = typeof window === 'undefined' + return new Authentication(props.authentication.tokens, disableSocketIO) // eslint-disable-next-line react-hooks/exhaustive-deps -- We only want to run this memo once }, []) @@ -33,7 +34,9 @@ export const AuthenticationProvider: React.FC< useEffect(() => { setLanguage(props.authentication.user.settings.language).catch(() => {}) setTheme(props.authentication.user.settings.theme) - + return () => { + authentication?.socket?.disconnect() + } // eslint-disable-next-line react-hooks/exhaustive-deps -- We only want to run this effect once }, []) diff --git a/tools/authentication/authenticationFromServerSide.ts b/tools/authentication/authenticationFromServerSide.ts index 4d48a31..29e2e85 100644 --- a/tools/authentication/authenticationFromServerSide.ts +++ b/tools/authentication/authenticationFromServerSide.ts @@ -71,7 +71,7 @@ export const authenticationFromServerSide = ( } else { try { let data: any = {} - const authentication = new Authentication(tokens) + const authentication = new Authentication(tokens, true) const { data: currentUser } = await authentication.api.get< unknown, AxiosResponse diff --git a/tools/cache.ts b/tools/cache.ts new file mode 100644 index 0000000..2baa15a --- /dev/null +++ b/tools/cache.ts @@ -0,0 +1,34 @@ +import { PaginationItem } from '../hooks/usePagination' + +export const GUILDS_CACHE_KEY = 'guilds' as const +export const CHANNELS_CACHE_KEY = 'channels' as const +export const MEMBERS_CACHE_KEY = 'members' as const +export const MESSAGES_CACHE_KEY = 'messages' as const + +export type CacheKey = + | typeof GUILDS_CACHE_KEY + | `${number}-${typeof CHANNELS_CACHE_KEY}` + | `${number}-${typeof MEMBERS_CACHE_KEY}` + | `${number}-${typeof MESSAGES_CACHE_KEY}` + +export const getPaginationCache = ( + key: CacheKey +): T[] => { + const cache = localStorage.getItem(key) + if (cache != null) { + try { + const data = JSON.parse(cache) + if (Array.isArray(data)) { + return data + } + } catch {} + } + return [] +} + +export const savePaginationCache = ( + key: CacheKey, + data: T[] +): void => { + localStorage.setItem(key, JSON.stringify(data)) +} diff --git a/tools/handleSocketData.ts b/tools/handleSocketData.ts index e9909b2..9012a44 100644 --- a/tools/handleSocketData.ts +++ b/tools/handleSocketData.ts @@ -1,4 +1,5 @@ import { SetItems } from '../hooks/usePagination' +import { CacheKey, savePaginationCache } from './cache' export interface Item { id: number @@ -13,6 +14,7 @@ export interface SocketData { export interface HandleSocketDataOptions { setItems: SetItems data: SocketData + cacheKey?: CacheKey } export type SocketListener = (data: SocketData) => void @@ -20,7 +22,8 @@ export type SocketListener = (data: SocketData) => void export const handleSocketData = ( options: HandleSocketDataOptions ): void => { - const { data, setItems } = options + const { data, setItems, cacheKey } = options + console.log('socket.io data received: ', data) setItems((oldItems) => { const newItems = [...oldItems] @@ -47,6 +50,9 @@ export const handleSocketData = ( break } } + if (cacheKey != null) { + savePaginationCache(cacheKey, newItems) + } return newItems }) }