Skip to content
This repository has been archived by the owner on Feb 16, 2024. It is now read-only.

Niloofar / Footer's Network Status #38

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default {
'^Components/(.*)$': '<rootDir>/src/components/$1',
'^Constants/(.*)$': '<rootDir>/src/constants/$1',
'^Contexts/(.*)$': '<rootDir>/src/contexts/$1',
'^Hooks/(.*)$': '<rootDir>/src/hooks/$1',
'^Styles/(.*)$': '<rootDir>/src/styles/$1',
'^Translations$': '<rootDir>/src/translations/$1',
'^Types/(.*)$': '<rootDir>/src/types/$1',
Expand Down
1,500 changes: 536 additions & 964 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions src/api/hooks/useAPI.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useContext } from 'react';
import { useCallback } from 'react';

import type {
TSocketEndpointNames,
Expand All @@ -8,25 +8,25 @@ import type {
TSocketSubscribableEndpointNames,
} from 'Api/types';

import APIContext from 'Utils/websocket/APIContext';
import useAPIContext from './useAPIContext';

const useAPI = () => {
const api = useContext(APIContext);
const { socketConnection } = useAPIContext();

const send = useCallback(
async <T extends TSocketEndpointNames | TSocketPaginateableEndpointNames = TSocketEndpointNames>(
name: T,
payload?: TSocketRequestPayload<T>
): Promise<TSocketResponseData<T>> => {
const response = await api?.send({ [name]: 1, ...(payload || {}) });
const response = await socketConnection?.send({ [name]: 1, ...(payload || {}) });

if (response.error) {
throw response.error;
}

return response;
},
[api]
[socketConnection]
);

const subscribe = useCallback(
Expand All @@ -38,8 +38,8 @@ const useAPI = () => {
onData: (response: Promise<TSocketResponseData<T>>) => void,
onError: (response: Promise<TSocketResponseData<T>>) => void
) => { unsubscribe?: VoidFunction };
} => api?.subscribe({ [name]: 1, subscribe: 1, ...(payload || {}) }),
[api]
} => socketConnection?.subscribe({ [name]: 1, subscribe: 1, ...(payload || {}) }),
[socketConnection]
);

return {
Expand Down
6 changes: 6 additions & 0 deletions src/api/hooks/useAPIContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { useContext } from 'react';
import APIContext from 'Utils/websocket/APIContext';

const useAPIContext = () => useContext(APIContext);

export default useAPIContext;
4 changes: 2 additions & 2 deletions src/components/layout/footer/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ import ServerTime from './ServerTime';
import WhatsApp from './WhatsApp';

const Footer = () => (
<div className='fixed bottom-0 hidden h-9 w-full items-center justify-end border-t border-general-section-1 md:flex'>
<footer className='fixed bottom-0 hidden h-9 w-full items-center justify-end border-t border-general-section-1 md:flex'>
<NetworkStatus />
<LanguageSettings />
<ServerTime />
<WhatsApp />
<LiveChat />
<HelpCenter />
<FullScreen />
</div>
</footer>
);

export default Footer;
7 changes: 4 additions & 3 deletions src/components/layout/footer/NetworkStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { useTranslation } from 'react-i18next';
import Tooltip from 'Components/common/tooltip';
import useNetworkStatus from 'Hooks/useNetworkStatus';

// TODO complete the functionality + add tests
const NetworkStatus = () => {
const { className, tooltip } = useNetworkStatus();
const { t } = useTranslation();

return (
<Tooltip className='px-3' content={t('Network status: Online')}>
<div className='h-2 w-2 rounded-full bg-success' />
<Tooltip className='px-3' content={t('Network status: {{tooltip}}', { tooltip })}>
<div data-testid='dt_network_status_circle' className={`h-2 w-2 rounded-full ${className}`} />
</Tooltip>
);
};
Expand Down
26 changes: 26 additions & 0 deletions src/components/layout/footer/__tests__/NetworkStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react';
import useNavigatorOnline from 'Hooks/useNavigatorOnline';
import NetworkStatus from '../NetworkStatus';

jest.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));

jest.mock('Utils/websocket/APIProvider');
jest.mock('Hooks/useNavigatorOnline');

const mockUseNavigatorOnline = useNavigatorOnline as jest.MockedFunction<typeof useNavigatorOnline>;

describe('NetworkStatus component', () => {
it('Should have bg-success class when the user is online', () => {
mockUseNavigatorOnline.mockReturnValue(true);
render(<NetworkStatus />);
expect(screen.getByTestId('dt_network_status_circle')).toHaveClass('bg-success');
});

it('Should have bg-danger class when the user is offline', () => {
mockUseNavigatorOnline.mockReturnValue(false);
render(<NetworkStatus />);
expect(screen.getByTestId('dt_network_status_circle')).toHaveClass('bg-danger');
});
});
33 changes: 33 additions & 0 deletions src/hooks/__tests__/useNavigatorOnline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { act, renderHook } from '@testing-library/react';
import useNavigatorOnline from 'Hooks/useNavigatorOnline';

describe('useNavigatorOnline hook', () => {
it('Should return true by default', () => {
const { result } = renderHook(() => useNavigatorOnline());
expect(result.current).toBeTruthy();
});

it('Should update the status to true when it is online', () => {
const { result } = renderHook(() => useNavigatorOnline());
act(() => window.dispatchEvent(new Event('online')));
expect(result.current).toBeTruthy();
});

it('Should update the status to false when it is offline', () => {
const { result } = renderHook(() => useNavigatorOnline());
act(() => window.dispatchEvent(new Event('offline')));
expect(result.current).toBeFalsy();
});

it('Should remove event listeners on unmount', () => {
const { unmount } = renderHook(() => useNavigatorOnline());
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');

unmount();

expect(removeEventListenerSpy).toHaveBeenCalledWith('online', expect.any(Function));
expect(removeEventListenerSpy).toHaveBeenCalledWith('offline', expect.any(Function));

removeEventListenerSpy.mockRestore();
});
});
35 changes: 35 additions & 0 deletions src/hooks/__tests__/useNetworkStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { renderHook } from '@testing-library/react';
import useAPIContext from 'Api/hooks/useAPIContext';
import useNavigatorOnline from 'Hooks/useNavigatorOnline';
import useNetworkStatus from 'Hooks/useNetworkStatus';

jest.mock('Hooks/useNavigatorOnline');
jest.mock('Api/hooks/useAPIContext');

const mockUseNavigatorOnline = useNavigatorOnline as jest.MockedFunction<typeof useNavigatorOnline>;
const mockUseAPIContext = useAPIContext as jest.MockedFunction<typeof useAPIContext>;

describe('useNetworkStatus hook', () => {
it('should return online status when the user is online', () => {
mockUseNavigatorOnline.mockReturnValue(true);
mockUseAPIContext.mockReturnValue({
socketConnection: { connection: { readyState: 1 } },
setSocketConnection: jest.fn(),
});
const { result } = renderHook(() => useNetworkStatus());
expect(result.current).toEqual({ className: 'bg-success', tooltip: 'Online' });
});

it('should return offline status when the user is offline', () => {
mockUseNavigatorOnline.mockReturnValue(false);
const closeMock = jest.fn();
mockUseAPIContext.mockReturnValue({
socketConnection: { connection: { readyState: 1, close: closeMock } },
setSocketConnection: jest.fn(),
});
const { result } = renderHook(() => useNetworkStatus());

expect(result.current).toEqual({ className: 'bg-danger', tooltip: 'Offline' });
expect(closeMock).toHaveBeenCalled();
});
});
25 changes: 25 additions & 0 deletions src/hooks/useNavigatorOnline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useEffect, useState } from 'react';

const getOnlineStatus = () =>
typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean' ? navigator.onLine : true;

const useNavigatorOnline = () => {
const [status, setStatus] = useState(getOnlineStatus());

const setOnline = () => setStatus(true);
const setOffline = () => setStatus(false);

useEffect(() => {
window.addEventListener('online', setOnline);
window.addEventListener('offline', setOffline);

return () => {
window.removeEventListener('online', setOnline);
window.removeEventListener('offline', setOffline);
};
}, []);

return status;
};

export default useNavigatorOnline;
54 changes: 54 additions & 0 deletions src/hooks/useNetworkStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
import useNavigatorOnline from 'Hooks/useNavigatorOnline';
import useAPIContext from 'Api/hooks/useAPIContext';
import { getDerivAPIInstance } from 'Utils/websocket/APIProvider';

type TStatus = 'online' | 'offline' | 'blinking';

const useNetworkStatus = () => {
const [status, setStatus] = useState<TStatus>('online');
const networkStatus = useNavigatorOnline();
const { socketConnection, setSocketConnection } = useAPIContext();
const connection = socketConnection.connection;

useEffect(() => {
let reconnectTimeout: ReturnType<typeof setTimeout>;

if (networkStatus) {
/* The user is online */
setStatus('blinking');

const closeState = connection?.readyState == 2 || connection?.readyState == 3;
const openState = connection?.readyState == 1;

/* Will reconnect after the timout if the network status is online and the connection is closed or closing */
reconnectTimeout = setTimeout(() => {
if (networkStatus && closeState) {
const newSocketConnection = getDerivAPIInstance();
setSocketConnection(newSocketConnection);
} else if (openState) {
connection?.send({ ping: 1 }); // get stable status sooner
}
}, 500);

setStatus('online');
} else {
/* The user is offline */
window.DerivAPI = {};
connection?.close();
setStatus('offline');
}

return () => clearTimeout(reconnectTimeout);
}, [networkStatus, connection, setSocketConnection]);

const statusConfigs = {
online: { className: 'bg-success', tooltip: 'Online' },
offline: { className: 'bg-danger', tooltip: 'Offline' },
blinking: { className: 'animate-pulse bg-success', tooltip: 'Connecting to server' },
}[status];

return statusConfigs;
};

export default useNetworkStatus;
11 changes: 9 additions & 2 deletions src/utils/websocket/APIContext.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { createContext } from 'react';
import { Dispatch, SetStateAction, createContext } from 'react';
// @ts-expect-error `@deriv/deriv-api` is not in TypeScript, Hence we ignore the TS error.
import DerivAPIBasic from '@deriv/deriv-api/dist/DerivAPIBasic';

const APIContext = createContext<Record<string, DerivAPIBasic> | null>(null);
type TSocketConnection = Record<string, DerivAPIBasic>;

type TAPIContext = {
socketConnection: TSocketConnection;
setSocketConnection: Dispatch<SetStateAction<TSocketConnection>>;
};

const APIContext = createContext<TAPIContext>({ socketConnection: {}, setSocketConnection: () => {} });

export default APIContext;
9 changes: 5 additions & 4 deletions src/utils/websocket/APIProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PropsWithChildren } from 'react';
import { PropsWithChildren, useState } from 'react';
// @ts-expect-error `@deriv/deriv-api` is not in TypeScript, Hence we ignore the TS error.
import DerivAPIBasic from '@deriv/deriv-api/dist/DerivAPIBasic';
import { getAppId, getSocketURL } from './config';
Expand Down Expand Up @@ -26,7 +26,7 @@ const getSharedQueryClientContext = (): QueryClient => {

// This is a temporary workaround to share a single `DerivAPIBasic` instance for every unique URL.
// Later once we have each package separated we won't need this anymore and can remove this.
const getDerivAPIInstance = (): DerivAPIBasic => {
export const getDerivAPIInstance = (): DerivAPIBasic => {
const endpoint = getSocketURL();
const language = DEFAULT_LANGUAGE; // Need to use the language from the app context.
const brand = 'deriv';
Expand All @@ -48,10 +48,11 @@ const queryClient = getSharedQueryClientContext();
const APIProvider = ({ children }: PropsWithChildren) => {
// Use the new API instance if the `standalone` prop is set to true,
// else use the legacy socket connection.
const active_connection = getDerivAPIInstance();
const activeConnection = getDerivAPIInstance();
const [socketConnection, setSocketConnection] = useState(activeConnection);

return (
<APIContext.Provider value={active_connection}>
<APIContext.Provider value={{ socketConnection, setSocketConnection }}>
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools />
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

{
"compilerOptions": {
"target": "es5",
Expand Down Expand Up @@ -29,6 +28,7 @@
"Components/*": ["components/*"],
"Constants/*": ["constants/*"],
"Contexts/*": ["contexts/*"],
"Hooks/*": ["hooks/*"],
"Styles/*": ["styles/*"],
"Translations/*": ["translations/*"],
"Types/*": ["types/*"],
Expand Down
1 change: 1 addition & 0 deletions webpack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const config: Configuration = {
Components: path.resolve(dirname, 'src/components'),
Constants: path.resolve(dirname, 'src/constants'),
Contexts: path.resolve(dirname, 'src/contexts'),
Hooks: path.resolve(dirname, 'src/hooks'),
Api: path.resolve(dirname, 'src/api'),
Utils: path.resolve(dirname, 'src/utils'),
Styles: path.resolve(dirname, 'src/styles'),
Expand Down