From 234bd921d1c80d87f66dd176536ea80f122966f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82a=C5=BCej=20Lewandowski?= Date: Tue, 30 May 2023 20:08:55 +0200 Subject: [PATCH 01/28] bump up node version --- template/.node-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/.node-version b/template/.node-version index b6a7d89..3c03207 100644 --- a/template/.node-version +++ b/template/.node-version @@ -1 +1 @@ -16 +18 From 9eba1bcda1284e0a78485997c3f289862e83d56b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82a=C5=BCej=20Lewandowski?= Date: Tue, 30 May 2023 21:05:44 +0200 Subject: [PATCH 02/28] improve networking typescript types & throw detailed http errors instead of just messages --- template/.eslintrc.js | 6 ++ template/package.json | 4 +- template/src/api/apiSaga.ts | 59 ----------------- template/src/api/comics.ts | 9 +-- template/src/api/common.ts | 79 +++++++++++++++++++++-- template/src/api/mappers/comicMappers.ts | 8 +++ template/src/api/types/comic.types.ts | 20 ++++++ template/src/models/ComicModel.ts | 14 ---- template/src/redux/rootSaga.ts | 4 +- template/src/screens/demoSlice.ts | 25 ++++--- template/src/utils/error.ts | 25 +++++++ template/src/utils/getMessageFromError.ts | 20 ------ 12 files changed, 152 insertions(+), 121 deletions(-) delete mode 100644 template/src/api/apiSaga.ts create mode 100644 template/src/api/mappers/comicMappers.ts create mode 100644 template/src/api/types/comic.types.ts delete mode 100644 template/src/models/ComicModel.ts create mode 100644 template/src/utils/error.ts delete mode 100644 template/src/utils/getMessageFromError.ts diff --git a/template/.eslintrc.js b/template/.eslintrc.js index 9f48e86..e5bdcd1 100644 --- a/template/.eslintrc.js +++ b/template/.eslintrc.js @@ -15,6 +15,7 @@ module.exports = { 'prettier', 'jest', 'eslint-comments', + '@jambit/typed-redux-saga', ], env: { 'react-native/react-native': true, @@ -65,6 +66,11 @@ module.exports = { // React-Native Plugin 'react-native/no-inline-styles': 1, + + // Typed Redux Saga Plugin + + '@jambit/typed-redux-saga/use-typed-effects': 'error', // ensures, that you import from typed-redux-saga instead of redux-saga/effects + '@jambit/typed-redux-saga/delegate-effects': 'error', // ensures, that you use yield* on effects from typed-redux-saga }, settings: { react: { diff --git a/template/package.json b/template/package.json index e470da0..8a7e105 100644 --- a/template/package.json +++ b/template/package.json @@ -40,12 +40,14 @@ "react-redux": "^7.2.4", "redux": "^4.1.0", "redux-flipper": "^2.0.1", - "redux-saga": "^1.1.3" + "redux-saga": "^1.1.3", + "typed-redux-saga": "^1.5.0" }, "devDependencies": { "@babel/core": "^7.20.0", "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.20.0", + "@jambit/eslint-plugin-typed-redux-saga": "^0.4.0", "@react-native-community/eslint-config": "^3.2.0", "@testing-library/jest-native": "^4.0.1", "@testing-library/react-native": "^7.2.0", diff --git a/template/src/api/apiSaga.ts b/template/src/api/apiSaga.ts deleted file mode 100644 index f6b1cbe..0000000 --- a/template/src/api/apiSaga.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { ActionCreatorWithPayload } from '@reduxjs/toolkit' -import { call, delay, put, race } from 'redux-saga/effects' -import { API_TIMEOUT } from '@config/timing' -import { getErrorMessage } from '@utils/getMessageFromError' -import tryJson from '@utils/tryJson' - -export type SuccessResponse = { json: object; headers: Headers } -type ApiCallResponse = Response | undefined - -interface ApiCallOptions { - onError: ActionCreatorWithPayload -} - -export function* makeApiCall

( - apiRequest: (payload?: P) => Promise, - options: ApiCallOptions & { payload?: P }, -): Generator { - const { payload, onError } = options - - try { - const { apiResponse, timeout } = (yield race({ - apiResponse: call(apiRequest, payload), - timeout: delay(API_TIMEOUT), - })) as { apiResponse?: ApiCallResponse; timeout?: boolean } - - if (timeout) { - throw new Error('Request timed out.') - } - - const headers = apiResponse?.headers - if (!apiResponse || !headers) { - const error = new Error('no API response or headers') - throw error - } - - if (!apiResponse.ok) { - const message = `Server returned ${apiResponse.status} code! Response: ${tryJson( - apiResponse, - )}` - throw new Error(message) - } - - const parsedResponse = (yield apiResponse.json()) as object | { errors: string[] } - - // eslint-disable-next-line no-prototype-builtins - if (parsedResponse.hasOwnProperty('errors')) { - const errorJSON = parsedResponse as { errors: string[] } - throw new Error(errorJSON.errors.join(', ')) - } - - const json = parsedResponse as object - return { json, headers } - } catch (error) { - console.error('[makeApiCall] Error:', getErrorMessage(error)) - yield put(onError(getErrorMessage(error))) - - return null - } -} diff --git a/template/src/api/comics.ts b/template/src/api/comics.ts index 09ed542..fa8111f 100644 --- a/template/src/api/comics.ts +++ b/template/src/api/comics.ts @@ -1,7 +1,4 @@ -import { makeRequest } from './common' +import { getRequest } from './common' +import { ComicBE } from './types/comic.types' -export const getLatestComic = () => - makeRequest({ - path: 'info.0.json', - method: 'GET', - }) +export const getLatestComic = () => getRequest({ path: 'info.0.json' }) diff --git a/template/src/api/common.ts b/template/src/api/common.ts index c26f73f..4c1e839 100644 --- a/template/src/api/common.ts +++ b/template/src/api/common.ts @@ -1,21 +1,88 @@ +import { API_TIMEOUT } from '@config/timing'; +import { HttpError, getErrorMessage, isTimeoutError } from '@utils/error'; import Config from 'react-native-config' -interface RequestParams extends RequestInit { +interface RequestParams extends Omit { path: string + body?: Record } -export const makeRequest = async (params: RequestParams) => { - const { path, ...restParams } = params +type SimplifiedRequestParams = Omit + +async function makeRequest(params: RequestParams): Promise { + // Change the default request timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT); + + const {path, ...restParams} = params; try { const response = await fetch(`${Config.API_URL}/${path}`, { ...restParams, + body: JSON.stringify(restParams.body), + signal: controller.signal, headers: { Accept: 'application/json', + 'Content-Type': 'application/json', ...restParams.headers, }, - }) - return response + }); + + const hasBody = parseInt(response.headers.get('content-length')!) !== 0; + const body = hasBody ? await response.json() : null + + if (!response.ok) { + const readableBody = body ? ` and ${JSON.stringify(body, null, 2)}` : ''; + throw new HttpError(`Server returned ${response.status}${readableBody}`, response.status, body); + } + + return body; } catch (error) { - console.error('[makeRequest] Error:', error) + // Log all the errors and rethrow to parent handlers + const errorMessage = isTimeoutError(error) ? 'Request timed out' : getErrorMessage(error) + console.warn(`#makeRequest failed on /${path}: ${errorMessage}`); + + throw error; + } finally { + clearTimeout(timeoutId); + } +} + +export const getRequest = (params: SimplifiedRequestParams) => { + const getParams: RequestParams = { + ...params, + method: 'GET', + } + return makeRequest(getParams) +} + +export const postRequest = (params: SimplifiedRequestParams) => { + const postParams: RequestParams = { + ...params, + method: 'POST', + } + return makeRequest(postParams) +} + +export const putRequest = (params: SimplifiedRequestParams) => { + const putParams: RequestParams = { + ...params, + method: 'PUT', + } + return makeRequest(putParams) +} + +export const patchRequest = (params: SimplifiedRequestParams) => { + const patchParams: RequestParams = { + ...params, + method: 'PATCH', + } + return makeRequest(patchParams) +} + +export const deleteRequest = (params: SimplifiedRequestParams) => { + const deleteParams: RequestParams = { + ...params, + method: 'DELETE', } + return makeRequest(deleteParams) } diff --git a/template/src/api/mappers/comicMappers.ts b/template/src/api/mappers/comicMappers.ts new file mode 100644 index 0000000..9923356 --- /dev/null +++ b/template/src/api/mappers/comicMappers.ts @@ -0,0 +1,8 @@ +import { Comic, ComicBE } from '@api/types/comic.types' + +export const mapComic = (comic: ComicBE): Comic => ({ + id: comic.num, + title: comic.title, + description: comic.alt, + imageUrl: comic.img, +}) diff --git a/template/src/api/types/comic.types.ts b/template/src/api/types/comic.types.ts new file mode 100644 index 0000000..feda8e1 --- /dev/null +++ b/template/src/api/types/comic.types.ts @@ -0,0 +1,20 @@ +export interface ComicBE { + month: string + num: number + link: string + year: string + news: string + safe_title: string + transcript: string + alt: string + img: string + title: string + day: string +} + +export interface Comic { + id: number + title: string + description: string + imageUrl: string +} diff --git a/template/src/models/ComicModel.ts b/template/src/models/ComicModel.ts deleted file mode 100644 index 69dab00..0000000 --- a/template/src/models/ComicModel.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface Comic { - readonly id: number - readonly title: string - readonly description: string - readonly imageUrl: string -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const parseComic = (json: any): Comic => ({ - id: json.num, - title: json.title, - description: json.alt, - imageUrl: json.img, -}) diff --git a/template/src/redux/rootSaga.ts b/template/src/redux/rootSaga.ts index d054cba..5d40b25 100644 --- a/template/src/redux/rootSaga.ts +++ b/template/src/redux/rootSaga.ts @@ -1,6 +1,6 @@ -import { all } from 'redux-saga/effects' +import { all } from 'typed-redux-saga' import { watchFetchLatestComicSaga } from '@screens/demoSlice' export default function* rootSaga() { - yield all([watchFetchLatestComicSaga()]) + yield* all([watchFetchLatestComicSaga()]) } diff --git a/template/src/screens/demoSlice.ts b/template/src/screens/demoSlice.ts index a457e70..08b3fa7 100644 --- a/template/src/screens/demoSlice.ts +++ b/template/src/screens/demoSlice.ts @@ -1,8 +1,8 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit' -import { put, takeEvery } from 'redux-saga/effects' -import { SuccessResponse, makeApiCall } from '@api/apiSaga' +import { call, put, takeEvery } from 'typed-redux-saga' import { getLatestComic } from '@api/comics' -import { Comic, parseComic } from '@models/ComicModel' +import { mapComic } from '@api/mappers/comicMappers' +import { Comic } from '@api/types/comic.types' import { Failure, Loading, @@ -13,21 +13,20 @@ import { hasData, } from '@models/RemoteData' import { RootState } from '@redux/store' +import { getErrorMessage } from '@utils/error' -export function* fetchLatestComic(): Generator { - const response = yield makeApiCall(getLatestComic, { - onError: getLatestComicAsyncFailure, - }) - - if (response) { - const { json } = response as SuccessResponse - const comic = parseComic(json) - yield put(getLatestComicAsyncSuccess(comic)) +export function* fetchLatestComic() { + try { + const comic = yield* call(getLatestComic) + yield* put(getLatestComicAsyncSuccess(mapComic(comic))) + } catch (error) { + const errorMessage = getErrorMessage(error) + yield* put(getLatestComicAsyncFailure(errorMessage)) } } export function* watchFetchLatestComicSaga() { - yield takeEvery(getLatestComicAsync, fetchLatestComic) + yield* takeEvery(getLatestComicAsync, fetchLatestComic) } interface DemoState { diff --git a/template/src/utils/error.ts b/template/src/utils/error.ts new file mode 100644 index 0000000..105c9c9 --- /dev/null +++ b/template/src/utils/error.ts @@ -0,0 +1,25 @@ +export class HttpError extends Error { + constructor( + message: string, + readonly status: number, + readonly body: Record | null, + ) { + super(message) + this.name = 'HttpError' + } +} + +export function isHttpError(error: unknown): error is HttpError { + return error instanceof HttpError +} + +export function isTimeoutError(error: unknown) { + return error instanceof Error && error.name === 'AbortError' +} + +export function getErrorMessage(error: unknown) { + if (error instanceof Error) { + return error.message + } + return String(error) +} diff --git a/template/src/utils/getMessageFromError.ts b/template/src/utils/getMessageFromError.ts deleted file mode 100644 index 1574b9e..0000000 --- a/template/src/utils/getMessageFromError.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ErrorWithMessage } from './types' - -const hasMessageField = (error: unknown): error is ErrorWithMessage => - typeof (error as Record)?.message === 'string' - -const toErrorWithMessage = (maybeError: unknown): ErrorWithMessage => { - if (hasMessageField(maybeError)) { - return maybeError - } - - try { - return new Error(JSON.stringify(maybeError)) - } catch { - // fallback in case there's an error stringifying the maybeError - // like with circular references for example. - return new Error(String(maybeError)) - } -} - -export const getErrorMessage = (error: unknown) => toErrorWithMessage(error).message From 4090efbc22ad4c6d7181892a0dcf10ef088a70f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82a=C5=BCej=20Lewandowski?= Date: Fri, 2 Jun 2023 17:53:25 +0200 Subject: [PATCH 03/28] Add auth token management --- template/src/api/auth.ts | 22 ++++++ template/src/api/authSaga.ts | 13 ++++ template/src/api/common.ts | 76 ++++++++++++++++++- template/src/api/types/auth.types.ts | 4 + template/src/localization/en/translation.json | 4 +- template/src/redux/rootReducer.ts | 2 + template/src/redux/rootSaga.ts | 6 +- template/src/redux/store.ts | 4 + template/src/screens/DemoScreen.tsx | 20 ++++- template/src/screens/demoSlice.ts | 2 +- template/src/screens/userSlice.ts | 56 ++++++++++++++ template/src/utils/tryJson.ts | 10 --- 12 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 template/src/api/auth.ts create mode 100644 template/src/api/authSaga.ts create mode 100644 template/src/api/types/auth.types.ts create mode 100644 template/src/screens/userSlice.ts delete mode 100644 template/src/utils/tryJson.ts diff --git a/template/src/api/auth.ts b/template/src/api/auth.ts new file mode 100644 index 0000000..fc9fcf0 --- /dev/null +++ b/template/src/api/auth.ts @@ -0,0 +1,22 @@ +import { AuthTokens } from './common' +import { Credentials } from './types/auth.types' + +// This is the place where you want to call your auth provider API +/* eslint-disable @typescript-eslint/no-unused-vars */ + +export const logIn = (credentials: Credentials) => { + return new Promise<{ accessToken: string; refreshToken: string }>(resolve => { + const fakeAuthTokens = { accessToken: 'FAKE_ACCESS_TOKEN', refreshToken: 'FAKE_REFRESH_TOKEN' } + setTimeout(() => resolve(fakeAuthTokens), 500) + }) +} + +export const refreshTokens = (refreshToken: AuthTokens['refreshToken']) => { + return new Promise<{ accessToken: string; refreshToken: string }>(resolve => { + const fakeRefreshedAuthTokens = { + accessToken: 'REFRESHED_ACCESS_TOKEN', + refreshToken: 'REFRESHED_REFRESH_TOKEN', + } + setTimeout(() => resolve(fakeRefreshedAuthTokens), 500) + }) +} diff --git a/template/src/api/authSaga.ts b/template/src/api/authSaga.ts new file mode 100644 index 0000000..e428253 --- /dev/null +++ b/template/src/api/authSaga.ts @@ -0,0 +1,13 @@ +import { takeLatest } from 'typed-redux-saga' +import { logInAsyncSuccess } from '@screens/userSlice' +import { setAuthConfig } from './common' + +// eslint-disable-next-line require-yield +function* setApiAuthConfig(action: ReturnType) { + setAuthConfig(action.payload) +} + +export function* watchAuthTokens() { + // TODO yield* takeLatest([logInAsyncSuccess, REHYDRATE], setApiAuthConfig) + yield* takeLatest(logInAsyncSuccess, setApiAuthConfig) +} diff --git a/template/src/api/common.ts b/template/src/api/common.ts index 4c1e839..616c3d5 100644 --- a/template/src/api/common.ts +++ b/template/src/api/common.ts @@ -1,6 +1,7 @@ import { API_TIMEOUT } from '@config/timing'; import { HttpError, getErrorMessage, isTimeoutError } from '@utils/error'; import Config from 'react-native-config' +import { refreshTokens } from './auth'; interface RequestParams extends Omit { path: string @@ -9,7 +10,26 @@ interface RequestParams extends Omit { type SimplifiedRequestParams = Omit -async function makeRequest(params: RequestParams): Promise { +export interface AuthTokens { + accessToken: string; + refreshToken: string; +} +interface AuthConfig extends Partial { + persistNewTokens?: ((tokens: AuthTokens) => void) +} + +const authConfig: AuthConfig = { + accessToken: undefined, + refreshToken: undefined, + persistNewTokens: undefined +} + +export function setAuthConfig(newConfig: AuthConfig) { + Object.assign(authConfig, newConfig) +}; + + +async function makeRequest(params: RequestParams, isFirstTry = true): Promise { // Change the default request timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT); @@ -27,6 +47,11 @@ async function makeRequest(params: RequestParams): Promise { }, }); + const isAuthenticatedRequest = restParams.headers && 'authorization' in restParams.headers + if(response.status === 401 && isFirstTry && isAuthenticatedRequest) { + return refreshTokensAndRetryRequest(params) + } + const hasBody = parseInt(response.headers.get('content-length')!) !== 0; const body = hasBody ? await response.json() : null @@ -47,6 +72,35 @@ async function makeRequest(params: RequestParams): Promise { } } +async function refreshTokensAndRetryRequest(params: RequestParams) { + if (!authConfig.refreshToken) { + throw new Error('Tokens cannot be refreshed without the refresh token'); + } + + if (!authConfig.persistNewTokens) { + throw new Error("Tokens won't be persisted without redux handler for it"); + } + + let newTokens: AuthTokens; + + try { + newTokens = await refreshTokens(authConfig.refreshToken) + } catch (error) { + throw new Error(`Failed to refresh expired auth tokens - ${getErrorMessage(error)}`); + } + + authConfig.persistNewTokens(newTokens) + const refreshedParams: RequestParams = { + ...params, + headers: { + ...params.headers, + authorization: `Bearer ${newTokens.accessToken}`, + } + } + + return makeRequest(refreshedParams, false) +} + export const getRequest = (params: SimplifiedRequestParams) => { const getParams: RequestParams = { ...params, @@ -86,3 +140,23 @@ export const deleteRequest = (params: SimplifiedRequestParams) => { } return makeRequest(deleteParams) } + +const withAuth = (params: RequestParams): RequestParams => { + if (!authConfig.accessToken) { + throw new Error('Authenticated request cannot be made without the token'); + } + + return { + ...params, + headers: { + ...params.headers, + authorization: `Bearer ${authConfig.accessToken}`, + }, + }; +}; + +export const authGetRequest = (params: SimplifiedRequestParams) => getRequest(withAuth(params)); +export const authPostRequest = (params: SimplifiedRequestParams) => postRequest(withAuth(params)); +export const authPutRequest = (params: SimplifiedRequestParams) => putRequest(withAuth(params)); +export const authPatchRequest = (params: SimplifiedRequestParams) => patchRequest(withAuth(params)); +export const authDeleteRequest = (params: SimplifiedRequestParams) => deleteRequest(withAuth(params)); \ No newline at end of file diff --git a/template/src/api/types/auth.types.ts b/template/src/api/types/auth.types.ts new file mode 100644 index 0000000..3d0e91d --- /dev/null +++ b/template/src/api/types/auth.types.ts @@ -0,0 +1,4 @@ +export interface Credentials { + username: string + password: string +} diff --git a/template/src/localization/en/translation.json b/template/src/localization/en/translation.json index 4b439af..15bae17 100644 --- a/template/src/localization/en/translation.json +++ b/template/src/localization/en/translation.json @@ -1,6 +1,8 @@ { "demoScreen": { - "hermesEnabled": "Hermes enabled -> {{enabled}}", + "logInStatus": "Logged in: {{isLoggedIn}}", + "logInButton": "Log in", + "logOutButton": "Log out", "incrementButton": "Increment counter by 5", "decrementButton": "Decrement counter by 15", "counter": "Counter:", diff --git a/template/src/redux/rootReducer.ts b/template/src/redux/rootReducer.ts index 5d66733..9698bb9 100644 --- a/template/src/redux/rootReducer.ts +++ b/template/src/redux/rootReducer.ts @@ -1,7 +1,9 @@ import demoReducer from '@screens/demoSlice' +import userReducer from '@screens/userSlice' const rootReducer = { demo: demoReducer, + user: userReducer, } export default rootReducer diff --git a/template/src/redux/rootSaga.ts b/template/src/redux/rootSaga.ts index 5d40b25..be998ec 100644 --- a/template/src/redux/rootSaga.ts +++ b/template/src/redux/rootSaga.ts @@ -1,6 +1,8 @@ import { all } from 'typed-redux-saga' -import { watchFetchLatestComicSaga } from '@screens/demoSlice' +import { watchAuthTokens } from '@api/authSaga' +import { watchGetLatestComicSaga } from '@screens/demoSlice' +import { watchLogInSaga } from '@screens/userSlice' export default function* rootSaga() { - yield* all([watchFetchLatestComicSaga()]) + yield* all([watchAuthTokens(), watchGetLatestComicSaga(), watchLogInSaga()]) } diff --git a/template/src/redux/store.ts b/template/src/redux/store.ts index fb9c76f..de8fcc9 100644 --- a/template/src/redux/store.ts +++ b/template/src/redux/store.ts @@ -1,5 +1,7 @@ import { configureStore } from '@reduxjs/toolkit' import createSagaMiddleware from 'redux-saga' +import { setAuthConfig } from '@api/common' +import { logInAsyncSuccess } from '@screens/userSlice' import rootReducer from './rootReducer' import rootSaga from './rootSaga' @@ -19,6 +21,8 @@ const store = configureStore({ sagaMiddleware.run(rootSaga) +setAuthConfig({ persistNewTokens: tokens => store.dispatch(logInAsyncSuccess(tokens)) }) + export type RootState = ReturnType export type AppDispatch = typeof store.dispatch diff --git a/template/src/screens/DemoScreen.tsx b/template/src/screens/DemoScreen.tsx index 9ff1bf9..0e78fa9 100644 --- a/template/src/screens/DemoScreen.tsx +++ b/template/src/screens/DemoScreen.tsx @@ -7,7 +7,7 @@ import { TestIDs } from '@config/testIDs' import Colors from '@config/ui/colors' import useAppDispatch from '@hooks/useAppDispatch' import useAppSelector from '@hooks/useAppSelector' -import { hasData } from '@models/RemoteData' +import { hasData, isLoading } from '@models/RemoteData' import type { RootStackScreenProps } from '@navigation/navigators/RootStackNavigator' import Routes from '@navigation/routes' import { @@ -17,6 +17,7 @@ import { selectComic, selectCounter, } from './demoSlice' +import { logInAsync, selectAuthTokens, selectIsLoggedIn } from './userSlice' export type DemoScreenParams = undefined @@ -26,6 +27,8 @@ interface DemoScreenProps { } const DemoScreen = ({ navigation }: DemoScreenProps) => { + const authTokensRequest = useAppSelector(selectAuthTokens) + const isLoggedIn = useAppSelector(selectIsLoggedIn) const counter = useAppSelector(selectCounter) const comicRequest = useAppSelector(selectComic) const dispatch = useAppDispatch() @@ -39,6 +42,21 @@ const DemoScreen = ({ navigation }: DemoScreenProps) => { return ( + + {t('demoScreen.logInStatus', { isLoggedIn })} + {isLoading(authTokensRequest) ? ( + + ) : isLoggedIn ? ( +