Skip to content

Commit

Permalink
fix: feed screen after page refresh (#538)
Browse files Browse the repository at this point in the history
  • Loading branch information
davidgamez authored Jul 10, 2024
1 parent 11aca4c commit 4c04750
Show file tree
Hide file tree
Showing 9 changed files with 180 additions and 18 deletions.
15 changes: 13 additions & 2 deletions web-app/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,24 @@ import { RemoteConfigProvider } from './context/RemoteConfigProvider';
import { useDispatch } from 'react-redux';
import { anonymousLogin } from './store/profile-reducer';
import i18n from '../i18n';
import { Suspense, useEffect } from 'react';
import { Suspense, useEffect, useState } from 'react';
import { I18nextProvider } from 'react-i18next';
import { app } from '../firebase';

function App(): React.ReactElement {
require('typeface-muli'); // Load font
const dispatch = useDispatch();
const [isAppReady, setIsAppReady] = useState(false);

useEffect(() => {
app.auth().onAuthStateChanged((user) => {
if (user != null) {
setIsAppReady(true);
} else {
setIsAppReady(false);
dispatch(anonymousLogin());
}
});
dispatch(anonymousLogin());
}, [dispatch]);

Expand All @@ -26,7 +37,7 @@ function App(): React.ReactElement {
<AppSpinner>
<BrowserRouter>
<Header />
<AppRouter />
{isAppReady ? <AppRouter /> : null}
</BrowserRouter>
</AppSpinner>
<Footer />
Expand Down
6 changes: 4 additions & 2 deletions web-app/src/app/components/LogoutConfirmModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import React from 'react';
import { useAppDispatch } from '../hooks';
import { logout } from '../store/profile-reducer';
import { SIGN_IN_TARGET } from '../constants/Navigation';
import { SIGN_OUT_TARGET } from '../constants/Navigation';
import { useNavigate } from 'react-router-dom';

interface ConfirmModalProps {
Expand All @@ -25,7 +25,9 @@ export default function ConfirmModal({
const dispatch = useAppDispatch();
const navigateTo = useNavigate();
const confirmLogout = (): void => {
dispatch(logout({ redirectScreen: SIGN_IN_TARGET, navigateTo }));
dispatch(
logout({ redirectScreen: SIGN_OUT_TARGET, navigateTo, propagate: true }),
);
setOpenDialog(false);
};

Expand Down
2 changes: 1 addition & 1 deletion web-app/src/app/constants/Navigation.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type NavigationItem from '../interface/Navigation';
import { type RemoteConfigValues } from '../interface/RemoteConfig';

export const SIGN_OUT_TARGET = '/sign-out';
export const SIGN_OUT_TARGET = '/';
export const SIGN_IN_TARGET = '/sign-in';
export const ACCOUNT_TARGET = '/account';
export const POST_REGISTRATION_TARGET = '/verify-email';
Expand Down
41 changes: 39 additions & 2 deletions web-app/src/app/router/Router.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import React, { useEffect } from 'react';
import { Routes, Route, useNavigate } from 'react-router-dom';
import SignIn from '../screens/SignIn';
import SignUp from '../screens/SignUp';
import Account from '../screens/Account';
Expand All @@ -17,8 +17,45 @@ import TermsAndConditions from '../screens/TermsAndConditions';
import PrivacyPolicy from '../screens/PrivacyPolicy';
import Feed from '../screens/Feed';
import Feeds from '../screens/Feeds';
import { SIGN_OUT_TARGET } from '../constants/Navigation';
import {
LOGIN_CHANNEL,
LOGOUT_CHANNEL,
createBroadcastChannel,
} from '../services/channel-service';
import { useAppDispatch } from '../hooks';
import { logout } from '../store/profile-reducer';

export const AppRouter: React.FC = () => {
const navigateTo = useNavigate();
const dispatch = useAppDispatch();

/**
* Logs out the user and redirects to the sign-out target screen after a logout event is received on the other sessions.
*/
const logoutUserCallback = (): void => {
dispatch(
logout({ redirectScreen: SIGN_OUT_TARGET, navigateTo, propagate: false }),
);
};

/**
* Refreshes the page to ensure the user is authenticated after a login event is received on the other sessions.
*/
const loginUserCallback = (): void => {
window.location.reload();
};

/**
* The channel creation is placed in this component rather than the App.tsx file due to the need of the navigateTo instance.
* The navigateTo instance is only available within the scope the Router including its children.
* The callback functions are used to handle the logout and login events received from other sessions.
*/
useEffect(() => {
createBroadcastChannel(LOGOUT_CHANNEL, logoutUserCallback);
createBroadcastChannel(LOGIN_CHANNEL, loginUserCallback);
}, []);

return (
<Routes>
<Route path='/' element={<Home />} />
Expand Down
52 changes: 52 additions & 0 deletions web-app/src/app/services/channel-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
let channels: Map<string, BroadcastChannel> | undefined;

export const LOGOUT_CHANNEL = 'logout-channel';
export const LOGIN_CHANNEL = 'login-channel';

/**
* Creates a new broadcast channel with the specified name and callback. The callback is called when a message is received.
* If the channel already exists, the function returns false.
* @param channelName name of the channel
* @param callback function to be called when a message is received
* @returns true if the channel was created, false if the channel already exists
* @see broadcastMessage
*/
export const createBroadcastChannel = (
channelName: string,
callback: () => void,
): boolean => {
if (channels === undefined) {
channels = new Map<string, BroadcastChannel>();
}
let channel = channels.get(channelName);
if (channel !== undefined) {
return false;
}
channel = new BroadcastChannel(channelName);
channel.onmessage = () => {
callback();
};
channels.set(channelName, channel);
return true;
};

/**
* Broadcasts a message to all subscribers of the channel. The channel must be created before broadcasting.
* If the channel is not found, an error is thrown.
* @param channelName name of the channel
* @param message to be broadcasted or undefined
* @see createDispatchChannel
*/
export const broadcastMessage = (
channelName: string,
message?: string,
): void => {
if (channels === undefined) {
throw new Error('No channels created');
}
const channel = channels.get(channelName);
if (channel === undefined) {
throw new Error(`Channel ${channelName} not found`);
}
channel.postMessage(message);
};
12 changes: 11 additions & 1 deletion web-app/src/app/store/profile-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const initialState: UserProfileState = {
Registration: null,
ResetPassword: null,
VerifyEmail: null,
AnonymousLogin: null,
},
isRefreshingAccessToken: false,
isVerificationEmailSent: false,
Expand Down Expand Up @@ -83,6 +84,7 @@ export const userProfileSlice = createSlice({
action: PayloadAction<{
redirectScreen: string;
navigateTo: NavigateFunction;
propagate: boolean;
}>,
) => {
state.status = 'login_out';
Expand All @@ -93,6 +95,7 @@ export const userProfileSlice = createSlice({
state.status = 'unauthenticated';
state.isSignedInWithProvider = false;
state.isAppRefreshing = false;
state.user = undefined;
},
logoutFail: (state) => {
state.status = 'unauthenticated';
Expand Down Expand Up @@ -253,9 +256,15 @@ export const userProfileSlice = createSlice({
},
anonymousLogin: (state) => {
state.isAppRefreshing = true;
state.errors.AnonymousLogin = null;
},
anonymousLoginFailed: (state) => {
anonymousLoginFailed: (state, action: PayloadAction<ProfileError>) => {
state.isAppRefreshing = false;
state.errors.AnonymousLogin = action.payload;
},
anonymousLoginSkipped: (state, action: PayloadAction<ProfileError>) => {
state.isAppRefreshing = false;
state.errors.AnonymousLogin = action.payload;
},
},
});
Expand Down Expand Up @@ -293,6 +302,7 @@ export const {
emailVerified,
anonymousLogin,
anonymousLoginFailed,
anonymousLoginSkipped,
} = userProfileSlice.actions;

export default userProfileSlice.reducer;
67 changes: 58 additions & 9 deletions web-app/src/app/store/saga/auth-saga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
USER_PROFILE_SEND_VERIFICATION_EMAIL,
USER_PROFILE_ANONYMOUS_LOGIN,
type ProfileError,
ProfileErrorSource,
} from '../../types';
import 'firebase/compat/auth';
import {
Expand All @@ -28,8 +29,8 @@ import {
resetPasswordSuccess,
verifyFail,
verifySuccess,
anonymousLogin,
anonymousLoginFailed,
anonymousLoginSkipped,
} from '../profile-reducer';
import { type NavigateFunction } from 'react-router-dom';
import {
Expand All @@ -49,7 +50,12 @@ import {
signInAnonymously,
} from 'firebase/auth';
import { getAppError } from '../../utils/error';
import { selectIsAuthenticated } from '../profile-selectors';
import { selectIsAnonymous, selectIsAuthenticated } from '../profile-selectors';
import {
LOGIN_CHANNEL,
LOGOUT_CHANNEL,
broadcastMessage,
} from '../../services/channel-service';

function* emailLoginSaga({
payload: { email, password },
Expand All @@ -68,22 +74,26 @@ function* emailLoginSaga({
undefined,
);
yield put(loginSuccess(userEnhanced));
broadcastMessage(LOGIN_CHANNEL);
} catch (error) {
yield put(loginFail(getAppError(error) as ProfileError));
}
}

function* logoutSaga({
payload: { redirectScreen, navigateTo },
payload: { redirectScreen, navigateTo, propagate },
}: PayloadAction<{
redirectScreen: string;
navigateTo: NavigateFunction;
propagate: boolean;
}>): Generator {
try {
navigateTo(redirectScreen);
yield app.auth().signOut();
yield put(logoutSuccess());
yield put(anonymousLogin()); // Use anonymous login to keep the user signed in
navigateTo(redirectScreen);
if (propagate) {
broadcastMessage(LOGOUT_CHANNEL);
}
} catch (error) {
yield put(loginFail(getAppError(error) as ProfileError));
}
Expand Down Expand Up @@ -159,6 +169,7 @@ function* loginWithProviderSaga({
additionalUserInfo,
);
yield put(loginSuccess(userEnhanced));
broadcastMessage(LOGIN_CHANNEL);
} catch (error) {
yield put(loginFail(getAppError(error) as ProfileError));
}
Expand All @@ -185,7 +196,13 @@ function* anonymousLoginSaga(): Generator {
selectIsAuthenticated,
)) as boolean;
if (isAuthenticated) {
yield put(anonymousLoginFailed()); // fails silently
yield put(
anonymousLoginSkipped({
code: 'unknown',
message: 'User is already authenticated',
source: ProfileErrorSource.AnonymousLogin,
}),
);
return;
}

Expand All @@ -194,21 +211,53 @@ function* anonymousLoginSaga(): Generator {
yield call(async () => {
await signInAnonymously(auth);
});

const hasStateAnonymousSet: boolean = (yield select(
selectIsAnonymous,
)) as boolean;
if (hasStateAnonymousSet) {
yield put(
anonymousLoginSkipped({
code: 'unknown',
message: 'User had already login as anonymous before.',
source: ProfileErrorSource.AnonymousLogin,
}),
);
return;
}
const user = yield call(getUserFromSession);
if (user === null) {
throw new Error('User not found');
anonymousLoginSkipped({
code: 'unknown',
message: 'User not found',
source: ProfileErrorSource.AnonymousLogin,
});
return;
}
const firebaseUser = app.auth().currentUser;
if (firebaseUser === null) {
throw new Error('Firebase user not found');
yield put(
anonymousLoginFailed({
code: 'unknown',
message: 'User not found',
source: ProfileErrorSource.AnonymousLogin,
}),
);
return;
}
const currentUser = {
...user,
refreshToken: firebaseUser.refreshToken,
};
yield put(loginSuccess(currentUser as User));
} catch (error) {
yield put(anonymousLoginFailed()); // fails silently
yield put(
anonymousLoginFailed({
code: 'unknown',
message: 'Critical error while login as anonymous.',
source: ProfileErrorSource.AnonymousLogin,
}),
);
}
}

Expand Down
2 changes: 1 addition & 1 deletion web-app/src/app/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
PURGE,
REGISTER,
} from 'redux-persist';
import storage from 'redux-persist/lib/storage/session';
import storage from 'redux-persist/lib/storage';
import {
configureStore,
type ThunkAction,
Expand Down
1 change: 1 addition & 0 deletions web-app/src/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export enum ProfileErrorSource {
Registration = 'Registration',
ResetPassword = 'ResetPassword',
VerifyEmail = 'VerifyEmail',
AnonymousLogin = 'AnonymousLogin',
}
export enum FeedErrorSource {
DatabaseAPI = 'DatabaseAPI',
Expand Down

0 comments on commit 4c04750

Please sign in to comment.