From c36494c3f97d17ea4010aa359c62c7b520053b10 Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Tue, 2 Mar 2021 14:17:27 +0700 Subject: [PATCH 01/19] auth: refactored sagas to use try-catch --- package.json | 2 +- src/redux/auth/api.ts | 158 +++++----------- src/redux/auth/constants.ts | 26 +-- src/redux/auth/index.ts | 23 ++- src/redux/auth/sagas.ts | 353 ++++++++++++++++------------------- src/redux/auth/selectors.ts | 2 +- src/redux/auth/transforms.ts | 11 +- src/redux/auth/types.ts | 59 +++++- src/redux/boris/sagas.ts | 6 +- src/redux/flow/sagas.ts | 25 +-- src/redux/messages/index.ts | 2 +- src/redux/messages/sagas.ts | 24 +-- src/redux/modal/index.ts | 2 +- src/redux/node/sagas.ts | 56 +++--- src/redux/player/sagas.ts | 2 +- src/redux/store.ts | 28 ++- src/redux/tag/sagas.ts | 23 ++- src/redux/types.ts | 8 +- src/redux/uploads/sagas.ts | 4 +- src/utils/api/index.ts | 4 +- src/utils/validators.ts | 4 +- tsconfig.json | 2 +- 22 files changed, 400 insertions(+), 424 deletions(-) diff --git a/package.json b/package.json index 03f37c0f..4374ead7 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "start": "craco start", "build": "craco build", "test": "craco test", - "eject": "craco eject" + "ts-check": "tsc -p tsconfig.json --noEmit" }, "eslintConfig": { "extends": [ diff --git a/src/redux/auth/api.ts b/src/redux/auth/api.ts index 18cb240c..25f38508 100644 --- a/src/redux/auth/api.ts +++ b/src/redux/auth/api.ts @@ -1,55 +1,46 @@ -import { api, configWithToken, errorMiddleware, resultMiddleware } from '~/utils/api'; +import { api, cleanResult, errorMiddleware, resultMiddleware } from '~/utils/api'; import { API } from '~/constants/api'; -import { INotification, IResultWithStatus } from '~/redux/types'; -import { userLoginTransform } from '~/redux/auth/transforms'; -import { ISocialAccount, IUser } from './types'; +import { IResultWithStatus } from '~/redux/types'; +import { + ApiAttachSocialRequest, + ApiAttachSocialResult, + ApiAuthGetUpdatesRequest, + ApiAuthGetUpdatesResult, + ApiAuthGetUserProfileRequest, + ApiAuthGetUserProfileResult, + ApiAuthGetUserResult, + ApiCheckRestoreCodeRequest, + ApiCheckRestoreCodeResult, + ApiDropSocialRequest, + ApiDropSocialResult, + ApiGetSocialsResult, + ApiLoginWithSocialRequest, + ApiLoginWithSocialResult, + ApiRestoreCodeRequest, + ApiRestoreCodeResult, + ApiUpdateUserRequest, + ApiUpdateUserResult, + ApiUserLoginRequest, + ApiUserLoginResult, +} from './types'; -export const apiUserLogin = ({ - username, - password, -}: { - username: string; - password: string; -}): Promise> => +export const apiUserLogin = ({ username, password }: ApiUserLoginRequest) => api - .post(API.USER.LOGIN, { username, password }) - .then(resultMiddleware) - .catch(errorMiddleware) - .then(userLoginTransform); + .post(API.USER.LOGIN, { username, password }) + .then(cleanResult); -export const apiAuthGetUser = ({ access }): Promise> => - api - .get(API.USER.ME, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiAuthGetUser = () => api.get(API.USER.ME).then(cleanResult); -export const apiAuthGetUserProfile = ({ - access, - username, -}): Promise> => - api - .get(API.USER.PROFILE(username), configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiAuthGetUserProfile = ({ username }: ApiAuthGetUserProfileRequest) => + api.get(API.USER.PROFILE(username)).then(cleanResult); -export const apiAuthGetUpdates = ({ - access, - exclude_dialogs, - last, -}): Promise> => +export const apiAuthGetUpdates = ({ exclude_dialogs, last }: ApiAuthGetUpdatesRequest) => api - .get(API.USER.GET_UPDATES, configWithToken(access, { params: { exclude_dialogs, last } })) - .then(resultMiddleware) - .catch(errorMiddleware); + .get(API.USER.GET_UPDATES, { params: { exclude_dialogs, last } }) + .then(cleanResult); -export const apiUpdateUser = ({ access, user }): Promise> => - api - .patch(API.USER.ME, user, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiUpdateUser = ({ user }: ApiUpdateUserRequest) => + api.patch(API.USER.ME, user).then(cleanResult); export const apiRequestRestoreCode = ({ field }): Promise> => api @@ -57,75 +48,26 @@ export const apiRequestRestoreCode = ({ field }): Promise> .then(resultMiddleware) .catch(errorMiddleware); -export const apiCheckRestoreCode = ({ code }): Promise> => - api - .get(API.USER.REQUEST_CODE(code)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiCheckRestoreCode = ({ code }: ApiCheckRestoreCodeRequest) => + api.get(API.USER.REQUEST_CODE(code)).then(cleanResult); -export const apiRestoreCode = ({ code, password }): Promise> => +export const apiRestoreCode = ({ code, password }: ApiRestoreCodeRequest) => api - .post(API.USER.REQUEST_CODE(code), { password }) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.USER.REQUEST_CODE(code), { password }) + .then(cleanResult); -export const apiGetSocials = ({ - access, -}: { - access: string; -}): Promise> => - api - .get(API.USER.GET_SOCIALS, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiGetSocials = () => + api.get(API.USER.GET_SOCIALS).then(cleanResult); -export const apiDropSocial = ({ - access, - id, - provider, -}: { - access: string; - id: string; - provider: string; -}): Promise> => - api - .delete(API.USER.DROP_SOCIAL(provider, id), configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiDropSocial = ({ id, provider }: ApiDropSocialRequest) => + api.delete(API.USER.DROP_SOCIAL(provider, id)).then(cleanResult); -export const apiAttachSocial = ({ - access, - token, -}: { - access: string; - token: string; -}): Promise> => +export const apiAttachSocial = ({ token }: ApiAttachSocialRequest) => api - .post(API.USER.ATTACH_SOCIAL, { token }, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.USER.ATTACH_SOCIAL, { token }) + .then(cleanResult); -export const apiLoginWithSocial = ({ - token, - username, - password, -}: { - token: string; - username?: string; - password?: string; -}): Promise; - needs_register: boolean; -}>> => +export const apiLoginWithSocial = ({ token, username, password }: ApiLoginWithSocialRequest) => api - .post(API.USER.LOGIN_WITH_SOCIAL, { token, username, password }) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.USER.LOGIN_WITH_SOCIAL, { token, username, password }) + .then(cleanResult); diff --git a/src/redux/auth/constants.ts b/src/redux/auth/constants.ts index 5c66e845..d2959fdc 100644 --- a/src/redux/auth/constants.ts +++ b/src/redux/auth/constants.ts @@ -53,26 +53,26 @@ export const USER_ROLES = { }; export const EMPTY_TOKEN: IToken = { - access: null, - refresh: null, + access: '', + refresh: '', }; export const EMPTY_USER: IUser = { - id: null, + id: 0, role: USER_ROLES.GUEST, - email: null, - name: null, - username: null, - photo: null, - cover: null, + email: '', + name: '', + username: '', + photo: undefined, + cover: undefined, is_activated: false, is_user: false, - fullname: null, - description: null, + fullname: '', + description: '', - last_seen: null, - last_seen_messages: null, - last_seen_boris: null, + last_seen: '', + last_seen_messages: '', + last_seen_boris: '', }; export interface IApiUser { diff --git a/src/redux/auth/index.ts b/src/redux/auth/index.ts index 008d581f..357cb37e 100644 --- a/src/redux/auth/index.ts +++ b/src/redux/auth/index.ts @@ -8,17 +8,17 @@ const HANDLERS = { }; const INITIAL_STATE: IAuthState = { - token: null, + token: '', user: { ...EMPTY_USER }, updates: { - last: null, + last: '', notifications: [], - boris_commented_at: null, + boris_commented_at: '', }, login: { - error: null, + error: '', is_loading: false, is_registering: true, }, @@ -27,7 +27,7 @@ const INITIAL_STATE: IAuthState = { tab: 'profile', is_loading: true, - user: null, + user: undefined, patch_errors: {}, socials: { @@ -39,20 +39,19 @@ const INITIAL_STATE: IAuthState = { restore: { code: '', - user: null, + user: undefined, is_loading: false, is_succesfull: false, - error: null, + error: '', }, register_social: { errors: { - username: 'and this', - password: 'dislike this', + username: '', + password: '', }, - error: 'dont like this one', - token: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJEYXRhIjp7IlByb3ZpZGVyIjoiZ29vZ2xlIiwiSWQiOiJma2F0dXJvdkBpY2Vyb2NrZGV2LmNvbSIsIkVtYWlsIjoiZmthdHVyb3ZAaWNlcm9ja2Rldi5jb20iLCJUb2tlbiI6InlhMjkuYTBBZkg2U01EeXFGdlRaTExXckhsQm1QdGZIOFNIVGQteWlSYTFKSXNmVXluY2F6MTZ5UGhjRmxydTlDMWFtTEg0aHlHRzNIRkhrVGU0SXFUS09hVVBEREdqR2JQRVFJbGpPME9UbUp2T2RrdEtWNDVoUGpJcTB1cHVLc003UWJLSm1oRWhkMEFVa3YyejVHWlNSMjhaM2VOZVdwTEVYSGV0MW1yNyIsIkZldGNoZWQiOnsiUHJvdmlkZXIiOiJnb29nbGUiLCJJZCI6OTIyMzM3MjAzNjg1NDc3NTgwNywiTmFtZSI6IkZlZG9yIEthdHVyb3YiLCJQaG90byI6Imh0dHBzOi8vbGg2Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8ta1VMYXh0VV9jZTAvQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQU1adXVjbkEycTFReU1WLUN0RUtBclRhQzgydE52NTM2QS9waG90by5qcGcifX0sIlR5cGUiOiJvYXV0aF9jbGFpbSJ9.r1MY994BC_g4qRDoDoyNmwLs0qRzBLx6_Ez-3mHQtwg', + error: '', + token: '', is_loading: false, }, }; diff --git a/src/redux/auth/sagas.ts b/src/redux/auth/sagas.ts index 89c75b15..71af4744 100644 --- a/src/redux/auth/sagas.ts +++ b/src/redux/auth/sagas.ts @@ -1,5 +1,5 @@ import { call, delay, put, select, takeEvery, takeLatest } from 'redux-saga/effects'; -import { AUTH_USER_ACTIONS, EMPTY_USER, USER_ERRORS, USER_ROLES } from '~/redux/auth/constants'; +import { AUTH_USER_ACTIONS, EMPTY_USER, USER_ROLES } from '~/redux/auth/constants'; import { authAttachSocial, authDropSocial, @@ -48,49 +48,37 @@ import { selectAuthRestore, selectAuthUpdates, selectAuthUser, - selectToken, } from './selectors'; -import { IResultWithStatus, OAUTH_EVENT_TYPES, Unwrap } from '../types'; -import { IAuthState, IUser } from './types'; +import { OAUTH_EVENT_TYPES, Unwrap } from '../types'; +import { IAuthState } from './types'; import { REHYDRATE, RehydrateAction } from 'redux-persist'; import { selectModal } from '~/redux/modal/selectors'; import { IModalState } from '~/redux/modal'; import { DIALOGS } from '~/redux/modal/constants'; import { ERRORS } from '~/constants/errors'; import { messagesSet } from '~/redux/messages/actions'; +import { SagaIterator } from 'redux-saga'; -export function* reqWrapper(requestAction, props = {}): ReturnType { - const access = yield select(selectToken); - - const result = yield call(requestAction, { access, ...props }); - - if (result && result.status === 401) { - return { error: USER_ERRORS.UNAUTHORIZED, data: {} }; - } - - return result; +function* setTokenSaga({ token }: ReturnType) { + localStorage.setItem('token', token); } function* sendLoginRequestSaga({ username, password }: ReturnType) { if (!username || !password) return; - const { - error, - data: { token, user }, - }: IResultWithStatus<{ token: string; user: IUser }> = yield call(apiUserLogin, { - username, - password, - }); + try { + const { token, user }: Unwrap = yield call(apiUserLogin, { + username, + password, + }); - if (error) { - yield put(userSetLoginError(error)); - return; + yield put(authSetToken(token)); + yield put(authSetUser({ ...user, is_user: true })); + yield put(authLoggedIn()); + yield put(modalSetShown(false)); + } catch (e) { + yield put(userSetLoginError(e)); } - - yield put(authSetToken(token)); - yield put(authSetUser({ ...user, is_user: true })); - yield put(authLoggedIn()); - yield put(modalSetShown(false)); } function* refreshUser() { @@ -98,23 +86,18 @@ function* refreshUser() { if (!token) return; - const { - error, - data: { user }, - }: IResultWithStatus<{ user: IUser }> = yield call(reqWrapper, apiAuthGetUser); + try { + const { user }: Unwrap = yield call(apiAuthGetUser); - if (error) { + yield put(authSetUser({ ...user, is_user: true })); + } catch (e) { yield put( authSetUser({ ...EMPTY_USER, is_user: false, }) ); - - return; } - - yield put(authSetUser({ ...user, is_user: true })); } function* checkUserSaga({ key }: RehydrateAction) { @@ -126,44 +109,43 @@ function* gotPostMessageSaga({ token }: ReturnType) { yield put(authSetToken(token)); yield call(refreshUser); - const { is_shown, dialog }: IModalState = yield select(selectModal); + const { is_shown, dialog }: ReturnType = yield select(selectModal); if (is_shown && dialog === DIALOGS.LOGIN) yield put(modalSetShown(false)); } function* logoutSaga() { - yield put(authSetToken(null)); + yield put(authSetToken('')); yield put(authSetUser({ ...EMPTY_USER })); yield put( authSetUpdates({ - last: null, + last: '', notifications: [], }) ); } -function* loadProfile({ username }: ReturnType) { +function* loadProfile({ username }: ReturnType): SagaIterator { yield put(authSetProfile({ is_loading: true })); - const { - error, - data: { user }, - } = yield call(reqWrapper, apiAuthGetUserProfile, { username }); + try { + const { user }: Unwrap = yield call(apiAuthGetUserProfile, { + username, + }); - if (error || !user) { + yield put(authSetProfile({ is_loading: false, user })); + yield put(messagesSet({ messages: [] })); + return true; + } catch (error) { return false; } - - yield put(authSetProfile({ is_loading: false, user })); - yield put(messagesSet({ messages: [] })); - return true; } function* openProfile({ username, tab = 'profile' }: ReturnType) { yield put(modalShowDialog(DIALOGS.PROFILE)); yield put(authSetProfile({ tab })); - const success: boolean = yield call(loadProfile, authLoadProfile(username)); + const success: Unwrap = yield call(loadProfile, authLoadProfile(username)); if (!success) { return yield put(modalSetShown(false)); @@ -171,42 +153,39 @@ function* openProfile({ username, tab = 'profile' }: ReturnType = yield select(selectAuthUser); + try { + const user: ReturnType = yield select(selectAuthUser); - if (!user || !user.is_user || user.role === USER_ROLES.GUEST || !user.id) return; + if (!user || !user.is_user || user.role === USER_ROLES.GUEST || !user.id) return; - const modal: IModalState = yield select(selectModal); - const profile: IAuthState['profile'] = yield select(selectAuthProfile); - const { last, boris_commented_at }: IAuthState['updates'] = yield select(selectAuthUpdates); - const exclude_dialogs = - modal.is_shown && modal.dialog === DIALOGS.PROFILE && profile.user.id ? profile.user.id : null; + const modal: ReturnType = yield select(selectModal); + const profile: ReturnType = yield select(selectAuthProfile); + const { last, boris_commented_at }: IAuthState['updates'] = yield select(selectAuthUpdates); + const exclude_dialogs = + modal.is_shown && modal.dialog === DIALOGS.PROFILE && profile.user?.id ? profile.user.id : 0; - const { error, data }: Unwrap> = yield call( - reqWrapper, - apiAuthGetUpdates, - { exclude_dialogs, last: last || user.last_seen_messages } - ); + const data: Unwrap = yield call(apiAuthGetUpdates, { + exclude_dialogs, + last: last || user.last_seen_messages, + }); - if (error || !data) { - return; - } + if (data.notifications && data.notifications.length) { + yield put( + authSetUpdates({ + last: data.notifications[0].created_at, + notifications: data.notifications, + }) + ); + } - if (data.notifications && data.notifications.length) { - yield put( - authSetUpdates({ - last: data.notifications[0].created_at, - notifications: data.notifications, - }) - ); - } - - if (data.boris && data.boris.commented_at && boris_commented_at !== data.boris.commented_at) { - yield put( - authSetUpdates({ - boris_commented_at: data.boris.commented_at, - }) - ); - } + if (data.boris && data.boris.commented_at && boris_commented_at !== data.boris.commented_at) { + yield put( + authSetUpdates({ + boris_commented_at: data.boris.commented_at, + }) + ); + } + } catch (error) {} } function* startPollingSaga() { @@ -219,148 +198,136 @@ function* startPollingSaga() { function* setLastSeenMessages({ last_seen_messages }: ReturnType) { if (!Date.parse(last_seen_messages)) return; - yield call(reqWrapper, apiUpdateUser, { user: { last_seen_messages } }); + yield call(apiUpdateUser, { user: { last_seen_messages } }); } -function* patchUser({ user }: ReturnType) { - const me = yield select(selectAuthUser); +function* patchUser(payload: ReturnType) { + const me: ReturnType = yield select(selectAuthUser); - const { error, data } = yield call(reqWrapper, apiUpdateUser, { user }); + try { + const { user, errors }: Unwrap = yield call(apiUpdateUser, { + user: payload.user, + }); - if (error || !data.user || data.errors) { - return yield put(authSetProfile({ patch_errors: data.errors })); + if (errors && Object.keys(errors).length) { + yield put(authSetProfile({ patch_errors: errors })); + return; + } + + yield put(authSetUser({ ...me, ...user })); + yield put(authSetProfile({ user: { ...me, ...user }, tab: 'profile' })); + } catch (error) { + return; } - - yield put(authSetUser({ ...me, ...data.user })); - yield put(authSetProfile({ user: { ...me, ...data.user }, tab: 'profile' })); } function* requestRestoreCode({ field }: ReturnType) { if (!field) return; - yield put(authSetRestore({ error: null, is_loading: true })); - const { error, data } = yield call(apiRequestRestoreCode, { field }); + try { + yield put(authSetRestore({ error: '', is_loading: true })); + yield call(apiRequestRestoreCode, { + field, + }); - if (data.error || error) { - return yield put(authSetRestore({ is_loading: false, error: data.error || error })); + yield put(authSetRestore({ is_loading: false, is_succesfull: true })); + } catch (error) { + return yield put(authSetRestore({ is_loading: false, error })); } - - yield put(authSetRestore({ is_loading: false, is_succesfull: true })); } function* showRestoreModal({ code }: ReturnType) { - if (!code && !code.length) { - return yield put(authSetRestore({ error: ERRORS.CODE_IS_INVALID, is_loading: false })); + try { + if (!code && !code.length) { + return yield put(authSetRestore({ error: ERRORS.CODE_IS_INVALID, is_loading: false })); + } + + yield put(authSetRestore({ user: undefined, is_loading: true })); + + const data: Unwrap = yield call(apiCheckRestoreCode, { code }); + + yield put(authSetRestore({ user: data.user, code, is_loading: false })); + yield put(modalShowDialog(DIALOGS.RESTORE_PASSWORD)); + } catch (error) { + yield put(authSetRestore({ is_loading: false, error: error || ERRORS.CODE_IS_INVALID })); + yield put(modalShowDialog(DIALOGS.RESTORE_PASSWORD)); } - - yield put(authSetRestore({ user: null, is_loading: true })); - - const { error, data } = yield call(apiCheckRestoreCode, { code }); - - if (data.error || error || !data.user) { - yield put( - authSetRestore({ is_loading: false, error: data.error || error || ERRORS.CODE_IS_INVALID }) - ); - - return yield put(modalShowDialog(DIALOGS.RESTORE_PASSWORD)); - } - - yield put(authSetRestore({ user: data.user, code, is_loading: false })); - yield put(modalShowDialog(DIALOGS.RESTORE_PASSWORD)); } function* restorePassword({ password }: ReturnType) { - if (!password) return; + try { + if (!password) return; - yield put(authSetRestore({ is_loading: true })); - const { code } = yield select(selectAuthRestore); + yield put(authSetRestore({ is_loading: true })); + const { code } = yield select(selectAuthRestore); - if (!code) { - return yield put(authSetRestore({ error: ERRORS.CODE_IS_INVALID, is_loading: false })); + if (!code) { + return yield put(authSetRestore({ error: ERRORS.CODE_IS_INVALID, is_loading: false })); + } + + const data: Unwrap = yield call(apiRestoreCode, { code, password }); + + yield put(authSetToken(data.token)); + yield put(authSetUser(data.user)); + + yield put(authSetRestore({ is_loading: false, is_succesfull: true, error: '' })); + + yield call(refreshUser); + } catch (error) { + return yield put(authSetRestore({ is_loading: false, error: error || ERRORS.CODE_IS_INVALID })); } - - const { error, data } = yield call(apiRestoreCode, { code, password }); - - if (data.error || error || !data.user || !data.token) { - return yield put( - authSetRestore({ is_loading: false, error: data.error || error || ERRORS.CODE_IS_INVALID }) - ); - } - - yield put(authSetToken(data.token)); - yield put(authSetUser(data.user)); - - yield put(authSetRestore({ is_loading: false, is_succesfull: true, error: null })); - - yield call(refreshUser); } function* getSocials() { - yield put(authSetSocials({ is_loading: true, error: '' })); - try { - const { data, error }: Unwrap> = yield call( - reqWrapper, - apiGetSocials, - {} - ); - - if (error) { - throw new Error(error); - } - - yield put(authSetSocials({ is_loading: false, accounts: data.accounts, error: '' })); - } catch (e) { - yield put(authSetSocials({ is_loading: false, error: e.toString() })); + yield put(authSetSocials({ is_loading: true, error: '' })); + const data: Unwrap = yield call(apiGetSocials); + yield put(authSetSocials({ accounts: data.accounts })); + } catch (error) { + yield put(authSetSocials({ error })); + } finally { + yield put(authSetSocials({ is_loading: false })); } } +// TODO: start from here function* dropSocial({ provider, id }: ReturnType) { try { yield put(authSetSocials({ error: '' })); - const { error }: Unwrap> = yield call( - reqWrapper, - apiDropSocial, - { id, provider } - ); - - if (error) { - throw new Error(error); - } + yield call(apiDropSocial, { + id, + provider, + }); yield call(getSocials); - } catch (e) { - yield put(authSetSocials({ error: e.message })); + } catch (error) { + yield put(authSetSocials({ error })); } } function* attachSocial({ token }: ReturnType) { - if (!token) return; - try { + if (!token) return; + yield put(authSetSocials({ error: '', is_loading: true })); - const { data, error }: Unwrap> = yield call( - reqWrapper, - apiAttachSocial, - { token } - ); - - if (error) { - throw new Error(error); - } + const data: Unwrap = yield call(apiAttachSocial, { + token, + }); const { socials: { accounts }, }: ReturnType = yield select(selectAuthProfile); if (accounts.some(it => it.id === data.account.id && it.provider === data.account.provider)) { - yield put(authSetSocials({ is_loading: false })); - } else { - yield put(authSetSocials({ is_loading: false, accounts: [...accounts, data.account] })); + return; } + + yield put(authSetSocials({ accounts: [...accounts, data.account] })); } catch (e) { - yield put(authSetSocials({ is_loading: false, error: e.message })); + yield put(authSetSocials({ error: e.message })); + } finally { + yield put(authSetSocials({ is_loading: false })); } } @@ -368,10 +335,9 @@ function* loginWithSocial({ token }: ReturnType) { try { yield put(userSetLoginError('')); - const { - data, - error, - }: Unwrap> = yield call(apiLoginWithSocial, { token }); + const data: Unwrap = yield call(apiLoginWithSocial, { + token, + }); // Backend asks us for account registration if (data?.needs_register) { @@ -380,18 +346,14 @@ function* loginWithSocial({ token }: ReturnType) { return; } - if (error) { - throw new Error(error); - } - if (data.token) { yield put(authSetToken(data.token)); yield call(refreshUser); yield put(modalSetShown(false)); return; } - } catch (e) { - yield put(userSetLoginError(e.message)); + } catch (error) { + yield put(userSetLoginError(error)); } } @@ -414,23 +376,19 @@ function* authRegisterSocial({ username, password }: ReturnType> = yield select( + const { token }: Unwrap = yield select( selectAuthRegisterSocial ); - const { data, error }: Unwrap> = yield call( - apiLoginWithSocial, - { - token, - username, - password, - } - ); + const data: Unwrap = yield call(apiLoginWithSocial, { + token, + username, + password, + }); if (data?.errors) { yield put(authSetRegisterSocialErrors(data.errors)); - } else if (data?.error) { - throw new Error(error); + return; } if (data.token) { @@ -449,6 +407,7 @@ function* authSaga() { yield takeLatest([REHYDRATE, AUTH_USER_ACTIONS.LOGGED_IN], startPollingSaga); yield takeLatest(AUTH_USER_ACTIONS.LOGOUT, logoutSaga); + yield takeLatest(AUTH_USER_ACTIONS.SET_TOKEN, setTokenSaga); yield takeLatest(AUTH_USER_ACTIONS.SEND_LOGIN_REQUEST, sendLoginRequestSaga); yield takeLatest(AUTH_USER_ACTIONS.GOT_AUTH_POST_MESSAGE, gotPostMessageSaga); yield takeLatest(AUTH_USER_ACTIONS.OPEN_PROFILE, openProfile); diff --git a/src/redux/auth/selectors.ts b/src/redux/auth/selectors.ts index aa3aa475..6f6ed43b 100644 --- a/src/redux/auth/selectors.ts +++ b/src/redux/auth/selectors.ts @@ -5,7 +5,7 @@ export const selectUser = (state: IState) => state.auth.user; export const selectToken = (state: IState) => state.auth.token; export const selectAuthLogin = (state: IState) => state.auth.login; export const selectAuthProfile = (state: IState) => state.auth.profile; -export const selectAuthProfileUsername = (state: IState) => state.auth.profile.user.username; +export const selectAuthProfileUsername = (state: IState) => state.auth.profile.user?.username; export const selectAuthUser = (state: IState) => state.auth.user; export const selectAuthUpdates = (state: IState) => state.auth.updates; export const selectAuthRestore = (state: IState) => state.auth.restore; diff --git a/src/redux/auth/transforms.ts b/src/redux/auth/transforms.ts index 513b07fe..69c68fc8 100644 --- a/src/redux/auth/transforms.ts +++ b/src/redux/auth/transforms.ts @@ -1,13 +1,18 @@ import { IResultWithStatus } from '~/redux/types'; import { HTTP_RESPONSES } from '~/utils/api'; -export const userLoginTransform = ({ status, data, error }: IResultWithStatus): IResultWithStatus => { +export const userLoginTransform = ({ + status, + data, + error, +}: IResultWithStatus): IResultWithStatus => { switch (true) { - case (status === HTTP_RESPONSES.UNAUTHORIZED || !data.token) && status !== HTTP_RESPONSES.CONNECTION_REFUSED: + case (status === HTTP_RESPONSES.UNAUTHORIZED || !data.token) && + status !== HTTP_RESPONSES.CONNECTION_REFUSED: return { status, data, error: 'Пользователь не найден' }; case status === 200: - return { status, data, error: null }; + return { status, data, error: '' }; default: return { status, data, error: error || 'Неизвестная ошибка' }; diff --git a/src/redux/auth/types.ts b/src/redux/auth/types.ts index 52d417d4..55d7ae82 100644 --- a/src/redux/auth/types.ts +++ b/src/redux/auth/types.ts @@ -1,4 +1,4 @@ -import { IFile, INotification } from '../types'; +import { IFile, INotification, IResultWithStatus } from '../types'; export interface IToken { access: string; @@ -10,8 +10,8 @@ export interface IUser { username: string; email: string; role: string; - photo: IFile; - cover: IFile; + photo?: IFile; + cover?: IFile; name: string; fullname: string; description: string; @@ -53,7 +53,7 @@ export type IAuthState = Readonly<{ tab: 'profile' | 'messages' | 'settings'; is_loading: boolean; - user: IUser; + user?: IUser; patch_errors: Record; socials: { @@ -65,7 +65,7 @@ export type IAuthState = Readonly<{ restore: { code: string; - user: Pick; + user?: Pick; is_loading: boolean; is_succesfull: boolean; error: string; @@ -81,3 +81,52 @@ export type IAuthState = Readonly<{ is_loading: boolean; }; }>; + +export type ApiWithTokenRequest = { access: string }; + +export type ApiUserLoginRequest = Record<'username' | 'password', string>; +export type ApiUserLoginResult = { token: string; user: IUser }; + +export type ApiAuthGetUserRequest = {}; +export type ApiAuthGetUserResult = { user: IUser }; + +export type ApiUpdateUserRequest = { user: Partial }; +export type ApiUpdateUserResult = { user: IUser; errors: Record, string> }; + +export type ApiAuthGetUserProfileRequest = { username: string }; +export type ApiAuthGetUserProfileResult = { user: IUser }; + +export type ApiAuthGetUpdatesRequest = { + exclude_dialogs: number; + last: string; +}; +export type ApiAuthGetUpdatesResult = { + notifications: INotification[]; + boris: { commented_at: string }; +}; + +export type ApiCheckRestoreCodeRequest = { code: string }; +export type ApiCheckRestoreCodeResult = { user: IUser }; + +export type ApiRestoreCodeRequest = { code: string; password: string }; +export type ApiRestoreCodeResult = { token: string; user: IUser }; + +export type ApiGetSocialsResult = { accounts: ISocialAccount[] }; + +export type ApiDropSocialRequest = { id: string; provider: string }; +export type ApiDropSocialResult = { accounts: ISocialAccount[] }; + +export type ApiAttachSocialRequest = { token: string }; +export type ApiAttachSocialResult = { account: ISocialAccount }; + +export type ApiLoginWithSocialRequest = { + token: string; + username?: string; + password?: string; +}; + +export type ApiLoginWithSocialResult = { + token: string; + errors: Record; + needs_register: boolean; +}; diff --git a/src/redux/boris/sagas.ts b/src/redux/boris/sagas.ts index 1f92b6a3..cacb77f4 100644 --- a/src/redux/boris/sagas.ts +++ b/src/redux/boris/sagas.ts @@ -8,10 +8,8 @@ function* loadStats() { yield put(borisSetStats({ is_loading: true })); try { - const git: Unwrap> = yield call(getBorisGitStats); - const backend: Unwrap> = yield call( - getBorisBackendStats - ); + const git: Unwrap = yield call(getBorisGitStats); + const backend: Unwrap = yield call(getBorisBackendStats); yield put(borisSetStats({ git, backend: backend.data, is_loading: false })); } catch (e) { diff --git a/src/redux/flow/sagas.ts b/src/redux/flow/sagas.ts index 0929999b..d8e5ec36 100644 --- a/src/redux/flow/sagas.ts +++ b/src/redux/flow/sagas.ts @@ -14,7 +14,7 @@ import { } from './actions'; import { IResultWithStatus, INode, Unwrap } from '../types'; import { selectFlowNodes, selectFlow } from './selectors'; -import { reqWrapper } from '../auth/sagas'; +import { wrap } from '../auth/sagas'; import { postCellView, getSearchResults } from './api'; import { IFlowState } from './reducer'; import { uniq } from 'ramda'; @@ -47,7 +47,7 @@ function* onGetFlow() { recent: IFlowState['recent']; updated: IFlowState['updated']; valid: INode['id'][]; - }> = yield call(reqWrapper, getNodeDiff, { + }> = yield call(wrap, getNodeDiff, { start: new Date().toISOString(), end: new Date().toISOString(), with_heroes: true, @@ -71,7 +71,7 @@ function* onSetCellView({ id, flow }: ReturnType) { const nodes = yield select(selectFlowNodes); yield put(flowSetNodes(nodes.map(node => (node.id === id ? { ...node, flow } : node)))); - const { data, error } = yield call(reqWrapper, postCellView, { id, flow }); + const { data, error } = yield call(wrap, postCellView, { id, flow }); // TODO: error handling } @@ -83,7 +83,7 @@ function* getMore() { const start = nodes && nodes[0] && nodes[0].created_at; const end = nodes && nodes[nodes.length - 1] && nodes[nodes.length - 1].created_at; - const { error, data } = yield call(reqWrapper, getNodeDiff, { + const { error, data } = yield call(wrap, getNodeDiff, { start, end, with_heroes: false, @@ -124,13 +124,9 @@ function* changeSearch({ search }: ReturnType) { yield delay(500); - const { data, error }: Unwrap> = yield call( - reqWrapper, - getSearchResults, - { - ...search, - } - ); + const { data, error }: Unwrap = yield call(wrap, getSearchResults, { + ...search, + }); if (error) { yield put(flowSetSearch({ is_loading: false, results: [], total: 0 })); @@ -155,11 +151,8 @@ function* loadMoreSearch() { const { search }: ReturnType = yield select(selectFlow); - const { - result, - delay, - }: { result: Unwrap>; delay: any } = yield race({ - result: call(reqWrapper, getSearchResults, { + const { result, delay }: { result: Unwrap; delay: any } = yield race({ + result: call(wrap, getSearchResults, { ...search, skip: search.results.length, }), diff --git a/src/redux/messages/index.ts b/src/redux/messages/index.ts index c868715b..914eb0e3 100644 --- a/src/redux/messages/index.ts +++ b/src/redux/messages/index.ts @@ -12,7 +12,7 @@ export interface IMessagesState { const INITIAL_STATE: IMessagesState = { is_loading_messages: true, is_sending_messages: false, - error: null, + error: '', messages: [], }; diff --git a/src/redux/messages/sagas.ts b/src/redux/messages/sagas.ts index c45bc6ee..cbce096d 100644 --- a/src/redux/messages/sagas.ts +++ b/src/redux/messages/sagas.ts @@ -12,7 +12,7 @@ import { } from '~/redux/messages/api'; import { ERRORS } from '~/constants/errors'; import { IMessageNotification, Unwrap } from '~/redux/types'; -import { reqWrapper } from '~/redux/auth/sagas'; +import { wrap } from '~/redux/auth/sagas'; import { messagesDeleteMessage, messagesGetMessages, @@ -39,11 +39,8 @@ function* getMessages({ username }: ReturnType) { }) ); - const { - error, - data, - }: Unwrap> = yield call( - reqWrapper, + const { error, data }: Unwrap = yield call( + wrap, apiMessagesGetUserMessages, { username } ); @@ -82,8 +79,8 @@ function* sendMessage({ message, onSuccess }: ReturnType> = yield call( - reqWrapper, + const { error, data }: Unwrap = yield call( + wrap, apiMessagesSendMessage, { username, @@ -138,8 +135,8 @@ function* deleteMessage({ id, is_locked }: ReturnType> = yield call( - reqWrapper, + const { error, data }: Unwrap = yield call( + wrap, apiMessagesDeleteMessage, { username, @@ -187,11 +184,8 @@ function* refreshMessages({}: ReturnType) { const after = messages.length > 0 ? messages[0].created_at : undefined; - const { - data, - error, - }: Unwrap> = yield call( - reqWrapper, + const { data, error }: Unwrap = yield call( + wrap, apiMessagesGetUserMessages, { username, after } ); diff --git a/src/redux/modal/index.ts b/src/redux/modal/index.ts index 7b9dfaee..95aa0cb0 100644 --- a/src/redux/modal/index.ts +++ b/src/redux/modal/index.ts @@ -14,7 +14,7 @@ export interface IModalState { const INITIAL_STATE: IModalState = { is_shown: false, - dialog: null, + dialog: '', photoswipe: { images: [], index: 0, diff --git a/src/redux/node/sagas.ts b/src/redux/node/sagas.ts index 95f43082..53c85f8e 100644 --- a/src/redux/node/sagas.ts +++ b/src/redux/node/sagas.ts @@ -2,7 +2,13 @@ import { all, call, delay, put, select, takeLatest, takeLeading } from 'redux-sa import { push } from 'connected-react-router'; import { omit } from 'ramda'; -import { COMMENTS_DISPLAY, EMPTY_COMMENT, EMPTY_NODE, NODE_ACTIONS, NODE_EDITOR_DATA } from './constants'; +import { + COMMENTS_DISPLAY, + EMPTY_COMMENT, + EMPTY_NODE, + NODE_ACTIONS, + NODE_EDITOR_DATA, +} from './constants'; import { nodeCancelCommentEdit, nodeCreate, @@ -39,7 +45,7 @@ import { postNodeStar, updateNodeTags, } from './api'; -import { reqWrapper } from '../auth/sagas'; +import { wrap } from '../auth/sagas'; import { flowSetNodes, flowSetUpdated } from '../flow/actions'; import { ERRORS } from '~/constants/errors'; import { modalSetShown, modalShowDialog } from '../modal/actions'; @@ -77,7 +83,7 @@ function* onNodeSave({ node }: ReturnType) { const { error, data: { errors, node: result }, - } = yield call(reqWrapper, postNode, { node }); + } = yield call(wrap, postNode, { node }); if (errors && Object.values(errors).length > 0) { return yield put(nodeSetSaveErrors(errors)); @@ -117,15 +123,11 @@ function* onNodeLoadMoreComments() { comments, }: ReturnType = yield select(selectNode); - const { data, error }: Unwrap> = yield call( - reqWrapper, - getNodeComments, - { - id, - take: COMMENTS_DISPLAY, - skip: comments.length, - } - ); + const { data, error }: Unwrap = yield call(wrap, getNodeComments, { + id, + take: COMMENTS_DISPLAY, + skip: comments.length, + }); const current: ReturnType = yield select(selectNode); @@ -147,7 +149,7 @@ function* onNodeLoad({ id, order = 'ASC' }: ReturnType) { const { data: { node, error }, - } = yield call(reqWrapper, getNode, { id }); + } = yield call(wrap, getNode, { id }); if (error || !node || !node.id) { yield put(push(URLS.ERRORS.NOT_FOUND)); @@ -166,8 +168,8 @@ function* onNodeLoad({ id, order = 'ASC' }: ReturnType) { data: { related }, }, } = yield all({ - comments: call(reqWrapper, getNodeComments, { id, take: COMMENTS_DISPLAY, skip: 0 }), - related: call(reqWrapper, getNodeRelated, { id }), + comments: call(wrap, getNodeComments, { id, take: COMMENTS_DISPLAY, skip: 0 }), + related: call(wrap, getNodeRelated, { id }), }); yield put( @@ -190,14 +192,10 @@ function* onNodeLoad({ id, order = 'ASC' }: ReturnType) { } function* onPostComment({ nodeId, comment, callback }: ReturnType) { - const { data, error }: Unwrap> = yield call( - reqWrapper, - postNodeComment, - { - data: comment, - id: nodeId, - } - ); + const { data, error }: Unwrap = yield call(wrap, postNodeComment, { + data: comment, + id: nodeId, + }); if (error || !data.comment) { return callback(error); @@ -237,7 +235,7 @@ function* onUpdateTags({ id, tags }: ReturnType) { const { data: { node }, - }: IResultWithStatus<{ node: INode }> = yield call(reqWrapper, updateNodeTags, { id, tags }); + }: IResultWithStatus<{ node: INode }> = yield call(wrap, updateNodeTags, { id, tags }); const { current } = yield select(selectNode); @@ -259,7 +257,7 @@ function* onEditSaga({ id }: ReturnType) { const { data: { node }, error, - } = yield call(reqWrapper, getNode, { id }); + } = yield call(wrap, getNode, { id }); if (error || !node || !node.type || !NODE_EDITOR_DIALOGS[node.type]) return yield put(modalSetShown(false)); @@ -282,7 +280,7 @@ function* onLikeSaga({ id }: ReturnType) { like_count: is_liked ? Math.max(like_count - 1, 0) : like_count + 1, }); - const { data, error } = yield call(reqWrapper, postNodeLike, { id }); + const { data, error } = yield call(wrap, postNodeLike, { id }); if (!error || data.is_liked === !is_liked) return; // ok and matches @@ -297,7 +295,7 @@ function* onStarSaga({ id }: ReturnType) { yield call(updateNodeEverywhere, { ...current, is_heroic: !is_heroic }); - const { data, error } = yield call(reqWrapper, postNodeStar, { id }); + const { data, error } = yield call(wrap, postNodeStar, { id }); if (!error || data.is_heroic === !is_heroic) return; // ok and matches @@ -315,7 +313,7 @@ function* onLockSaga({ id, is_locked }: ReturnType) { deleted_at: is_locked ? new Date().toISOString() : null, }); - const { error } = yield call(reqWrapper, postNodeLock, { id, is_locked }); + const { error } = yield call(wrap, postNodeLock, { id, is_locked }); if (error) return yield call(updateNodeEverywhere, { ...current, deleted_at }); } @@ -333,7 +331,7 @@ function* onLockCommentSaga({ id, is_locked }: ReturnType) { diff --git a/src/redux/player/sagas.ts b/src/redux/player/sagas.ts index 36e4b8b4..d4a199d8 100644 --- a/src/redux/player/sagas.ts +++ b/src/redux/player/sagas.ts @@ -57,7 +57,7 @@ function* getYoutubeInfo() { } if (ticker || ids.length > 25) { - const result: Unwrap> = yield call(getEmbedYoutube, ids); + const result: Unwrap = yield call(getEmbedYoutube, ids); if (!result.error && result.data.items && Object.keys(result.data.items).length) { const { youtubes }: ReturnType = yield select(selectPlayer); diff --git a/src/redux/store.ts b/src/redux/store.ts index a26248eb..8890775e 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -26,7 +26,7 @@ import playerSaga from '~/redux/player/sagas'; import modal, { IModalState } from '~/redux/modal'; import { modalSaga } from './modal/sagas'; -import { authOpenProfile, gotAuthPostMessage } from './auth/actions'; +import { authLogout, authOpenProfile, gotAuthPostMessage } from './auth/actions'; import boris, { IBorisState } from './boris/reducer'; import borisSaga from './boris/sagas'; @@ -36,6 +36,9 @@ import messagesSaga from './messages/sagas'; import tag, { ITagState } from './tag'; import tagSaga from './tag/sagas'; +import { AxiosError } from 'axios'; +import { api } from '~/utils/api'; +import { assocPath } from 'ramda'; const authPersistConfig: PersistConfig = { key: 'auth', @@ -116,5 +119,28 @@ export function configureStore(): { const persistor = persistStore(store); + // Pass token to axios + api.interceptors.request.use(options => { + const token = store.getState().auth.token; + + if (!token) { + return options; + } + + return assocPath(['headers', 'authorization'], `Bearer ${token}`, options); + }); + + // Logout on 401 + api.interceptors.response.use(undefined, (error: AxiosError<{ message: string }>) => { + if (error.response?.status === 401) { + store.dispatch(authLogout()); + } + + console.log('Вот что случилось на сервере:', error); + throw new Error( + error?.response?.data?.message || error?.message || error?.response?.statusText + ); + }); + return { store, persistor }; } diff --git a/src/redux/tag/sagas.ts b/src/redux/tag/sagas.ts index ade289bd..00bcdf35 100644 --- a/src/redux/tag/sagas.ts +++ b/src/redux/tag/sagas.ts @@ -1,7 +1,12 @@ import { TAG_ACTIONS } from '~/redux/tag/constants'; import { call, delay, put, select, takeLatest } from 'redux-saga/effects'; -import { tagLoadAutocomplete, tagLoadNodes, tagSetAutocomplete, tagSetNodes, } from '~/redux/tag/actions'; -import { reqWrapper } from '~/redux/auth/sagas'; +import { + tagLoadAutocomplete, + tagLoadNodes, + tagSetAutocomplete, + tagSetNodes, +} from '~/redux/tag/actions'; +import { wrap } from '~/redux/auth/sagas'; import { selectTagNodes } from '~/redux/tag/selectors'; import { getTagAutocomplete, getTagNodes } from '~/redux/tag/api'; import { Unwrap } from '~/redux/types'; @@ -11,11 +16,11 @@ function* loadTagNodes({ tag }: ReturnType) { try { const { list }: ReturnType = yield select(selectTagNodes); - const { data, error }: Unwrap> = yield call( - reqWrapper, - getTagNodes, - { tag, limit: 18, offset: list.length } - ); + const { data, error }: Unwrap = yield call(wrap, getTagNodes, { + tag, + limit: 18, + offset: list.length, + }); if (error) throw new Error(error); @@ -33,8 +38,8 @@ function* loadAutocomplete({ search, exclude }: ReturnType> = yield call( - reqWrapper, + const { data, error }: Unwrap = yield call( + wrap, getTagAutocomplete, { search, exclude } ); diff --git a/src/redux/types.ts b/src/redux/types.ts index d38db98c..9155b1ff 100644 --- a/src/redux/types.ts +++ b/src/redux/types.ts @@ -192,7 +192,13 @@ export type INodeNotification = { export type INotification = IMessageNotification | ICommentNotification; -export type Unwrap = T extends Promise ? U : T; +export type Unwrap = T extends (...args: any) => Promise + ? T extends (...args: any) => Promise + ? U + : T + : T extends () => Iterator + ? U + : any; export interface IEmbed { provider: string; diff --git a/src/redux/uploads/sagas.ts b/src/redux/uploads/sagas.ts index b24ada1c..e832d16f 100644 --- a/src/redux/uploads/sagas.ts +++ b/src/redux/uploads/sagas.ts @@ -8,7 +8,7 @@ import { uploadDropStatus, uploadAddFile, } from './actions'; -import { reqWrapper } from '../auth/sagas'; +import { wrap } from '../auth/sagas'; import { createUploader, uploadGetThumb } from '~/utils/uploader'; import { HTTP_RESPONSES } from '~/utils/api'; import { IFileWithUUID, IFile, IUploadProgressHandler } from '../types'; @@ -20,7 +20,7 @@ function* uploadCall({ type, onProgress, }: IFileWithUUID & { onProgress: IUploadProgressHandler }) { - return yield call(reqWrapper, postUploadFile, { + return yield call(wrap, postUploadFile, { file, temp_id, type, diff --git a/src/utils/api/index.ts b/src/utils/api/index.ts index e7268343..dc68cdee 100644 --- a/src/utils/api/index.ts +++ b/src/utils/api/index.ts @@ -1,4 +1,4 @@ -import axios, { AxiosRequestConfig } from 'axios'; +import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; import { push } from 'connected-react-router'; import { API } from '~/constants/api'; import { store } from '~/redux/store'; @@ -50,3 +50,5 @@ export const configWithToken = ( ...config, headers: { ...(config.headers || {}), Authorization: `Bearer ${access}` }, }); + +export const cleanResult = (response: AxiosResponse): T => response?.data; diff --git a/src/utils/validators.ts b/src/utils/validators.ts index 8c50b790..0691546f 100644 --- a/src/utils/validators.ts +++ b/src/utils/validators.ts @@ -3,13 +3,13 @@ import { IMAGE_MIME_TYPES } from '~/utils/uploader'; const isValidEmail = (email: string): boolean => !!email && - String(email) && + !!String(email) && !!String(email).match( /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ ); const isLikeEmail = (email: string): boolean => - !!email && String(email) && !!String(email).match(/^([^\@]+)@([^\@]+)\.([^\@]+)$$/); + !!email && !!String(email) && !!String(email).match(/^([^\@]+)@([^\@]+)\.([^\@]+)$$/); const isNonEmpty = (value: string): boolean => !!value && value.trim().length > 0; const isLikePhone = isNonEmpty; diff --git a/tsconfig.json b/tsconfig.json index d0a4b9ad..6583d3c1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, - "strict": false, + "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "module": "esnext", From 20dead5ef5882294369fd1d4e71e00ecfb0d9c02 Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Tue, 2 Mar 2021 14:19:17 +0700 Subject: [PATCH 02/19] auth: refactored sagas select typings --- src/redux/auth/sagas.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/redux/auth/sagas.ts b/src/redux/auth/sagas.ts index 71af4744..47e26fa0 100644 --- a/src/redux/auth/sagas.ts +++ b/src/redux/auth/sagas.ts @@ -160,7 +160,9 @@ function* getUpdates() { const modal: ReturnType = yield select(selectModal); const profile: ReturnType = yield select(selectAuthProfile); - const { last, boris_commented_at }: IAuthState['updates'] = yield select(selectAuthUpdates); + const { last, boris_commented_at }: ReturnType = yield select( + selectAuthUpdates + ); const exclude_dialogs = modal.is_shown && modal.dialog === DIALOGS.PROFILE && profile.user?.id ? profile.user.id : 0; @@ -259,7 +261,7 @@ function* restorePassword({ password }: ReturnType) if (!password) return; yield put(authSetRestore({ is_loading: true })); - const { code } = yield select(selectAuthRestore); + const { code }: ReturnType = yield select(selectAuthRestore); if (!code) { return yield put(authSetRestore({ error: ERRORS.CODE_IS_INVALID, is_loading: false })); @@ -376,7 +378,7 @@ function* authRegisterSocial({ username, password }: ReturnType = yield select( + const { token }: ReturnType = yield select( selectAuthRegisterSocial ); @@ -397,8 +399,8 @@ function* authRegisterSocial({ username, password }: ReturnType Date: Tue, 2 Mar 2021 15:49:57 +0700 Subject: [PATCH 03/19] boris: refactored sagas --- src/redux/boris/api.ts | 11 ++++------- src/redux/boris/sagas.ts | 10 ++++++---- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/redux/boris/api.ts b/src/redux/boris/api.ts index 3a8bd25f..c1bd5a72 100644 --- a/src/redux/boris/api.ts +++ b/src/redux/boris/api.ts @@ -1,13 +1,10 @@ import git from '~/stats/git.json'; import { API } from '~/constants/api'; -import { api, resultMiddleware, errorMiddleware } from '~/utils/api'; +import { api, resultMiddleware, errorMiddleware, cleanResult } from '~/utils/api'; import { IBorisState, IStatBackend } from './reducer'; import { IResultWithStatus } from '../types'; -export const getBorisGitStats = (): Promise => Promise.resolve(git); +export const getBorisGitStats = () => Promise.resolve(git); -export const getBorisBackendStats = (): Promise> => - api - .get(API.BORIS.GET_BACKEND_STATS) - .then(resultMiddleware) - .catch(errorMiddleware); +export const getBorisBackendStats = () => + api.get(API.BORIS.GET_BACKEND_STATS).then(cleanResult); diff --git a/src/redux/boris/sagas.ts b/src/redux/boris/sagas.ts index cacb77f4..a0b1d003 100644 --- a/src/redux/boris/sagas.ts +++ b/src/redux/boris/sagas.ts @@ -5,15 +5,17 @@ import { getBorisGitStats, getBorisBackendStats } from './api'; import { Unwrap } from '../types'; function* loadStats() { - yield put(borisSetStats({ is_loading: true })); - try { + yield put(borisSetStats({ is_loading: true })); + const git: Unwrap = yield call(getBorisGitStats); const backend: Unwrap = yield call(getBorisBackendStats); - yield put(borisSetStats({ git, backend: backend.data, is_loading: false })); + yield put(borisSetStats({ git, backend })); } catch (e) { - yield put(borisSetStats({ git: [], backend: null, is_loading: false })); + yield put(borisSetStats({ git: [], backend: undefined })); + } finally { + yield put(borisSetStats({ is_loading: false })); } } From 31b03f9eae8ad66edccae2f8d8c02c3ae975856f Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Tue, 2 Mar 2021 16:09:45 +0700 Subject: [PATCH 04/19] flow: refactored sagas --- src/redux/flow/api.ts | 38 ++--- src/redux/flow/sagas.ts | 315 +++++++++++++++++++++------------------- src/redux/flow/types.ts | 10 ++ src/redux/node/api.ts | 54 +++---- src/redux/node/types.ts | 25 ++++ 5 files changed, 228 insertions(+), 214 deletions(-) create mode 100644 src/redux/flow/types.ts diff --git a/src/redux/flow/api.ts b/src/redux/flow/api.ts index f23a8101..d826fb6b 100644 --- a/src/redux/flow/api.ts +++ b/src/redux/flow/api.ts @@ -1,8 +1,8 @@ -import { api, configWithToken, resultMiddleware, errorMiddleware } from '~/utils/api'; +import { api, cleanResult, configWithToken } from '~/utils/api'; import { INode, IResultWithStatus } from '../types'; import { API } from '~/constants/api'; -import { flowSetCellView } from '~/redux/flow/actions'; -import { IFlowState } from './reducer'; +import { PostCellViewRequest, PostCellViewResult } from '~/redux/node/types'; +import { GetSearchResultsRequest, GetSearchResultsResult } from '~/redux/flow/types'; export const postNode = ({ access, @@ -11,32 +11,14 @@ export const postNode = ({ access: string; node: INode; }): Promise> => - api - .post(API.NODE.SAVE, { node }, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + api.post(API.NODE.SAVE, { node }, configWithToken(access)).then(cleanResult); -export const postCellView = ({ - id, - flow, - access, -}: ReturnType & { access: string }): Promise> => +export const postCellView = ({ id, flow }: PostCellViewRequest) => api - .post(API.NODE.SET_CELL_VIEW(id), { flow }, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.NODE.SET_CELL_VIEW(id), { flow }) + .then(cleanResult); -export const getSearchResults = ({ - access, - text, - skip = 0, -}: IFlowState['search'] & { - access: string; - skip: number; -}): Promise> => +export const getSearchResults = ({ text, skip = 0 }: GetSearchResultsRequest) => api - .get(API.SEARCH.NODES, configWithToken(access, { params: { text, skip } })) - .then(resultMiddleware) - .catch(errorMiddleware); + .get(API.SEARCH.NODES, { params: { text, skip } }) + .then(cleanResult); diff --git a/src/redux/flow/sagas.ts b/src/redux/flow/sagas.ts index d8e5ec36..09f4ac8d 100644 --- a/src/redux/flow/sagas.ts +++ b/src/redux/flow/sagas.ts @@ -1,175 +1,188 @@ -import { takeLatest, call, put, select, takeLeading, delay, race, take } from 'redux-saga/effects'; +import { call, delay, put, race, select, take, takeLatest, takeLeading } from 'redux-saga/effects'; import { REHYDRATE } from 'redux-persist'; import { FLOW_ACTIONS } from './constants'; import { getNodeDiff } from '../node/api'; import { - flowSetNodes, - flowSetCellView, - flowSetHeroes, - flowSetRecent, - flowSetUpdated, - flowSetFlow, flowChangeSearch, + flowSetCellView, + flowSetFlow, + flowSetHeroes, + flowSetNodes, + flowSetRecent, flowSetSearch, + flowSetUpdated, } from './actions'; -import { IResultWithStatus, INode, Unwrap } from '../types'; -import { selectFlowNodes, selectFlow } from './selectors'; -import { wrap } from '../auth/sagas'; -import { postCellView, getSearchResults } from './api'; -import { IFlowState } from './reducer'; +import { Unwrap } from '../types'; +import { selectFlow, selectFlowNodes } from './selectors'; +import { getSearchResults, postCellView } from './api'; import { uniq } from 'ramda'; function hideLoader() { - document.getElementById('main_loader').style.display = 'none'; -} + const loader = document.getElementById('main_loader'); -function* onGetFlow() { - const { - flow: { _persist }, - } = yield select(); - - if (!_persist.rehydrated) return; - - const stored: IFlowState['nodes'] = yield select(selectFlowNodes); - - if (stored.length) { - hideLoader(); - } - - yield put(flowSetFlow({ is_loading: true })); - - const { - data: { before = [], after = [], heroes = [], recent = [], updated = [], valid = null }, - }: IResultWithStatus<{ - before: IFlowState['nodes']; - after: IFlowState['nodes']; - heroes: IFlowState['heroes']; - recent: IFlowState['recent']; - updated: IFlowState['updated']; - valid: INode['id'][]; - }> = yield call(wrap, getNodeDiff, { - start: new Date().toISOString(), - end: new Date().toISOString(), - with_heroes: true, - with_updated: true, - with_recent: true, - with_valid: false, - }); - - const result = uniq([...(before || []), ...(after || [])]); - - yield put(flowSetFlow({ is_loading: false, nodes: result })); - - if (heroes.length) yield put(flowSetHeroes(heroes)); - if (recent.length) yield put(flowSetRecent(recent)); - if (updated.length) yield put(flowSetUpdated(updated)); - - if (!stored.length) hideLoader(); -} - -function* onSetCellView({ id, flow }: ReturnType) { - const nodes = yield select(selectFlowNodes); - yield put(flowSetNodes(nodes.map(node => (node.id === id ? { ...node, flow } : node)))); - - const { data, error } = yield call(wrap, postCellView, { id, flow }); - - // TODO: error handling -} - -function* getMore() { - yield put(flowSetFlow({ is_loading: true })); - const nodes: IFlowState['nodes'] = yield select(selectFlowNodes); - - const start = nodes && nodes[0] && nodes[0].created_at; - const end = nodes && nodes[nodes.length - 1] && nodes[nodes.length - 1].created_at; - - const { error, data } = yield call(wrap, getNodeDiff, { - start, - end, - with_heroes: false, - with_updated: true, - with_recent: true, - with_valid: true, - }); - - if (error || !data) return; - - const result = uniq([ - ...(data.before || []), - ...(data.valid ? nodes.filter(node => data.valid.includes(node.id)) : nodes), - ...(data.after || []), - ]); - - yield put( - flowSetFlow({ - is_loading: false, - nodes: result, - ...(data.recent ? { recent: data.recent } : {}), - ...(data.updated ? { updated: data.updated } : {}), - }) - ); - - yield delay(1000); -} - -function* changeSearch({ search }: ReturnType) { - yield put( - flowSetSearch({ - ...search, - is_loading: !!search.text, - }) - ); - - if (!search.text) return; - - yield delay(500); - - const { data, error }: Unwrap = yield call(wrap, getSearchResults, { - ...search, - }); - - if (error) { - yield put(flowSetSearch({ is_loading: false, results: [], total: 0 })); + if (!loader) { return; } - yield put( - flowSetSearch({ - is_loading: false, - results: data.nodes, - total: data.total, - }) - ); + loader.style.display = 'none'; +} + +function* onGetFlow() { + try { + const { + flow: { _persist }, + } = yield select(); + + if (!_persist.rehydrated) return; + + const stored: ReturnType = yield select(selectFlowNodes); + + if (stored.length) { + hideLoader(); + } + + yield put(flowSetFlow({ is_loading: true })); + + const { + before = [], + after = [], + heroes = [], + recent = [], + updated = [], + }: Unwrap = yield call(getNodeDiff, { + start: new Date().toISOString(), + end: new Date().toISOString(), + with_heroes: true, + with_updated: true, + with_recent: true, + with_valid: false, + }); + + const result = uniq([...(before || []), ...(after || [])]); + + yield put(flowSetFlow({ is_loading: false, nodes: result })); + + if (heroes.length) yield put(flowSetHeroes(heroes)); + if (recent.length) yield put(flowSetRecent(recent)); + if (updated.length) yield put(flowSetUpdated(updated)); + + if (!stored.length) hideLoader(); + } catch (error) { + console.log(error); + } +} + +function* onSetCellView({ id, flow }: ReturnType) { + try { + const nodes: ReturnType = yield select(selectFlowNodes); + yield put(flowSetNodes(nodes.map(node => (node.id === id ? { ...node, flow } : node)))); + yield call(postCellView, { id, flow }); + } catch (error) { + console.log(error); + } +} + +function* getMore() { + try { + yield put(flowSetFlow({ is_loading: true })); + const nodes: ReturnType = yield select(selectFlowNodes); + + const start = nodes && nodes[0] && nodes[0].created_at; + const end = nodes && nodes[nodes.length - 1] && nodes[nodes.length - 1].created_at; + + const data: Unwrap = yield call(getNodeDiff, { + start, + end, + with_heroes: false, + with_updated: true, + with_recent: true, + with_valid: true, + }); + + const result = uniq([ + ...(data.before || []), + ...(data.valid ? nodes.filter(node => data.valid.includes(node.id)) : nodes), + ...(data.after || []), + ]); + + yield put( + flowSetFlow({ + is_loading: false, + nodes: result, + ...(data.recent ? { recent: data.recent } : {}), + ...(data.updated ? { updated: data.updated } : {}), + }) + ); + + yield delay(1000); + } catch (error) {} +} + +function* changeSearch({ search }: ReturnType) { + try { + yield put( + flowSetSearch({ + ...search, + is_loading: !!search.text, + }) + ); + + if (!search.text) return; + + yield delay(500); + + const data: Unwrap = yield call(getSearchResults, { + text: search.text, + }); + + yield put( + flowSetSearch({ + results: data.nodes, + total: data.total, + }) + ); + } catch (error) { + yield put(flowSetSearch({ results: [], total: 0 })); + } finally { + yield put(flowSetSearch({ is_loading: false })); + } } function* loadMoreSearch() { - yield put( - flowSetSearch({ - is_loading_more: true, - }) - ); + try { + yield put( + flowSetSearch({ + is_loading_more: true, + }) + ); - const { search }: ReturnType = yield select(selectFlow); + const { search }: ReturnType = yield select(selectFlow); - const { result, delay }: { result: Unwrap; delay: any } = yield race({ - result: call(wrap, getSearchResults, { - ...search, - skip: search.results.length, - }), - delay: take(FLOW_ACTIONS.CHANGE_SEARCH), - }); + const { result, delay }: { result: Unwrap; delay: any } = yield race({ + result: call(getSearchResults, { + ...search, + skip: search.results.length, + }), + delay: take(FLOW_ACTIONS.CHANGE_SEARCH), + }); - if (delay || result.error) { - return put(flowSetSearch({ is_loading_more: false })); + if (delay) { + return; + } + + yield put( + flowSetSearch({ + results: [...search.results, ...result.nodes], + total: result.total, + }) + ); + } catch (error) { + yield put( + flowSetSearch({ + is_loading_more: false, + }) + ); } - - yield put( - flowSetSearch({ - results: [...search.results, ...result.data.nodes], - total: result.data.total, - is_loading_more: false, - }) - ); } export default function* nodeSaga() { diff --git a/src/redux/flow/types.ts b/src/redux/flow/types.ts new file mode 100644 index 00000000..8a5fc788 --- /dev/null +++ b/src/redux/flow/types.ts @@ -0,0 +1,10 @@ +import { INode } from '~/redux/types'; + +export type GetSearchResultsRequest = { + text: string; + skip?: number; +}; +export type GetSearchResultsResult = { + nodes: INode[]; + total: number; +}; diff --git a/src/redux/node/api.ts b/src/redux/node/api.ts index dc13ccc7..2748abf7 100644 --- a/src/redux/node/api.ts +++ b/src/redux/node/api.ts @@ -1,9 +1,10 @@ -import { api, configWithToken, resultMiddleware, errorMiddleware } from '~/utils/api'; +import { api, configWithToken, resultMiddleware, errorMiddleware, cleanResult } from '~/utils/api'; import { INode, IResultWithStatus, IComment } from '../types'; import { API } from '~/constants/api'; import { nodeUpdateTags, nodeLike, nodeStar, nodeLock, nodeLockComment } from './actions'; import { INodeState } from './reducer'; import { COMMENTS_DISPLAY } from './constants'; +import { GetNodeDiffRequest, GetNodeDiffResult } from '~/redux/node/types'; export const postNode = ({ access, @@ -18,7 +19,7 @@ export const postNode = ({ .catch(errorMiddleware); export const getNodes = ({ - from = null, + from, access, }: { from?: string; @@ -30,41 +31,27 @@ export const getNodes = ({ .catch(errorMiddleware); export const getNodeDiff = ({ - start = null, - end = null, + start, + end, take, with_heroes, with_updated, with_recent, with_valid, - access, -}: { - start?: string; - end?: string; - take?: number; - access: string; - with_heroes: boolean; - with_updated: boolean; - with_recent: boolean; - with_valid: boolean; -}): Promise> => +}: GetNodeDiffRequest) => api - .get( - API.NODE.GET_DIFF, - configWithToken(access, { - params: { - start, - end, - take, - with_heroes, - with_updated, - with_recent, - with_valid, - }, - }) - ) - .then(resultMiddleware) - .catch(errorMiddleware); + .get(API.NODE.GET_DIFF, { + params: { + start, + end, + take, + with_heroes, + with_updated, + with_recent, + with_valid, + }, + }) + .then(cleanResult); export const getNode = ({ id, @@ -73,10 +60,7 @@ export const getNode = ({ id: string | number; access: string; }): Promise> => - api - .get(API.NODE.GET_NODE(id), configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + api.get(API.NODE.GET_NODE(id), configWithToken(access)).then(cleanResult); export const postNodeComment = ({ id, diff --git a/src/redux/node/types.ts b/src/redux/node/types.ts index dc3ad0ed..25af91fc 100644 --- a/src/redux/node/types.ts +++ b/src/redux/node/types.ts @@ -6,3 +6,28 @@ export interface IEditorComponentProps { temp: string[]; setTemp: (val: string[]) => void; } + +export type GetNodeDiffRequest = { + start?: string; + end?: string; + take?: number; + with_heroes: boolean; + with_updated: boolean; + with_recent: boolean; + with_valid: boolean; +}; + +export type GetNodeDiffResult = { + before?: INode[]; + after?: INode[]; + heroes?: INode[]; + recent?: INode[]; + updated?: INode[]; + valid: INode['id'][]; +}; + +export type PostCellViewRequest = { + id: INode['id']; + flow: INode['flow']; +}; +export type PostCellViewResult = unknown; // TODO: update it with actual type From 64b27e517f5df04ba5ea6c853cfa7875ef2e742a Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Tue, 2 Mar 2021 16:09:58 +0700 Subject: [PATCH 05/19] flow: refactored reducer --- src/redux/flow/reducer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/redux/flow/reducer.ts b/src/redux/flow/reducer.ts index 1bf96683..7ab73585 100644 --- a/src/redux/flow/reducer.ts +++ b/src/redux/flow/reducer.ts @@ -31,7 +31,7 @@ const INITIAL_STATE: IFlowState = { is_loading_more: false, }, is_loading: false, - error: null, + error: '', }; export default createReducer(INITIAL_STATE, FLOW_HANDLERS); From be7829f1308ceb047f5edc1cf4973c0fa984399e Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Tue, 2 Mar 2021 16:35:46 +0700 Subject: [PATCH 06/19] messages: refactored reducer --- src/redux/messages/api.ts | 63 +++------ src/redux/messages/sagas.ts | 258 ++++++++++++++++++------------------ src/redux/messages/types.ts | 26 ++++ 3 files changed, 176 insertions(+), 171 deletions(-) create mode 100644 src/redux/messages/types.ts diff --git a/src/redux/messages/api.ts b/src/redux/messages/api.ts index ff69487d..2e224ab3 100644 --- a/src/redux/messages/api.ts +++ b/src/redux/messages/api.ts @@ -1,48 +1,29 @@ -import { IMessage, IResultWithStatus } from '~/redux/types'; -import { api, configWithToken, errorMiddleware, resultMiddleware } from '~/utils/api'; +import { api, cleanResult } from '~/utils/api'; import { API } from '~/constants/api'; +import { + ApiDeleteMessageRequest, + ApiDeleteMessageResult, + ApiGetUserMessagesRequest, + ApiGetUserMessagesResponse, + ApiSendMessageRequest, + ApiSendMessageResult, +} from '~/redux/messages/types'; -export const apiMessagesGetUserMessages = ({ - access, - username, - after, - before, -}: { - access: string; - username: string; - after?: string; - before?: string; -}): Promise> => +export const apiGetUserMessages = ({ username, after, before }: ApiGetUserMessagesRequest) => api - .get(API.USER.MESSAGES(username), configWithToken(access, { params: { after, before } })) - .then(resultMiddleware) - .catch(errorMiddleware); + .get(API.USER.MESSAGES(username), { + params: { after, before }, + }) + .then(cleanResult); -export const apiMessagesSendMessage = ({ - access, - username, - message, -}): Promise> => +export const apiSendMessage = ({ username, message }: ApiSendMessageRequest) => api - .post(API.USER.MESSAGE_SEND(username), { message }, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.USER.MESSAGE_SEND(username), { message }) + .then(cleanResult); -export const apiMessagesDeleteMessage = ({ - access, - username, - id, - is_locked, -}: { - access: string; - username: string; - id: number; - is_locked: boolean; -}): Promise> => +export const apiDeleteMessage = ({ username, id, is_locked }: ApiDeleteMessageRequest) => api - .delete( - API.USER.MESSAGE_DELETE(username, id), - configWithToken(access, { params: { is_locked } }) - ) - .then(resultMiddleware) - .catch(errorMiddleware); + .delete(API.USER.MESSAGE_DELETE(username, id), { + params: { is_locked }, + }) + .then(cleanResult); diff --git a/src/redux/messages/sagas.ts b/src/redux/messages/sagas.ts index cbce096d..3f9d8bfc 100644 --- a/src/redux/messages/sagas.ts +++ b/src/redux/messages/sagas.ts @@ -5,14 +5,9 @@ import { selectAuthProfileUsername, selectAuthUpdates, } from '~/redux/auth/selectors'; -import { - apiMessagesDeleteMessage, - apiMessagesGetUserMessages, - apiMessagesSendMessage, -} from '~/redux/messages/api'; +import { apiDeleteMessage, apiGetUserMessages, apiSendMessage } from '~/redux/messages/api'; import { ERRORS } from '~/constants/errors'; import { IMessageNotification, Unwrap } from '~/redux/types'; -import { wrap } from '~/redux/auth/sagas'; import { messagesDeleteMessage, messagesGetMessages, @@ -25,185 +20,188 @@ import { selectMessages } from '~/redux/messages/selectors'; import { sortCreatedAtDesc } from '~/utils/date'; function* getMessages({ username }: ReturnType) { - const { messages }: ReturnType = yield select(selectMessages); + try { + const { messages }: ReturnType = yield select(selectMessages); - yield put( - messagesSet({ - is_loading_messages: true, - messages: - messages && - messages.length > 0 && - (messages[0].to.username === username || messages[0].from.username === username) - ? messages - : [], - }) - ); - - const { error, data }: Unwrap = yield call( - wrap, - apiMessagesGetUserMessages, - { username } - ); - - if (error || !data.messages) { - return yield put( + yield put( messagesSet({ - is_loading_messages: false, - error: ERRORS.EMPTY_RESPONSE, + is_loading_messages: true, + messages: + messages && + messages.length > 0 && + (messages[0].to.username === username || messages[0].from.username === username) + ? messages + : [], }) ); - } - yield put(messagesSet({ is_loading_messages: false, messages: data.messages })); + const data: Unwrap = yield call(apiGetUserMessages, { + username, + }); - const { notifications }: ReturnType = yield select(selectAuthUpdates); + yield put(messagesSet({ is_loading_messages: false, messages: data.messages })); - // clear viewed message from notifcation list - const filtered = notifications.filter( - notification => - notification.type !== 'message' || - (notification as IMessageNotification).content.from.username !== username - ); + const { notifications }: ReturnType = yield select(selectAuthUpdates); - if (filtered.length !== notifications.length) { - yield put(authSetUpdates({ notifications: filtered })); + // clear viewed message from notifcation list + const filtered = notifications.filter( + notification => + notification.type !== 'message' || + (notification as IMessageNotification)?.content?.from?.username !== username + ); + + if (filtered.length !== notifications.length) { + yield put(authSetUpdates({ notifications: filtered })); + } + } catch (error) { + messagesSet({ + error: error || ERRORS.EMPTY_RESPONSE, + }); + } finally { + yield put( + messagesSet({ + is_loading_messages: false, + }) + ); } } function* sendMessage({ message, onSuccess }: ReturnType) { - const username: ReturnType = yield select( - selectAuthProfileUsername - ); + try { + const username: ReturnType = yield select( + selectAuthProfileUsername + ); - if (!username) return; + if (!username) return; - yield put(messagesSet({ is_sending_messages: true, error: null })); + yield put(messagesSet({ is_sending_messages: true, error: '' })); - const { error, data }: Unwrap = yield call( - wrap, - apiMessagesSendMessage, - { + const data: Unwrap = yield call(apiSendMessage, { username, message, + }); + + const { user }: ReturnType = yield select(selectAuthProfile); + + if (user?.username !== username) { + return yield put(messagesSet({ is_sending_messages: false })); } - ); - if (error || !data.message) { - return yield put( - messagesSet({ - is_sending_messages: false, - error: error || ERRORS.EMPTY_RESPONSE, - }) - ); - } + const { messages }: ReturnType = yield select(selectMessages); - const { user }: ReturnType = yield select(selectAuthProfile); + if (message.id && message.id > 0) { + // modified + yield put( + messagesSet({ + is_sending_messages: false, + messages: messages.map(item => (item.id === message.id ? data.message : item)), + }) + ); + } else { + // created + yield put( + messagesSet({ + is_sending_messages: false, + messages: [data.message, ...messages], + }) + ); + } - if (user.username !== username) { - return yield put(messagesSet({ is_sending_messages: false })); - } - - const { messages }: ReturnType = yield select(selectMessages); - - if (message.id > 0) { - // modified + onSuccess(); + } catch (error) { + messagesSet({ + error: error || ERRORS.EMPTY_RESPONSE, + }); + } finally { yield put( messagesSet({ - is_sending_messages: false, - messages: messages.map(item => (item.id === message.id ? data.message : item)), - }) - ); - } else { - // created - yield put( - messagesSet({ - is_sending_messages: false, - messages: [data.message, ...messages], + is_loading_messages: false, }) ); } - - onSuccess(); } function* deleteMessage({ id, is_locked }: ReturnType) { - const username: ReturnType = yield select( - selectAuthProfileUsername - ); + try { + const username: ReturnType = yield select( + selectAuthProfileUsername + ); - if (!username) return; + if (!username) return; - yield put(messagesSet({ is_sending_messages: true, error: null })); + yield put(messagesSet({ is_sending_messages: true, error: '' })); - const { error, data }: Unwrap = yield call( - wrap, - apiMessagesDeleteMessage, - { + const data: Unwrap = yield call(apiDeleteMessage, { username, id, is_locked, - } - ); + }); - if (error || !data.message) { - return yield put( + const currentUsername: ReturnType = yield select( + selectAuthProfileUsername + ); + + if (currentUsername !== username) { + return yield put(messagesSet({ is_sending_messages: false })); + } + + const { messages }: ReturnType = yield select(selectMessages); + + yield put( messagesSet({ is_sending_messages: false, + messages: messages.map(item => (item.id === id ? data.message : item)), + }) + ); + } catch (error) { + messagesSet({ + error: error || ERRORS.EMPTY_RESPONSE, + }); + } finally { + yield put( + messagesSet({ + is_loading_messages: false, }) ); } - - const currentUsername: ReturnType = yield select( - selectAuthProfileUsername - ); - - if (currentUsername !== username) { - return yield put(messagesSet({ is_sending_messages: false })); - } - - const { messages }: ReturnType = yield select(selectMessages); - - yield put( - messagesSet({ - is_sending_messages: false, - messages: messages.map(item => (item.id === id ? data.message : item)), - }) - ); } function* refreshMessages({}: ReturnType) { - const username: ReturnType = yield select( - selectAuthProfileUsername - ); + try { + const username: ReturnType = yield select( + selectAuthProfileUsername + ); - if (!username) return; + if (!username) return; - const { messages }: ReturnType = yield select(selectMessages); + const { messages }: ReturnType = yield select(selectMessages); - yield put(messagesSet({ is_loading_messages: true })); + yield put(messagesSet({ is_loading_messages: true })); - const after = messages.length > 0 ? messages[0].created_at : undefined; + const after = messages.length > 0 ? messages[0].created_at : undefined; - const { data, error }: Unwrap = yield call( - wrap, - apiMessagesGetUserMessages, - { username, after } - ); + const data: Unwrap = yield call(apiGetUserMessages, { + username, + after, + }); - yield put(messagesSet({ is_loading_messages: false })); + yield put(messagesSet({ is_loading_messages: false })); - if (error) { - return yield put( + if (!data.messages || !data.messages.length) return; + + const newMessages = [...data.messages, ...messages].sort(sortCreatedAtDesc); + yield put(messagesSet({ messages: newMessages })); + } catch (error) { + messagesSet({ + error: error || ERRORS.EMPTY_RESPONSE, + }); + } finally { + yield put( messagesSet({ - error: error || ERRORS.EMPTY_RESPONSE, + is_loading_messages: false, }) ); } - - if (!data.messages || !data.messages.length) return; - - const newMessages = [...data.messages, ...messages].sort(sortCreatedAtDesc); - yield put(messagesSet({ messages: newMessages })); } export default function*() { diff --git a/src/redux/messages/types.ts b/src/redux/messages/types.ts new file mode 100644 index 00000000..519e7057 --- /dev/null +++ b/src/redux/messages/types.ts @@ -0,0 +1,26 @@ +import { IMessage } from '~/redux/types'; + +export type ApiGetUserMessagesRequest = { + username: string; + after?: string; + before?: string; +}; +export type ApiGetUserMessagesResponse = { messages: IMessage[] }; + +export type ApiSendMessageRequest = { + username: string; + message: Partial; +}; +export type ApiSendMessageResult = { + message: IMessage; +}; + +export type ApiDeleteMessageRequest = { + username: string; + id: number; + is_locked: boolean; +}; + +export type ApiDeleteMessageResult = { + message: IMessage; +}; From 5102c738b3b2d1e0156496b2572b6a855bc52077 Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Wed, 3 Mar 2021 11:10:28 +0700 Subject: [PATCH 07/19] node: refactored sagas --- src/constants/api.ts | 4 +- src/redux/node/actions.ts | 5 - src/redux/node/api.ts | 169 ++++++--------- src/redux/node/constants.ts | 1 - src/redux/node/sagas.ts | 409 ++++++++++++++++++------------------ src/redux/node/types.ts | 54 ++++- 6 files changed, 318 insertions(+), 324 deletions(-) diff --git a/src/constants/api.ts b/src/constants/api.ts index 8dc1cbe6..6f57d689 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -31,9 +31,9 @@ export const API = { RELATED: (id: INode['id']) => `/node/${id}/related`, UPDATE_TAGS: (id: INode['id']) => `/node/${id}/tags`, POST_LIKE: (id: INode['id']) => `/node/${id}/like`, - POST_STAR: (id: INode['id']) => `/node/${id}/heroic`, + POST_HEROIC: (id: INode['id']) => `/node/${id}/heroic`, POST_LOCK: (id: INode['id']) => `/node/${id}/lock`, - POST_LOCK_COMMENT: (id: INode['id'], comment_id: IComment['id']) => + LOCK_COMMENT: (id: INode['id'], comment_id: IComment['id']) => `/node/${id}/comment/${comment_id}/lock`, SET_CELL_VIEW: (id: INode['id']) => `/node/${id}/cell-view`, }, diff --git a/src/redux/node/actions.ts b/src/redux/node/actions.ts index 776cb852..d2f95963 100644 --- a/src/redux/node/actions.ts +++ b/src/redux/node/actions.ts @@ -55,11 +55,6 @@ export const nodePostLocalComment = ( type: NODE_ACTIONS.POST_COMMENT, }); -export const nodeCancelCommentEdit = (id: number) => ({ - id, - type: NODE_ACTIONS.CANCEL_COMMENT_EDIT, -}); - export const nodeSetSendingComment = (is_sending_comment: boolean) => ({ is_sending_comment, type: NODE_ACTIONS.SET_SENDING_COMMENT, diff --git a/src/redux/node/api.ts b/src/redux/node/api.ts index 2748abf7..e33d49b4 100644 --- a/src/redux/node/api.ts +++ b/src/redux/node/api.ts @@ -1,22 +1,43 @@ -import { api, configWithToken, resultMiddleware, errorMiddleware, cleanResult } from '~/utils/api'; -import { INode, IResultWithStatus, IComment } from '../types'; +import { api, cleanResult, configWithToken, errorMiddleware, resultMiddleware } from '~/utils/api'; +import { IComment, INode, IResultWithStatus } from '../types'; import { API } from '~/constants/api'; -import { nodeUpdateTags, nodeLike, nodeStar, nodeLock, nodeLockComment } from './actions'; -import { INodeState } from './reducer'; import { COMMENTS_DISPLAY } from './constants'; -import { GetNodeDiffRequest, GetNodeDiffResult } from '~/redux/node/types'; +import { + ApiGetNodeRelatedRequest, + ApiGetNodeRelatedResult, + ApiGetNodeRequest, + ApiGetNodeResult, + ApiLockCommentRequest, + ApiLockcommentResult, + ApiLockNodeRequest, + ApiLockNodeResult, + ApiPostCommentRequest, + ApiPostCommentResult, + ApiPostNodeHeroicRequest, + ApiPostNodeHeroicResponse, + ApiPostNodeLikeRequest, + ApiPostNodeLikeResult, + ApiPostNodeTagsRequest, + ApiPostNodeTagsResult, + GetNodeDiffRequest, + GetNodeDiffResult, +} from '~/redux/node/types'; -export const postNode = ({ - access, - node, -}: { - access: string; +export type ApiPostNodeRequest = { node: INode }; +export type ApiPostNodeResult = { node: INode; -}): Promise> => - api - .post(API.NODE.SAVE, node, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + errors: Record; +}; + +export type ApiGetNodeCommentsRequest = { + id: number; + take?: number; + skip?: number; +}; +export type ApiGetNodeCommentsResponse = { comments: IComment[]; comment_count: number }; + +export const apiPostNode = ({ node }: ApiPostNodeRequest) => + api.post(API.NODE.SAVE, node).then(cleanResult); export const getNodes = ({ from, @@ -53,113 +74,41 @@ export const getNodeDiff = ({ }) .then(cleanResult); -export const getNode = ({ - id, - access, -}: { - id: string | number; - access: string; -}): Promise> => - api.get(API.NODE.GET_NODE(id), configWithToken(access)).then(cleanResult); +export const apiGetNode = ({ id }: ApiGetNodeRequest) => + api.get(API.NODE.GET_NODE(id)).then(cleanResult); -export const postNodeComment = ({ - id, - data, - access, -}: { - access: string; - id: number; - data: IComment; -}): Promise> => - api - .post(API.NODE.COMMENT(id), data, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiPostComment = ({ id, data }: ApiPostCommentRequest) => + api.post(API.NODE.COMMENT(id), data).then(cleanResult); -export const getNodeComments = ({ +export const apiGetNodeComments = ({ id, - access, take = COMMENTS_DISPLAY, skip = 0, -}: { - id: number; - access: string; - take?: number; - skip?: number; -}): Promise> => +}: ApiGetNodeCommentsRequest) => api - .get(API.NODE.COMMENT(id), configWithToken(access, { params: { take, skip } })) - .then(resultMiddleware) - .catch(errorMiddleware); + .get(API.NODE.COMMENT(id), { params: { take, skip } }) + .then(cleanResult); -export const getNodeRelated = ({ - id, - access, -}: { - id: number; - access: string; -}): Promise> => - api - .get(API.NODE.RELATED(id), configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiGetNodeRelated = ({ id }: ApiGetNodeRelatedRequest) => + api.get(API.NODE.RELATED(id)).then(cleanResult); -export const updateNodeTags = ({ - id, - tags, - access, -}: ReturnType & { access: string }): Promise> => +export const apiPostNodeTags = ({ id, tags }: ApiPostNodeTagsRequest) => api - .post(API.NODE.UPDATE_TAGS(id), { tags }, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.NODE.UPDATE_TAGS(id), { tags }) + .then(cleanResult); -export const postNodeLike = ({ - id, - access, -}: ReturnType & { access: string }): Promise> => - api - .post(API.NODE.POST_LIKE(id), {}, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiPostNodeLike = ({ id }: ApiPostNodeLikeRequest) => + api.post(API.NODE.POST_LIKE(id)).then(cleanResult); -export const postNodeStar = ({ - id, - access, -}: ReturnType & { access: string }): Promise> => - api - .post(API.NODE.POST_STAR(id), {}, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiPostNodeHeroic = ({ id }: ApiPostNodeHeroicRequest) => + api.post(API.NODE.POST_HEROIC(id)).then(cleanResult); -export const postNodeLock = ({ - id, - is_locked, - access, -}: ReturnType & { access: string }): Promise> => +export const apiLockNode = ({ id, is_locked }: ApiLockNodeRequest) => api - .post(API.NODE.POST_LOCK(id), { is_locked }, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.NODE.POST_LOCK(id), { is_locked }) + .then(cleanResult); -export const postNodeLockComment = ({ - id, - is_locked, - current, - access, -}: ReturnType & { - access: string; - current: INode['id']; -}): Promise> => +export const apiLockComment = ({ id, is_locked, current }: ApiLockCommentRequest) => api - .post(API.NODE.POST_LOCK_COMMENT(current, id), { is_locked }, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.NODE.LOCK_COMMENT(current, id), { is_locked }) + .then(cleanResult); diff --git a/src/redux/node/constants.ts b/src/redux/node/constants.ts index 81ef9a79..acd3f348 100644 --- a/src/redux/node/constants.ts +++ b/src/redux/node/constants.ts @@ -29,7 +29,6 @@ export const NODE_ACTIONS = { LOCK: `${prefix}LOCK`, LOCK_COMMENT: `${prefix}LOCK_COMMENT`, EDIT_COMMENT: `${prefix}EDIT_COMMENT`, - CANCEL_COMMENT_EDIT: `${prefix}CANCEL_COMMENT_EDIT`, CREATE: `${prefix}CREATE`, LOAD_MORE_COMMENTS: `${prefix}LOAD_MORE_COMMENTS`, diff --git a/src/redux/node/sagas.ts b/src/redux/node/sagas.ts index 53c85f8e..2e95ff8b 100644 --- a/src/redux/node/sagas.ts +++ b/src/redux/node/sagas.ts @@ -1,6 +1,5 @@ -import { all, call, delay, put, select, takeLatest, takeLeading } from 'redux-saga/effects'; +import { all, call, put, select, takeLatest, takeLeading } from 'redux-saga/effects'; import { push } from 'connected-react-router'; -import { omit } from 'ramda'; import { COMMENTS_DISPLAY, @@ -10,10 +9,8 @@ import { NODE_EDITOR_DATA, } from './constants'; import { - nodeCancelCommentEdit, nodeCreate, nodeEdit, - nodeEditComment, nodeGotoNode, nodeLike, nodeLoadNode, @@ -34,35 +31,33 @@ import { nodeUpdateTags, } from './actions'; import { - getNode, - getNodeComments, - getNodeRelated, - postNode, - postNodeComment, - postNodeLike, - postNodeLock, - postNodeLockComment, - postNodeStar, - updateNodeTags, + apiGetNode, + apiGetNodeComments, + apiGetNodeRelated, + apiLockComment, + apiLockNode, + apiPostComment, + apiPostNode, + apiPostNodeHeroic, + apiPostNodeLike, + apiPostNodeTags, } from './api'; -import { wrap } from '../auth/sagas'; import { flowSetNodes, flowSetUpdated } from '../flow/actions'; import { ERRORS } from '~/constants/errors'; import { modalSetShown, modalShowDialog } from '../modal/actions'; import { selectFlow, selectFlowNodes } from '../flow/selectors'; import { URLS } from '~/constants/urls'; import { selectNode } from './selectors'; -import { INode, IResultWithStatus, Unwrap } from '../types'; +import { Unwrap } from '../types'; import { NODE_EDITOR_DIALOGS } from '~/constants/dialogs'; import { DIALOGS } from '~/redux/modal/constants'; -import { INodeState } from './reducer'; -import { IFlowState } from '../flow/reducer'; export function* updateNodeEverywhere(node) { const { current: { id }, - }: INodeState = yield select(selectNode); - const flow_nodes: IFlowState['nodes'] = yield select(selectFlowNodes); + }: ReturnType = yield select(selectNode); + + const flow_nodes: ReturnType = yield select(selectFlowNodes); if (id === node.id) { yield put(nodeSetCurrent(node)); @@ -78,35 +73,33 @@ export function* updateNodeEverywhere(node) { } function* onNodeSave({ node }: ReturnType) { - yield put(nodeSetSaveErrors({})); + try { + yield put(nodeSetSaveErrors({})); - const { - error, - data: { errors, node: result }, - } = yield call(wrap, postNode, { node }); + const { errors, node: result }: Unwrap = yield call(apiPostNode, { node }); - if (errors && Object.values(errors).length > 0) { - return yield put(nodeSetSaveErrors(errors)); + if (errors && Object.values(errors).length > 0) { + yield put(nodeSetSaveErrors(errors)); + return; + } + + const nodes: ReturnType = yield select(selectFlowNodes); + const updated_flow_nodes = node.id + ? nodes.map(item => (item.id === result.id ? result : item)) + : [result, ...nodes]; + + yield put(flowSetNodes(updated_flow_nodes)); + + const { current } = yield select(selectNode); + + if (node.id && current.id === result.id) { + yield put(nodeSetCurrent(result)); + } + + return yield put(modalSetShown(false)); + } catch (error) { + yield put(nodeSetSaveErrors({ error: error || ERRORS.CANT_SAVE_NODE })); } - - if (error || !result || !result.id) { - return yield put(nodeSetSaveErrors({ error: error || ERRORS.CANT_SAVE_NODE })); - } - - const nodes = yield select(selectFlowNodes); - const updated_flow_nodes = node.id - ? nodes.map(item => (item.id === result.id ? result : item)) - : [result, ...nodes]; - - yield put(flowSetNodes(updated_flow_nodes)); - - const { current } = yield select(selectNode); - - if (node.id && current.id === result.id) { - yield put(nodeSetCurrent(result)); - } - - return yield put(modalSetShown(false)); } function* onNodeGoto({ id, node_type }: ReturnType) { @@ -114,134 +107,120 @@ function* onNodeGoto({ id, node_type }: ReturnType) { yield put(nodeLoadNode(id)); yield put(nodeSetCommentData(0, { ...EMPTY_COMMENT })); - yield put(nodeSetRelated(null)); + yield put(nodeSetRelated({ albums: {}, similar: [] })); } function* onNodeLoadMoreComments() { - const { - current: { id }, - comments, - }: ReturnType = yield select(selectNode); + try { + const { + current: { id }, + comments, + }: ReturnType = yield select(selectNode); - const { data, error }: Unwrap = yield call(wrap, getNodeComments, { - id, - take: COMMENTS_DISPLAY, - skip: comments.length, - }); + if (!id) { + return; + } - const current: ReturnType = yield select(selectNode); + const data: Unwrap = yield call(apiGetNodeComments, { + id, + take: COMMENTS_DISPLAY, + skip: comments.length, + }); - if (!data || error || current.current.id != id) { - return; - } + const current: ReturnType = yield select(selectNode); - yield put( - nodeSet({ - comments: [...comments, ...data.comments], - comment_count: data.comment_count, - }) - ); + if (!data || current.current.id != id) { + return; + } + + yield put( + nodeSet({ + comments: [...comments, ...data.comments], + comment_count: data.comment_count, + }) + ); + } catch (error) {} } -function* onNodeLoad({ id, order = 'ASC' }: ReturnType) { - yield put(nodeSetLoading(true)); - yield put(nodeSetLoadingComments(true)); +function* onNodeLoad({ id }: ReturnType) { + // Get node body + try { + yield put(nodeSetLoading(true)); + yield put(nodeSetLoadingComments(true)); - const { - data: { node, error }, - } = yield call(wrap, getNode, { id }); + const { node }: Unwrap = yield call(apiGetNode, { id }); - if (error || !node || !node.id) { + yield put(nodeSetCurrent(node)); + yield put(nodeSetLoading(false)); + } catch (error) { yield put(push(URLS.ERRORS.NOT_FOUND)); yield put(nodeSetLoading(false)); - return; } - yield put(nodeSetCurrent(node)); - yield put(nodeSetLoading(false)); + // Comments and related + try { + const [{ comments, comment_count }, { related }]: [ + Unwrap, + Unwrap + ] = yield all([ + call(apiGetNodeComments, { id, take: COMMENTS_DISPLAY, skip: 0 }), + call(apiGetNodeRelated, { id }), + ]); - const { - comments: { - data: { comments, comment_count }, - }, - related: { - data: { related }, - }, - } = yield all({ - comments: call(wrap, getNodeComments, { id, take: COMMENTS_DISPLAY, skip: 0 }), - related: call(wrap, getNodeRelated, { id }), - }); - - yield put( - nodeSet({ - comments, - comment_count, - related, - is_loading_comments: false, - comment_data: { 0: { ...EMPTY_COMMENT } }, - }) - ); + yield put( + nodeSet({ + comments, + comment_count, + related, + is_loading_comments: false, + }) + ); + } catch {} + // Remove current node from recently updated const { updated } = yield select(selectFlow); if (updated.some(item => item.id === id)) { yield put(flowSetUpdated(updated.filter(item => item.id !== id))); } - - return; } function* onPostComment({ nodeId, comment, callback }: ReturnType) { - const { data, error }: Unwrap = yield call(wrap, postNodeComment, { - data: comment, - id: nodeId, - }); + try { + const data: Unwrap = yield call(apiPostComment, { + data: comment, + id: nodeId, + }); - if (error || !data.comment) { + const { current }: ReturnType = yield select(selectNode); + + if (current?.id === nodeId) { + const { comments }: ReturnType = yield select(selectNode); + + if (!comment.id) { + yield put(nodeSetComments([data.comment, ...comments])); + } else { + yield put( + nodeSet({ + comments: comments.map(item => (item.id === comment.id ? data.comment : item)), + }) + ); + } + + callback(); + } + } catch (error) { return callback(error); } - - const { current }: ReturnType = yield select(selectNode); - - if (current?.id === nodeId) { - const { comments } = yield select(selectNode); - - if (!comment.id) { - yield put(nodeSetComments([data.comment, ...comments])); - } else { - yield put( - nodeSet({ - comments: comments.map(item => (item.id === comment.id ? data.comment : item)), - }) - ); - } - - callback(); - } -} - -function* onCancelCommentEdit({ id }: ReturnType) { - const { comment_data } = yield select(selectNode); - - yield put( - nodeSet({ - comment_data: omit([id.toString()], comment_data), - }) - ); } function* onUpdateTags({ id, tags }: ReturnType) { - yield delay(100); - - const { - data: { node }, - }: IResultWithStatus<{ node: INode }> = yield call(wrap, updateNodeTags, { id, tags }); - - const { current } = yield select(selectNode); - - if (!node || !node.id || node.id !== current.id) return; - - yield put(nodeSetTags(node.tags)); + try { + const { node }: Unwrap = yield call(apiPostNodeTags, { id, tags }); + const { current }: ReturnType = yield select(selectNode); + if (!node || !node.id || node.id !== current.id) return; + yield put(nodeSetTags(node.tags)); + } catch {} } function* onCreateSaga({ node_type: type }: ReturnType) { @@ -252,96 +231,118 @@ function* onCreateSaga({ node_type: type }: ReturnType) { } function* onEditSaga({ id }: ReturnType) { - yield put(modalShowDialog(DIALOGS.LOADING)); + try { + if (!id) { + return; + } - const { - data: { node }, - error, - } = yield call(wrap, getNode, { id }); + yield put(modalShowDialog(DIALOGS.LOADING)); - if (error || !node || !node.type || !NODE_EDITOR_DIALOGS[node.type]) - return yield put(modalSetShown(false)); + const { node }: Unwrap = yield call(apiGetNode, { id }); - yield put(nodeSetEditor(node)); - yield put(modalShowDialog(NODE_EDITOR_DIALOGS[node.type])); + if (!NODE_EDITOR_DIALOGS[node?.type]) { + throw new Error('Unknown node type'); + } - return true; + yield put(nodeSetEditor(node)); + yield put(modalShowDialog(NODE_EDITOR_DIALOGS[node.type])); + } catch (error) { + yield put(modalSetShown(false)); + } } function* onLikeSaga({ id }: ReturnType) { - const { - current, - current: { is_liked, like_count }, - } = yield select(selectNode); + try { + const { current }: ReturnType = yield select(selectNode); - yield call(updateNodeEverywhere, { - ...current, - is_liked: !is_liked, - like_count: is_liked ? Math.max(like_count - 1, 0) : like_count + 1, - }); + const count = current.like_count || 0; - const { data, error } = yield call(wrap, postNodeLike, { id }); + yield call(updateNodeEverywhere, { + ...current, + is_liked: !current.is_liked, + like_count: current.is_liked ? Math.max(count - 1, 0) : count + 1, + }); - if (!error || data.is_liked === !is_liked) return; // ok and matches + const data: Unwrap = yield call(apiPostNodeLike, { id }); - yield call(updateNodeEverywhere, { ...current, is_liked, like_count }); + yield call(updateNodeEverywhere, { + ...current, + is_liked: data.is_liked, + }); + } catch {} } function* onStarSaga({ id }: ReturnType) { - const { - current, - current: { is_heroic }, - } = yield select(selectNode); + try { + const { + current, + current: { is_heroic }, + } = yield select(selectNode); - yield call(updateNodeEverywhere, { ...current, is_heroic: !is_heroic }); + yield call(updateNodeEverywhere, { ...current, is_heroic: !is_heroic }); - const { data, error } = yield call(wrap, postNodeStar, { id }); + const data: Unwrap = yield call(apiPostNodeHeroic, { id }); - if (!error || data.is_heroic === !is_heroic) return; // ok and matches - - yield call(updateNodeEverywhere, { ...current, is_heroic }); + yield call(updateNodeEverywhere, { ...current, is_heroic: data.is_heroic }); + } catch {} } function* onLockSaga({ id, is_locked }: ReturnType) { - const { - current, - current: { deleted_at }, - } = yield select(selectNode); + const { current }: ReturnType = yield select(selectNode); - yield call(updateNodeEverywhere, { - ...current, - deleted_at: is_locked ? new Date().toISOString() : null, - }); + try { + yield call(updateNodeEverywhere, { + ...current, + deleted_at: is_locked ? new Date().toISOString() : null, + }); - const { error } = yield call(wrap, postNodeLock, { id, is_locked }); + const data: Unwrap = yield call(apiLockNode, { id, is_locked }); - if (error) return yield call(updateNodeEverywhere, { ...current, deleted_at }); + yield call(updateNodeEverywhere, { + ...current, + deleted_at: data.deleted_at || undefined, + }); + } catch { + yield call(updateNodeEverywhere, { ...current, deleted_at: current.deleted_at }); + } } function* onLockCommentSaga({ id, is_locked }: ReturnType) { - const { current, comments } = yield select(selectNode); + const { current, comments }: ReturnType = yield select(selectNode); - yield put( - nodeSetComments( - comments.map(comment => - comment.id === id - ? { ...comment, deleted_at: is_locked ? new Date().toISOString() : null } - : comment + try { + yield put( + nodeSetComments( + comments.map(comment => + comment.id === id + ? { ...comment, deleted_at: is_locked ? new Date().toISOString() : undefined } + : comment + ) ) - ) - ); + ); - yield call(wrap, postNodeLockComment, { current: current.id, id, is_locked }); -} + const data: Unwrap = yield call(apiLockComment, { + current: current.id, + id, + is_locked, + }); -function* onEditCommentSaga({ id }: ReturnType) { - const { comments } = yield select(selectNode); - - const comment = comments.find(item => item.id === id); - - if (!comment) return; - - yield put(nodeSetCommentData(id, { ...EMPTY_COMMENT, ...comment })); + yield put( + nodeSetComments( + comments.map(comment => + comment.id === id ? { ...comment, deleted_at: data.deleted_at || undefined } : comment + ) + ) + ); + } catch { + yield put( + nodeSetComments( + comments.map(comment => + comment.id === id ? { ...comment, deleted_at: current.deleted_at } : comment + ) + ) + ); + } } export default function* nodeSaga() { @@ -349,7 +350,6 @@ export default function* nodeSaga() { yield takeLatest(NODE_ACTIONS.GOTO_NODE, onNodeGoto); yield takeLatest(NODE_ACTIONS.LOAD_NODE, onNodeLoad); yield takeLatest(NODE_ACTIONS.POST_COMMENT, onPostComment); - yield takeLatest(NODE_ACTIONS.CANCEL_COMMENT_EDIT, onCancelCommentEdit); yield takeLatest(NODE_ACTIONS.UPDATE_TAGS, onUpdateTags); yield takeLatest(NODE_ACTIONS.CREATE, onCreateSaga); yield takeLatest(NODE_ACTIONS.EDIT, onEditSaga); @@ -357,6 +357,5 @@ export default function* nodeSaga() { yield takeLatest(NODE_ACTIONS.STAR, onStarSaga); yield takeLatest(NODE_ACTIONS.LOCK, onLockSaga); yield takeLatest(NODE_ACTIONS.LOCK_COMMENT, onLockCommentSaga); - yield takeLatest(NODE_ACTIONS.EDIT_COMMENT, onEditCommentSaga); yield takeLeading(NODE_ACTIONS.LOAD_MORE_COMMENTS, onNodeLoadMoreComments); } diff --git a/src/redux/node/types.ts b/src/redux/node/types.ts index 25af91fc..d7973326 100644 --- a/src/redux/node/types.ts +++ b/src/redux/node/types.ts @@ -1,4 +1,5 @@ -import { INode } from '~/redux/types'; +import { IComment, INode } from '~/redux/types'; +import { INodeState } from '~/redux/node/reducer'; export interface IEditorComponentProps { data: INode; @@ -31,3 +32,54 @@ export type PostCellViewRequest = { flow: INode['flow']; }; export type PostCellViewResult = unknown; // TODO: update it with actual type + +export type ApiGetNodeRequest = { + id: string | number; +}; +export type ApiGetNodeResult = { node: INode }; + +export type ApiGetNodeRelatedRequest = { + id: INode['id']; +}; +export type ApiGetNodeRelatedResult = { + related: INodeState['related']; +}; + +export type ApiPostCommentRequest = { + id: INode['id']; + data: IComment; +}; +export type ApiPostCommentResult = { + comment: IComment; +}; + +export type ApiPostNodeTagsRequest = { + id: INode['id']; + tags: string[]; +}; +export type ApiPostNodeTagsResult = { + node: INode; +}; + +export type ApiPostNodeLikeRequest = { id: INode['id'] }; +export type ApiPostNodeLikeResult = { is_liked: boolean }; + +export type ApiPostNodeHeroicRequest = { id: INode['id'] }; +export type ApiPostNodeHeroicResponse = { is_heroic: boolean }; + +export type ApiLockNodeRequest = { + id: INode['id']; + is_locked: boolean; +}; +export type ApiLockNodeResult = { + deleted_at: string; +}; + +export type ApiLockCommentRequest = { + id: IComment['id']; + current: INode['id']; + is_locked: boolean; +}; +export type ApiLockcommentResult = { + deleted_at: string; +}; From 7d4df409633b97495e0c46c133e1c4040c547f61 Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Wed, 3 Mar 2021 11:17:54 +0700 Subject: [PATCH 08/19] player: refactored sagas --- src/redux/player/api.ts | 13 +++++-------- src/redux/player/reducer.ts | 4 ++-- src/redux/player/sagas.ts | 29 +++++++++++++++++++---------- src/redux/player/types.ts | 3 +++ 4 files changed, 29 insertions(+), 20 deletions(-) create mode 100644 src/redux/player/types.ts diff --git a/src/redux/player/api.ts b/src/redux/player/api.ts index 9f1c0936..57e73420 100644 --- a/src/redux/player/api.ts +++ b/src/redux/player/api.ts @@ -1,11 +1,8 @@ -import { IResultWithStatus, IEmbed } from '../types'; -import { api, resultMiddleware, errorMiddleware } from '~/utils/api'; +import { api, cleanResult } from '~/utils/api'; import { API } from '~/constants/api'; +import { ApiGetEmbedYoutubeResult } from '~/redux/player/types'; -export const getEmbedYoutube = ( - ids: string[] -): Promise }>> => +export const getEmbedYoutube = (ids: string[]) => api - .get(API.EMBED.YOUTUBE, { params: { ids: ids.join(',') } }) - .then(resultMiddleware) - .catch(errorMiddleware); + .get(API.EMBED.YOUTUBE, { params: { ids: ids.join(',') } }) + .then(cleanResult); diff --git a/src/redux/player/reducer.ts b/src/redux/player/reducer.ts index e8c88810..c0ebba7e 100644 --- a/src/redux/player/reducer.ts +++ b/src/redux/player/reducer.ts @@ -5,13 +5,13 @@ import { IFile, IEmbed } from '../types'; export type IPlayerState = Readonly<{ status: typeof PLAYER_STATES[keyof typeof PLAYER_STATES]; - file: IFile; + file?: IFile; youtubes: Record; }>; const INITIAL_STATE: IPlayerState = { status: PLAYER_STATES.UNSET, - file: null, + file: undefined, youtubes: {}, }; diff --git a/src/redux/player/sagas.ts b/src/redux/player/sagas.ts index d4a199d8..1b651e2a 100644 --- a/src/redux/player/sagas.ts +++ b/src/redux/player/sagas.ts @@ -14,7 +14,12 @@ import { getEmbedYoutube } from './api'; import { selectPlayer } from './selectors'; function* setFileAndPlaySaga({ file }: ReturnType) { + if (!file) { + return; + } + yield put(playerSetFile(file)); + Player.set(getURL(file)); Player.play(); } @@ -37,7 +42,7 @@ function seekSaga({ seek }: ReturnType) { function* stoppedSaga() { yield put(playerSetStatus(PLAYER_STATES.UNSET)); - yield put(playerSetFile(null)); + yield put(playerSetFile(undefined)); } function* getYoutubeInfo() { @@ -49,34 +54,38 @@ function* getYoutubeInfo() { ticker, }: { action: ReturnType; ticker: any } = yield race({ action: take(PLAYER_ACTIONS.GET_YOUTUBE_INFO), - ...(ids.length > 0 ? { ticker: delay(1000) } : {}), + ...(ids.length > 0 ? { ticker: delay(500) } : {}), }); if (action) { ids.push(action.url); } - if (ticker || ids.length > 25) { - const result: Unwrap = yield call(getEmbedYoutube, ids); + if (!ticker && ids.length <= 25) { + // Try to collect more items in next 500ms + continue; + } - if (!result.error && result.data.items && Object.keys(result.data.items).length) { + try { + const data: Unwrap = yield call(getEmbedYoutube, ids); + + if (data.items && Object.keys(data.items).length) { const { youtubes }: ReturnType = yield select(selectPlayer); - yield put(playerSet({ youtubes: { ...youtubes, ...result.data.items } })); + yield put(playerSet({ youtubes: { ...youtubes, ...data.items } })); } ids = []; - } + } catch {} } } export default function* playerSaga() { + yield fork(getYoutubeInfo); + yield takeLatest(PLAYER_ACTIONS.SET_FILE_AND_PLAY, setFileAndPlaySaga); yield takeLatest(PLAYER_ACTIONS.PAUSE, pauseSaga); yield takeLatest(PLAYER_ACTIONS.PLAY, playSaga); yield takeLatest(PLAYER_ACTIONS.SEEK, seekSaga); yield takeLatest(PLAYER_ACTIONS.STOP, stopSaga); yield takeLatest(PLAYER_ACTIONS.STOPPED, stoppedSaga); - - yield fork(getYoutubeInfo); - // yield takeEvery(PLAYER_ACTIONS.GET_YOUTUBE_INFO, getYoutubeInfo); } diff --git a/src/redux/player/types.ts b/src/redux/player/types.ts new file mode 100644 index 00000000..98e6f91d --- /dev/null +++ b/src/redux/player/types.ts @@ -0,0 +1,3 @@ +import { IEmbed } from '~/redux/types'; + +export type ApiGetEmbedYoutubeResult = { items: Record }; From 4da55dcd211b29c478194410eefed181f25221b9 Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Wed, 3 Mar 2021 11:18:16 +0700 Subject: [PATCH 09/19] player: renamed apiGetEmbedYoutube --- src/redux/player/api.ts | 2 +- src/redux/player/sagas.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/redux/player/api.ts b/src/redux/player/api.ts index 57e73420..9ee0eb84 100644 --- a/src/redux/player/api.ts +++ b/src/redux/player/api.ts @@ -2,7 +2,7 @@ import { api, cleanResult } from '~/utils/api'; import { API } from '~/constants/api'; import { ApiGetEmbedYoutubeResult } from '~/redux/player/types'; -export const getEmbedYoutube = (ids: string[]) => +export const apiGetEmbedYoutube = (ids: string[]) => api .get(API.EMBED.YOUTUBE, { params: { ids: ids.join(',') } }) .then(cleanResult); diff --git a/src/redux/player/sagas.ts b/src/redux/player/sagas.ts index 1b651e2a..97c0a4a1 100644 --- a/src/redux/player/sagas.ts +++ b/src/redux/player/sagas.ts @@ -10,7 +10,7 @@ import { import { Player } from '~/utils/player'; import { getURL } from '~/utils/dom'; import { Unwrap } from '../types'; -import { getEmbedYoutube } from './api'; +import { apiGetEmbedYoutube } from './api'; import { selectPlayer } from './selectors'; function* setFileAndPlaySaga({ file }: ReturnType) { @@ -67,7 +67,7 @@ function* getYoutubeInfo() { } try { - const data: Unwrap = yield call(getEmbedYoutube, ids); + const data: Unwrap = yield call(apiGetEmbedYoutube, ids); if (data.items && Object.keys(data.items).length) { const { youtubes }: ReturnType = yield select(selectPlayer); From 7031084b09b9aaba34903a3d678faa4764afedea Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Wed, 3 Mar 2021 12:20:21 +0700 Subject: [PATCH 10/19] player: renamed apiGetEmbedYoutube --- src/redux/tag/api.ts | 41 ++++-------- src/redux/tag/sagas.ts | 35 +++++----- src/redux/tag/types.ts | 16 +++++ src/redux/uploads/api.ts | 27 +++----- src/redux/uploads/sagas.ts | 128 +++++++++++++++++++------------------ src/redux/uploads/types.ts | 6 ++ src/utils/uploader.ts | 17 ++--- 7 files changed, 130 insertions(+), 140 deletions(-) create mode 100644 src/redux/tag/types.ts create mode 100644 src/redux/uploads/types.ts diff --git a/src/redux/tag/api.ts b/src/redux/tag/api.ts index d657b9b7..b03f9400 100644 --- a/src/redux/tag/api.ts +++ b/src/redux/tag/api.ts @@ -1,33 +1,18 @@ -import { INode, IResultWithStatus } from '~/redux/types'; -import { api, configWithToken, errorMiddleware, resultMiddleware } from '~/utils/api'; +import { api, cleanResult } from '~/utils/api'; import { API } from '~/constants/api'; +import { + ApiGetNodesOfTagRequest, + ApiGetNodesOfTagResult, + ApiGetTagSuggestionsRequest, + ApiGetTagSuggestionsResult, +} from '~/redux/tag/types'; -export const getTagNodes = ({ - access, - tag, - offset, - limit, -}: { - access: string; - tag: string; - offset: number; - limit: number; -}): Promise> => +export const apiGetNodesOfTag = ({ tag, offset, limit }: ApiGetNodesOfTagRequest) => api - .get(API.TAG.NODES, configWithToken(access, { params: { name: tag, offset, limit } })) - .then(resultMiddleware) - .catch(errorMiddleware); + .get(API.TAG.NODES, { params: { name: tag, offset, limit } }) + .then(cleanResult); -export const getTagAutocomplete = ({ - search, - exclude, - access, -}: { - access: string; - search: string; - exclude: string[]; -}): Promise> => +export const apiGetTagSuggestions = ({ search, exclude }: ApiGetTagSuggestionsRequest) => api - .get(API.TAG.AUTOCOMPLETE, configWithToken(access, { params: { search, exclude } })) - .then(resultMiddleware) - .catch(errorMiddleware); + .get(API.TAG.AUTOCOMPLETE, { params: { search, exclude } }) + .then(cleanResult); diff --git a/src/redux/tag/sagas.ts b/src/redux/tag/sagas.ts index 00bcdf35..eb1c3f16 100644 --- a/src/redux/tag/sagas.ts +++ b/src/redux/tag/sagas.ts @@ -6,48 +6,43 @@ import { tagSetAutocomplete, tagSetNodes, } from '~/redux/tag/actions'; -import { wrap } from '~/redux/auth/sagas'; import { selectTagNodes } from '~/redux/tag/selectors'; -import { getTagAutocomplete, getTagNodes } from '~/redux/tag/api'; +import { apiGetTagSuggestions, apiGetNodesOfTag } from '~/redux/tag/api'; import { Unwrap } from '~/redux/types'; function* loadTagNodes({ tag }: ReturnType) { - yield put(tagSetNodes({ isLoading: true })); + yield put(tagSetNodes({ isLoading: true, list: [] })); try { const { list }: ReturnType = yield select(selectTagNodes); - const { data, error }: Unwrap = yield call(wrap, getTagNodes, { + const data: Unwrap = yield call(apiGetNodesOfTag, { tag, limit: 18, offset: list.length, }); - if (error) throw new Error(error); - - yield put(tagSetNodes({ isLoading: false, list: [...list, ...data.nodes], count: data.count })); - } catch (e) { - console.log(e); + yield put(tagSetNodes({ list: [...list, ...data.nodes], count: data.count })); + } catch { + } finally { yield put(tagSetNodes({ isLoading: false })); } } function* loadAutocomplete({ search, exclude }: ReturnType) { - if (search.length < 3) return; + if (search.length < 2) return; try { yield put(tagSetAutocomplete({ isLoading: true })); - yield delay(100); + yield delay(200); - const { data, error }: Unwrap = yield call( - wrap, - getTagAutocomplete, - { search, exclude } - ); + const data: Unwrap = yield call(apiGetTagSuggestions, { + search, + exclude, + }); - if (error) throw new Error(error); - - yield put(tagSetAutocomplete({ options: data.tags, isLoading: false })); - } catch (e) { + yield put(tagSetAutocomplete({ options: data.tags })); + } catch { + } finally { yield put(tagSetAutocomplete({ isLoading: false })); } } diff --git a/src/redux/tag/types.ts b/src/redux/tag/types.ts new file mode 100644 index 00000000..9f7f41e7 --- /dev/null +++ b/src/redux/tag/types.ts @@ -0,0 +1,16 @@ +import { INode } from '~/redux/types'; + +export type ApiGetNodesOfTagRequest = { + tag: string; + offset: number; + limit: number; +}; +export type ApiGetNodesOfTagResult = { nodes: INode[]; count: number }; + +export type ApiGetTagSuggestionsRequest = { + search: string; + exclude: string[]; +}; +export type ApiGetTagSuggestionsResult = { + tags: string[]; +}; diff --git a/src/redux/uploads/api.ts b/src/redux/uploads/api.ts index 2697c75a..756ac8ac 100644 --- a/src/redux/uploads/api.ts +++ b/src/redux/uploads/api.ts @@ -1,31 +1,20 @@ -import { - IResultWithStatus, IFile, IUploadProgressHandler, IFileWithUUID, -} from '~/redux/types'; -import { - api, configWithToken, resultMiddleware, errorMiddleware, -} from '~/utils/api'; +import { api, cleanResult } from '~/utils/api'; import { API } from '~/constants/api'; +import { ApiUploadFileRequest, ApiUploadFIleResult } from '~/redux/uploads/types'; -export const postUploadFile = ({ - access, +export const apiUploadFile = ({ file, target = 'others', type = 'image', onProgress, -}: IFileWithUUID & { - access: string; - onProgress: IUploadProgressHandler; -}): Promise> => { +}: ApiUploadFileRequest) => { const data = new FormData(); data.append('file', file); return api - .post( - API.USER.UPLOAD(target, type), - data, - configWithToken(access, { onUploadProgress: onProgress }) - ) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.USER.UPLOAD(target, type), data, { + onUploadProgress: onProgress, + }) + .then(cleanResult); }; diff --git a/src/redux/uploads/sagas.ts b/src/redux/uploads/sagas.ts index e832d16f..c7b168fd 100644 --- a/src/redux/uploads/sagas.ts +++ b/src/redux/uploads/sagas.ts @@ -1,17 +1,17 @@ -import { takeEvery, all, spawn, call, put, take, fork, race } from 'redux-saga/effects'; -import { postUploadFile } from './api'; -import { UPLOAD_ACTIONS, FILE_MIMES } from '~/redux/uploads/constants'; +import { SagaIterator } from 'redux-saga'; +import { all, call, fork, put, race, spawn, take, takeEvery } from 'redux-saga/effects'; +import { apiUploadFile } from './api'; +import { FILE_MIMES, UPLOAD_ACTIONS } from '~/redux/uploads/constants'; import { - uploadUploadFiles, - uploadSetStatus, + uploadAddFile, uploadAddStatus, uploadDropStatus, - uploadAddFile, + uploadSetStatus, + uploadUploadFiles, } from './actions'; -import { wrap } from '../auth/sagas'; import { createUploader, uploadGetThumb } from '~/utils/uploader'; import { HTTP_RESPONSES } from '~/utils/api'; -import { IFileWithUUID, IFile, IUploadProgressHandler } from '../types'; +import { IFileWithUUID, IUploadProgressHandler, Unwrap } from '../types'; function* uploadCall({ file, @@ -20,13 +20,15 @@ function* uploadCall({ type, onProgress, }: IFileWithUUID & { onProgress: IUploadProgressHandler }) { - return yield call(wrap, postUploadFile, { + const data: Unwrap = yield call(apiUploadFile, { file, temp_id, type, target, onProgress, }); + + return data; } function* onUploadProgress(chan) { @@ -46,7 +48,12 @@ function* uploadCancelWorker(id) { return true; } -function* uploadWorker({ file, temp_id, target, type }: IFileWithUUID) { +function* uploadWorker({ + file, + temp_id, + target, + type, +}: IFileWithUUID): SagaIterator> { const [promise, chan] = createUploader, Partial>( uploadCall, { temp_id, target, type } @@ -63,77 +70,74 @@ function* uploadWorker({ file, temp_id, target, type }: IFileWithUUID) { } function* uploadFile({ file, temp_id, type, target, onSuccess, onFail }: IFileWithUUID) { - if (!file.type || !FILE_MIMES[type] || !FILE_MIMES[type].includes(file.type)) { - return { - error: 'File_Not_Image', - status: HTTP_RESPONSES.BAD_REQUEST, - data: {}, - }; - } + if (!temp_id) return; - const preview = yield call(uploadGetThumb, file); + try { + if (!file.type || !FILE_MIMES[type] || !FILE_MIMES[type].includes(file.type)) { + return { + error: 'File_Not_Image', + status: HTTP_RESPONSES.BAD_REQUEST, + data: {}, + }; + } - yield put( - uploadAddStatus( - // replace with the one, what adds file upload status - temp_id, - { - preview, + const preview: Unwrap = yield call(uploadGetThumb, file); + + yield put( + uploadAddStatus(temp_id, { + preview: preview.toString(), is_uploading: true, temp_id, type, name: file.name, - } - ) - ); + }) + ); - const { result, cancel, cancel_editing } = yield race({ - result: call(uploadWorker, { - file, - temp_id, - target, - type, - }), - cancel: call(uploadCancelWorker, temp_id), - }); + const [result, cancel]: [ + Unwrap, + Unwrap + ] = yield race([ + call(uploadWorker, { + file, + temp_id, + target, + type, + }), + call(uploadCancelWorker, temp_id), + ]); - if (cancel || cancel_editing) { - if (onFail) onFail(); - return yield put(uploadDropStatus(temp_id)); - } + if (cancel || !result) { + if (onFail) onFail(); + return yield put(uploadDropStatus(temp_id)); + } - const { data, error }: { data: IFile & { detail: string }; error: string } = result; + yield put( + uploadSetStatus(temp_id, { + is_uploading: false, + error: '', + uuid: result.id, + url: result.full_path, + type, + thumbnail_url: result.full_path, + progress: 1, + name: file.name, + }) + ); - if (error) { + yield put(uploadAddFile(result)); + + if (onSuccess) onSuccess(result); + } catch (error) { if (onFail) onFail(); return yield put( uploadSetStatus(temp_id, { is_uploading: false, - error: data.detail || error, + error, type, }) ); } - - yield put( - uploadSetStatus(temp_id, { - is_uploading: false, - error: null, - uuid: data.id, - url: data.full_path, - type, - thumbnail_url: data.full_path, - progress: 1, - name: file.name, - }) - ); - - yield put(uploadAddFile(data)); - - if (onSuccess) onSuccess(data); - - return { error: null, status: HTTP_RESPONSES.CREATED, data: {} }; // add file here as data } function* uploadFiles({ files }: ReturnType) { diff --git a/src/redux/uploads/types.ts b/src/redux/uploads/types.ts new file mode 100644 index 00000000..410acb22 --- /dev/null +++ b/src/redux/uploads/types.ts @@ -0,0 +1,6 @@ +import { IFile, IFileWithUUID, IUploadProgressHandler } from '~/redux/types'; + +export type ApiUploadFileRequest = IFileWithUUID & { + onProgress: IUploadProgressHandler; +}; +export type ApiUploadFIleResult = IFile; diff --git a/src/utils/uploader.ts b/src/utils/uploader.ts index f161cd66..c1ad5941 100644 --- a/src/utils/uploader.ts +++ b/src/utils/uploader.ts @@ -1,7 +1,7 @@ import uuid from 'uuid4'; -import { eventChannel, END, EventChannel } from 'redux-saga'; +import { END, eventChannel, EventChannel } from 'redux-saga'; import { VALIDATORS } from '~/utils/validators'; -import { IResultWithStatus, IFile } from '~/redux/types'; +import { IFile, IResultWithStatus } from '~/redux/types'; import { HTTP_RESPONSES } from './api'; import { EMPTY_FILE, FILE_MIMES, UPLOAD_TYPES } from '~/redux/uploads/constants'; @@ -33,13 +33,11 @@ export function createUploader( export const uploadGetThumb = async file => { if (!file.type || !VALIDATORS.IS_IMAGE_MIME(file.type)) return ''; - const thumb = await new Promise(resolve => { + return new Promise(resolve => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result || ''); reader.readAsDataURL(file); }); - - return thumb; }; export const fakeUploader = ({ @@ -73,9 +71,6 @@ export const fakeUploader = ({ }); }; -export const getFileType = (file: File): keyof typeof UPLOAD_TYPES => { - return ( - (file.type && Object.keys(FILE_MIMES).find(mime => FILE_MIMES[mime].includes(file.type))) || - null - ); -}; +export const getFileType = (file: File): keyof typeof UPLOAD_TYPES | undefined => + (file.type && Object.keys(FILE_MIMES).find(mime => FILE_MIMES[mime].includes(file.type))) || + undefined; From d4c2e7ee09f2384cf38b58c854042570e9f4fc7a Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Wed, 3 Mar 2021 17:54:58 +0700 Subject: [PATCH 11/19] refactored component errors --- .../comment/CommentContent/index.tsx | 3 +- .../comment/CommentEmbedBlock/index.tsx | 37 ++++++----- src/components/comment/CommentForm/index.tsx | 8 ++- .../comment/CommentFormAttaches/index.tsx | 63 +++++++++--------- .../LocalCommentFormTextarea/index.tsx | 2 +- .../containers/CoverBackdrop/index.tsx | 16 ++--- src/components/containers/FullWidth/index.tsx | 2 +- src/components/containers/Sticky/index.tsx | 2 +- src/components/editors/AudioEditor/index.tsx | 15 +---- src/components/editors/AudioGrid/index.tsx | 2 +- src/components/editors/EditorPanel/index.tsx | 23 ++++--- .../editors/EditorUploadButton/index.tsx | 5 +- .../editors/EditorUploadCoverButton/index.tsx | 14 ++-- src/components/editors/ImageEditor/index.tsx | 14 +--- .../editors/SortableAudioGrid/index.tsx | 2 +- src/components/editors/TextEditor/index.tsx | 6 +- src/components/editors/VideoEditor/index.tsx | 9 ++- src/components/flow/Cell/index.tsx | 2 +- src/components/flow/FlowGrid/index.tsx | 32 +++++---- src/components/flow/FlowHero/index.tsx | 55 +++++++++------- src/components/input/ArcProgress/index.tsx | 14 +--- src/components/input/Button/index.tsx | 2 +- src/components/input/InputText/index.tsx | 10 ++- src/components/main/GodRays/index.tsx | 6 +- src/components/main/Notifications/index.tsx | 11 ++-- src/components/main/UserButton/index.tsx | 2 + src/components/media/AudioPlayer/index.tsx | 16 +++-- src/components/node/ImageSwitcher/index.tsx | 5 +- src/components/node/NodeComments/index.tsx | 2 +- .../node/NodeImageSlideBlock/index.tsx | 10 +-- src/components/node/NodePanel/index.tsx | 6 +- src/components/node/NodePanelInner/index.tsx | 4 +- src/components/node/NodeRelatedItem/index.tsx | 26 ++++---- src/components/node/NodeTextBlock/index.tsx | 2 +- src/components/node/NodeVideoBlock/index.tsx | 2 +- .../NotificationMessage/index.tsx | 2 +- src/components/profile/MessageForm/index.tsx | 4 +- .../profile/ProfileDescription/index.tsx | 6 +- src/components/tags/Tag/index.tsx | 2 +- src/components/tags/TagAutocomplete/index.tsx | 5 +- src/components/tags/TagInput/index.tsx | 6 +- src/components/tags/Tags/index.tsx | 16 +++-- src/constants/urls.ts | 4 +- .../dialogs/BetterScrollDialog/index.tsx | 14 ++-- src/containers/dialogs/EditorDialog/index.tsx | 30 +++++++-- src/containers/dialogs/LoginDialog/index.tsx | 2 +- .../dialogs/LoginDialogButtons/index.tsx | 3 +- src/containers/dialogs/Modal/index.tsx | 7 +- src/containers/dialogs/PhotoSwipe/index.tsx | 4 +- .../dialogs/RestorePasswordDialog/index.tsx | 24 ++++--- .../dialogs/RestoreRequestDialog/index.tsx | 6 +- src/containers/node/BorisLayout/index.tsx | 1 + src/containers/node/NodeLayout/index.tsx | 30 +++++---- .../profile/ProfileAvatar/index.tsx | 64 +++++++++--------- src/containers/profile/ProfileInfo/index.tsx | 10 +-- .../profile/ProfileLayout/index.tsx | 4 +- .../profile/ProfileMessages/index.tsx | 52 ++++++++------- .../profile/ProfilePageLeft/index.tsx | 15 +++-- src/containers/profile/ProfileTabs/index.tsx | 65 +++++++++++-------- .../sidebars/ProfileSidebar/index.tsx | 2 +- src/containers/sidebars/TagSidebar/index.tsx | 5 +- src/redux/boris/reducer.ts | 4 +- src/redux/node/actions.ts | 2 +- src/redux/node/constants.ts | 23 +++---- src/redux/node/reducer.ts | 15 +++-- src/redux/node/sagas.ts | 8 ++- src/redux/node/types.ts | 6 ++ src/redux/types.ts | 12 ++-- src/redux/uploads/constants.ts | 24 +++---- src/redux/uploads/handlers.ts | 43 ++++++------ src/redux/uploads/mocks.ts | 15 ----- src/redux/uploads/sagas.ts | 2 +- src/utils/dom.ts | 5 +- src/utils/fn.ts | 15 +++-- src/utils/hooks/fileUploader.tsx | 23 +++++-- src/utils/hooks/index.ts | 4 +- src/utils/hooks/useCommentFormFormik.ts | 2 +- src/utils/player.ts | 13 ++-- src/utils/tag.ts | 6 +- 79 files changed, 573 insertions(+), 462 deletions(-) delete mode 100644 src/redux/uploads/mocks.ts diff --git a/src/components/comment/CommentContent/index.tsx b/src/components/comment/CommentContent/index.tsx index b215379e..318380f3 100644 --- a/src/components/comment/CommentContent/index.tsx +++ b/src/components/comment/CommentContent/index.tsx @@ -33,7 +33,8 @@ const CommentContent: FC = memo(({ comment, can_edit, onDelete, modalSho const groupped = useMemo>( () => reduce( - (group, file) => assocPath([file.type], append(file, group[file.type]), group), + (group, file) => + file.type ? assocPath([file.type], append(file, group[file.type]), group) : group, {}, comment.files ), diff --git a/src/components/comment/CommentEmbedBlock/index.tsx b/src/components/comment/CommentEmbedBlock/index.tsx index 46bdd08a..77a374d2 100644 --- a/src/components/comment/CommentEmbedBlock/index.tsx +++ b/src/components/comment/CommentEmbedBlock/index.tsx @@ -6,6 +6,7 @@ import { selectPlayer } from '~/redux/player/selectors'; import { connect } from 'react-redux'; import * as PLAYER_ACTIONS from '~/redux/player/actions'; import { Icon } from '~/components/input/Icon'; +import { path } from 'ramda'; const mapStateToProps = state => ({ youtubes: selectPlayer(state).youtubes, @@ -21,30 +22,32 @@ type Props = ReturnType & const CommentEmbedBlockUnconnected: FC = memo( ({ block, youtubes, playerGetYoutubeInfo }) => { - const link = useMemo( - () => - block.content.match( - /https?:\/\/(www\.)?(youtube\.com|youtu\.be)\/(watch)?(\?v=)?([\w\-\=]+)/ - ), - [block.content] - ); + const id = useMemo(() => { + const match = block.content.match( + /https?:\/\/(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch)?(?:\?v=)?([\w\-\=]+)/ + ); + + return (match && match[1]) || ''; + }, [block.content]); const preview = useMemo(() => getYoutubeThumb(block.content), [block.content]); useEffect(() => { - if (!link[5] || youtubes[link[5]]) return; - playerGetYoutubeInfo(link[5]); - }, [link, playerGetYoutubeInfo]); + if (!id) return; + playerGetYoutubeInfo(id); + }, [id, playerGetYoutubeInfo]); - const title = useMemo( - () => - (youtubes[link[5]] && youtubes[link[5]].metadata && youtubes[link[5]].metadata.title) || '', - [link, youtubes] - ); + const title = useMemo(() => { + if (!id) { + return block.content; + } + + return path([id, 'metadata', 'title'], youtubes) || block.content; + }, [id, youtubes, block.content]); return ( diff --git a/src/components/comment/CommentForm/index.tsx b/src/components/comment/CommentForm/index.tsx index 8b0a1c8c..b644509d 100644 --- a/src/components/comment/CommentForm/index.tsx +++ b/src/components/comment/CommentForm/index.tsx @@ -67,7 +67,13 @@ const CommentForm: FC = ({ comment, nodeId, onCancelEdit }) => { - + + {!!textarea && ( + + )} {isLoading && } diff --git a/src/components/comment/CommentFormAttaches/index.tsx b/src/components/comment/CommentFormAttaches/index.tsx index 1c764f14..7422c134 100644 --- a/src/components/comment/CommentFormAttaches/index.tsx +++ b/src/components/comment/CommentFormAttaches/index.tsx @@ -10,7 +10,8 @@ import { COMMENT_FILE_TYPES, UPLOAD_TYPES } from '~/redux/uploads/constants'; import { useFileUploaderContext } from '~/utils/hooks/fileUploader'; const CommentFormAttaches: FC = () => { - const { files, pending, setFiles, uploadFiles } = useFileUploaderContext(); + const uploader = useFileUploaderContext(); + const { files, pending, setFiles, uploadFiles } = uploader!; const images = useMemo(() => files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), [ files, @@ -70,7 +71,7 @@ const CommentFormAttaches: FC = () => { ); const onAudioTitleChange = useCallback( - (fileId: IFile['id'], title: IFile['metadata']['title']) => { + (fileId: IFile['id'], title: string) => { setFiles( files.map(file => file.id === fileId ? { ...file, metadata: { ...file.metadata, title } } : file @@ -80,36 +81,36 @@ const CommentFormAttaches: FC = () => { [files, setFiles] ); - return ( - hasAttaches && ( -
- {hasImageAttaches && ( - - )} + if (!hasAttaches) return null; - {hasAudioAttaches && ( - - )} -
- ) + return ( +
+ {hasImageAttaches && ( + + )} + + {hasAudioAttaches && ( + + )} +
); }; diff --git a/src/components/comment/LocalCommentFormTextarea/index.tsx b/src/components/comment/LocalCommentFormTextarea/index.tsx index 79e4a524..2dab51fb 100644 --- a/src/components/comment/LocalCommentFormTextarea/index.tsx +++ b/src/components/comment/LocalCommentFormTextarea/index.tsx @@ -13,7 +13,7 @@ const LocalCommentFormTextarea: FC = ({ setRef }) => { const onKeyDown = useCallback>( ({ ctrlKey, key }) => { - if (!!ctrlKey && key === 'Enter') handleSubmit(null); + if (ctrlKey && key === 'Enter') handleSubmit(undefined); }, [handleSubmit] ); diff --git a/src/components/containers/CoverBackdrop/index.tsx b/src/components/containers/CoverBackdrop/index.tsx index 2e491137..0a6b7f80 100644 --- a/src/components/containers/CoverBackdrop/index.tsx +++ b/src/components/containers/CoverBackdrop/index.tsx @@ -1,16 +1,16 @@ -import React, { FC, useState, useCallback, useEffect, useRef } from "react"; -import { IUser } from "~/redux/auth/types"; +import React, { FC, useState, useCallback, useEffect, useRef } from 'react'; +import { IUser } from '~/redux/auth/types'; import styles from './styles.module.scss'; -import { getURL } from "~/utils/dom"; -import { PRESETS } from "~/constants/urls"; -import classNames from "classnames"; +import { getURL } from '~/utils/dom'; +import { PRESETS } from '~/constants/urls'; +import classNames from 'classnames'; interface IProps { - cover: IUser["cover"]; + cover: IUser['cover']; } const CoverBackdrop: FC = ({ cover }) => { - const ref = useRef(); + const ref = useRef(null); const [is_loaded, setIsLoaded] = useState(false); @@ -21,7 +21,7 @@ const CoverBackdrop: FC = ({ cover }) => { useEffect(() => { if (!cover || !cover.url || !ref || !ref.current) return; - ref.current.src = ""; + ref.current.src = ''; setIsLoaded(false); ref.current.src = getURL(cover, PRESETS.cover); }, [cover]); diff --git a/src/components/containers/FullWidth/index.tsx b/src/components/containers/FullWidth/index.tsx index 3c3eefed..b0e4dd0b 100644 --- a/src/components/containers/FullWidth/index.tsx +++ b/src/components/containers/FullWidth/index.tsx @@ -16,7 +16,7 @@ const FullWidth: FC = ({ children, onRefresh }) => { const { width } = sample.current.getBoundingClientRect(); const { clientWidth } = document.documentElement; - onRefresh(clientWidth); + if (onRefresh) onRefresh(clientWidth); return { width: clientWidth, diff --git a/src/components/containers/Sticky/index.tsx b/src/components/containers/Sticky/index.tsx index e3bc031b..79d57d3c 100644 --- a/src/components/containers/Sticky/index.tsx +++ b/src/components/containers/Sticky/index.tsx @@ -11,7 +11,7 @@ interface IProps extends DetailsHTMLAttributes {} const Sticky: FC = ({ children }) => { const ref = useRef(null); - let sb = null; + let sb; useEffect(() => { if (!ref.current) return; diff --git a/src/components/editors/AudioEditor/index.tsx b/src/components/editors/AudioEditor/index.tsx index a51c300d..94acddc4 100644 --- a/src/components/editors/AudioEditor/index.tsx +++ b/src/components/editors/AudioEditor/index.tsx @@ -1,5 +1,4 @@ import React, { FC, useCallback, useMemo } from 'react'; -import { INode } from '~/redux/types'; import { connect } from 'react-redux'; import { UPLOAD_TYPES } from '~/redux/uploads/constants'; import { ImageGrid } from '../ImageGrid'; @@ -8,19 +7,14 @@ import { selectUploads } from '~/redux/uploads/selectors'; import * as UPLOAD_ACTIONS from '~/redux/uploads/actions'; import styles from './styles.module.scss'; +import { NodeEditorProps } from '~/redux/node/types'; const mapStateToProps = selectUploads; const mapDispatchToProps = { uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles, }; -type IProps = ReturnType & - typeof mapDispatchToProps & { - data: INode; - setData: (val: INode) => void; - temp: string[]; - setTemp: (val: string[]) => void; - }; +type IProps = ReturnType & typeof mapDispatchToProps & NodeEditorProps; const AudioEditorUnconnected: FC = ({ data, setData, temp, statuses }) => { const images = useMemo( @@ -69,9 +63,6 @@ const AudioEditorUnconnected: FC = ({ data, setData, temp, statuses }) = ); }; -const AudioEditor = connect( - mapStateToProps, - mapDispatchToProps -)(AudioEditorUnconnected); +const AudioEditor = connect(mapStateToProps, mapDispatchToProps)(AudioEditorUnconnected); export { AudioEditor }; diff --git a/src/components/editors/AudioGrid/index.tsx b/src/components/editors/AudioGrid/index.tsx index 1640754f..d94c521b 100644 --- a/src/components/editors/AudioGrid/index.tsx +++ b/src/components/editors/AudioGrid/index.tsx @@ -35,7 +35,7 @@ const AudioGrid: FC = ({ files, setFiles, locked }) => { ); const onTitleChange = useCallback( - (changeId: IFile['id'], title: IFile['metadata']['title']) => { + (changeId: IFile['id'], title: string) => { setFiles( files.map(file => file && file.id === changeId ? { ...file, metadata: { ...file.metadata, title } } : file diff --git a/src/components/editors/EditorPanel/index.tsx b/src/components/editors/EditorPanel/index.tsx index c91a41d6..2506452b 100644 --- a/src/components/editors/EditorPanel/index.tsx +++ b/src/components/editors/EditorPanel/index.tsx @@ -2,6 +2,7 @@ import React, { FC, createElement } from 'react'; import styles from './styles.module.scss'; import { INode } from '~/redux/types'; import { NODE_PANEL_COMPONENTS } from '~/redux/node/constants'; +import { has } from 'ramda'; interface IProps { data: INode; @@ -10,13 +11,19 @@ interface IProps { setTemp: (val: string[]) => void; } -const EditorPanel: FC = ({ data, setData, temp, setTemp }) => ( -
- {NODE_PANEL_COMPONENTS[data.type] && - NODE_PANEL_COMPONENTS[data.type].map((el, key) => - createElement(el, { key, data, setData, temp, setTemp }) - )} -
-); +const EditorPanel: FC = ({ data, setData, temp, setTemp }) => { + if (!data.type || !has(data.type, NODE_PANEL_COMPONENTS)) { + return null; + } + + return ( +
+ {NODE_PANEL_COMPONENTS[data.type] && + NODE_PANEL_COMPONENTS[data.type].map((el, key) => + createElement(el, { key, data, setData, temp, setTemp }) + )} +
+ ); +}; export { EditorPanel }; diff --git a/src/components/editors/EditorUploadButton/index.tsx b/src/components/editors/EditorUploadButton/index.tsx index 34e90a44..21b7cc50 100644 --- a/src/components/editors/EditorUploadButton/index.tsx +++ b/src/components/editors/EditorUploadButton/index.tsx @@ -64,7 +64,10 @@ const EditorUploadButtonUnconnected: FC = ({ }) ); - const temps = items.map(file => file.temp_id).slice(0, limit); + const temps = items + .filter(file => file?.temp_id) + .map(file => file.temp_id!) + .slice(0, limit); setTemp([...temp, ...temps]); uploadUploadFiles(items); diff --git a/src/components/editors/EditorUploadCoverButton/index.tsx b/src/components/editors/EditorUploadCoverButton/index.tsx index 38cf84c3..9fd4d6cc 100644 --- a/src/components/editors/EditorUploadCoverButton/index.tsx +++ b/src/components/editors/EditorUploadCoverButton/index.tsx @@ -33,16 +33,16 @@ const EditorUploadCoverButtonUnconnected: FC = ({ statuses, uploadUploadFiles, }) => { - const [cover_temp, setCoverTemp] = useState(null); + const [coverTemp, setCoverTemp] = useState(''); useEffect(() => { Object.entries(statuses).forEach(([id, status]) => { - if (cover_temp === id && !!status.uuid && files[status.uuid]) { + if (coverTemp === id && !!status.uuid && files[status.uuid]) { setData({ ...data, cover: files[status.uuid] }); - setCoverTemp(null); + setCoverTemp(''); } }); - }, [statuses, files, cover_temp, setData, data]); + }, [statuses, files, coverTemp, setData, data]); const onUpload = useCallback( (uploads: File[]) => { @@ -56,7 +56,7 @@ const EditorUploadCoverButtonUnconnected: FC = ({ }) ); - setCoverTemp(path([0, 'temp_id'], items)); + setCoverTemp(path([0, 'temp_id'], items) || ''); uploadUploadFiles(items); }, [uploadUploadFiles, setCoverTemp] @@ -73,11 +73,11 @@ const EditorUploadCoverButtonUnconnected: FC = ({ [onUpload] ); const onDropCover = useCallback(() => { - setData({ ...data, cover: null }); + setData({ ...data, cover: undefined }); }, [setData, data]); const background = data.cover ? getURL(data.cover, PRESETS['300']) : null; - const status = cover_temp && path([cover_temp], statuses); + const status = coverTemp && path([coverTemp], statuses); const preview = status && path(['preview'], status); return ( diff --git a/src/components/editors/ImageEditor/index.tsx b/src/components/editors/ImageEditor/index.tsx index 5dfe29e8..104fa220 100644 --- a/src/components/editors/ImageEditor/index.tsx +++ b/src/components/editors/ImageEditor/index.tsx @@ -5,19 +5,14 @@ import * as UPLOAD_ACTIONS from '~/redux/uploads/actions'; import { selectUploads } from '~/redux/uploads/selectors'; import { ImageGrid } from '~/components/editors/ImageGrid'; import styles from './styles.module.scss'; +import { NodeEditorProps } from '~/redux/node/types'; const mapStateToProps = selectUploads; const mapDispatchToProps = { uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles, }; -type IProps = ReturnType & - typeof mapDispatchToProps & { - data: INode; - setData: (val: INode) => void; - temp: string[]; - setTemp: (val: string[]) => void; - }; +type IProps = ReturnType & typeof mapDispatchToProps & NodeEditorProps; const ImageEditorUnconnected: FC = ({ data, setData, temp, statuses }) => { const pending_files = useMemo(() => temp.filter(id => !!statuses[id]).map(id => statuses[id]), [ @@ -34,9 +29,6 @@ const ImageEditorUnconnected: FC = ({ data, setData, temp, statuses }) = ); }; -const ImageEditor = connect( - mapStateToProps, - mapDispatchToProps -)(ImageEditorUnconnected); +const ImageEditor = connect(mapStateToProps, mapDispatchToProps)(ImageEditorUnconnected); export { ImageEditor }; diff --git a/src/components/editors/SortableAudioGrid/index.tsx b/src/components/editors/SortableAudioGrid/index.tsx index 38368863..fc1bf578 100644 --- a/src/components/editors/SortableAudioGrid/index.tsx +++ b/src/components/editors/SortableAudioGrid/index.tsx @@ -17,7 +17,7 @@ const SortableAudioGrid = SortableContainer( items: IFile[]; locked: IUploadStatus[]; onDelete: (file_id: IFile['id']) => void; - onTitleChange: (file_id: IFile['id'], title: IFile['metadata']['title']) => void; + onTitleChange: (file_id: IFile['id'], title: string) => void; }) => { return (
diff --git a/src/components/editors/TextEditor/index.tsx b/src/components/editors/TextEditor/index.tsx index 2cb1e6fb..113d4df5 100644 --- a/src/components/editors/TextEditor/index.tsx +++ b/src/components/editors/TextEditor/index.tsx @@ -3,11 +3,9 @@ import { INode } from '~/redux/types'; import styles from './styles.module.scss'; import { Textarea } from '~/components/input/Textarea'; import { path } from 'ramda'; +import { NodeEditorProps } from '~/redux/node/types'; -interface IProps { - data: INode; - setData: (val: INode) => void; -} +type IProps = NodeEditorProps & {}; const TextEditor: FC = ({ data, setData }) => { const setText = useCallback( diff --git a/src/components/editors/VideoEditor/index.tsx b/src/components/editors/VideoEditor/index.tsx index 443dae4c..fa9803f9 100644 --- a/src/components/editors/VideoEditor/index.tsx +++ b/src/components/editors/VideoEditor/index.tsx @@ -5,11 +5,9 @@ import { path } from 'ramda'; import { InputText } from '~/components/input/InputText'; import classnames from 'classnames'; import { getYoutubeThumb } from '~/utils/dom'; +import { NodeEditorProps } from '~/redux/node/types'; -interface IProps { - data: INode; - setData: (val: INode) => void; -} +type IProps = NodeEditorProps & {}; const VideoEditor: FC = ({ data, setData }) => { const setUrl = useCallback( @@ -19,9 +17,10 @@ const VideoEditor: FC = ({ data, setData }) => { const url = (path(['blocks', 0, 'url'], data) as string) || ''; const preview = useMemo(() => getYoutubeThumb(url), [url]); + const backgroundImage = (preview && `url("${preview}")`) || ''; return ( -
+
diff --git a/src/components/flow/Cell/index.tsx b/src/components/flow/Cell/index.tsx index db1f47c9..2823dfb6 100644 --- a/src/components/flow/Cell/index.tsx +++ b/src/components/flow/Cell/index.tsx @@ -119,7 +119,7 @@ const Cell: FC = ({ } }, [title]); - const cellText = useMemo(() => formatCellText(text), [text]); + const cellText = useMemo(() => formatCellText(text || ''), [text]); return (
diff --git a/src/components/flow/FlowGrid/index.tsx b/src/components/flow/FlowGrid/index.tsx index 98ff4ff3..df0765ce 100644 --- a/src/components/flow/FlowGrid/index.tsx +++ b/src/components/flow/FlowGrid/index.tsx @@ -13,16 +13,22 @@ type IProps = Partial & { onChangeCellView: typeof flowSetCellView; }; -export const FlowGrid: FC = ({ user, nodes, onSelect, onChangeCellView }) => ( - - {nodes.map(node => ( - - ))} - -); +export const FlowGrid: FC = ({ user, nodes, onSelect, onChangeCellView }) => { + if (!nodes) { + return null; + } + + return ( + + {nodes.map(node => ( + + ))} + + ); +}; diff --git a/src/components/flow/FlowHero/index.tsx b/src/components/flow/FlowHero/index.tsx index 409b2d5c..2c1af190 100644 --- a/src/components/flow/FlowHero/index.tsx +++ b/src/components/flow/FlowHero/index.tsx @@ -7,7 +7,7 @@ import { getURL } from '~/utils/dom'; import { withRouter, RouteComponentProps, useHistory } from 'react-router'; import { URLS, PRESETS } from '~/constants/urls'; import { Icon } from '~/components/input/Icon'; -import { INode } from "~/redux/types"; +import { INode } from '~/redux/types'; type IProps = RouteComponentProps & { heroes: IFlowState['heroes']; @@ -18,46 +18,54 @@ const FlowHeroUnconnected: FC = ({ heroes }) => { const [limit, setLimit] = useState(6); const [current, setCurrent] = useState(0); const [loaded, setLoaded] = useState[]>([]); - const timer = useRef(null) + const timer = useRef(null); const history = useHistory(); - const onLoad = useCallback((i: number) => { - setLoaded([...loaded, heroes[i]]) - }, [heroes, loaded, setLoaded]) + const onLoad = useCallback( + (i: number) => { + setLoaded([...loaded, heroes[i]]); + }, + [heroes, loaded, setLoaded] + ); - const items = Math.min(heroes.length, limit) + const items = Math.min(heroes.length, limit); const title = useMemo(() => { return loaded[current]?.title || ''; }, [loaded, current, heroes]); const onNext = useCallback(() => { - if (heroes.length > limit) setLimit(limit + 1) - setCurrent(current < items - 1 ? current + 1 : 0) - }, [current, items, limit, heroes.length]) - const onPrev = useCallback(() => setCurrent(current > 0 ? current - 1 : items - 1), [current, items]) + if (heroes.length > limit) setLimit(limit + 1); + setCurrent(current < items - 1 ? current + 1 : 0); + }, [current, items, limit, heroes.length]); + const onPrev = useCallback(() => setCurrent(current > 0 ? current - 1 : items - 1), [ + current, + items, + ]); const goToNode = useCallback(() => { - history.push(URLS.NODE_URL(loaded[current].id)) + history.push(URLS.NODE_URL(loaded[current].id)); }, [current, loaded]); useEffect(() => { - timer.current = setTimeout(onNext, 5000) - return () => clearTimeout(timer.current) - }, [current, timer.current]) + timer.current = setTimeout(onNext, 5000); + return () => clearTimeout(timer.current); + }, [current, timer.current]); useEffect(() => { - if (loaded.length === 1) onNext() - }, [loaded]) + if (loaded.length === 1) onNext(); + }, [loaded]); return (
- { - heroes.slice(0, items).map((hero, i) => ( - onLoad(i)} /> - )) - } + {heroes.slice(0, items).map((hero, i) => ( + onLoad(i)} + /> + ))}
{loaded.length > 0 && ( @@ -87,10 +95,7 @@ const FlowHeroUnconnected: FC = ({ heroes }) => { key={hero.id} onClick={goToNode} > - {hero.thumbnail} + {hero.thumbnail}
))}
diff --git a/src/components/input/ArcProgress/index.tsx b/src/components/input/ArcProgress/index.tsx index f9db81ea..12309968 100644 --- a/src/components/input/ArcProgress/index.tsx +++ b/src/components/input/ArcProgress/index.tsx @@ -4,19 +4,11 @@ import { describeArc } from '~/utils/dom'; interface IProps { size: number; - progress: number; + progress?: number; } -export const ArcProgress: FC = ({ size, progress }) => ( +export const ArcProgress: FC = ({ size, progress = 0 }) => ( - + ); diff --git a/src/components/input/Button/index.tsx b/src/components/input/Button/index.tsx index d34c4b0a..8c263770 100644 --- a/src/components/input/Button/index.tsx +++ b/src/components/input/Button/index.tsx @@ -50,7 +50,7 @@ const Button: FC = memo( ref, ...props }) => { - const tooltip = useRef(); + const tooltip = useRef(null); const pop = usePopper(tooltip?.current?.parentElement, tooltip.current, { placement: 'top', modifiers: [ diff --git a/src/components/input/InputText/index.tsx b/src/components/input/InputText/index.tsx index e5d50552..5495c068 100644 --- a/src/components/input/InputText/index.tsx +++ b/src/components/input/InputText/index.tsx @@ -20,10 +20,16 @@ const InputText: FC = ({ ...props }) => { const [focused, setFocused] = useState(false); - const [inner_ref, setInnerRef] = useState(null); + const [inner_ref, setInnerRef] = useState(null); const onInput = useCallback( - ({ target }: ChangeEvent) => handler(target.value), + ({ target }: ChangeEvent) => { + if (!handler) { + return; + } + + handler(target.value); + }, [handler] ); diff --git a/src/components/main/GodRays/index.tsx b/src/components/main/GodRays/index.tsx index 306d9b19..24a18dfb 100644 --- a/src/components/main/GodRays/index.tsx +++ b/src/components/main/GodRays/index.tsx @@ -34,6 +34,10 @@ export class GodRays extends React.Component { const ctx = this.canvas.getContext('2d'); + if (!ctx) { + return; + } + ctx.globalCompositeOperation = 'luminosity'; ctx.clearRect(0, 0, width, height + 100); // clear canvas ctx.save(); @@ -123,7 +127,7 @@ export class GodRays extends React.Component { ); } - canvas: HTMLCanvasElement; + canvas: HTMLCanvasElement | null | undefined; inc; } diff --git a/src/components/main/Notifications/index.tsx b/src/components/main/Notifications/index.tsx index 2b02db7b..d5a04046 100644 --- a/src/components/main/Notifications/index.tsx +++ b/src/components/main/Notifications/index.tsx @@ -42,8 +42,12 @@ const NotificationsUnconnected: FC = ({ (notification: INotification) => { switch (notification.type) { case 'message': + if (!(notification as IMessageNotification)?.content?.from?.username) { + return; + } + return authOpenProfile( - (notification as IMessageNotification).content.from.username, + (notification as IMessageNotification).content.from!.username, 'messages' ); default: @@ -78,9 +82,6 @@ const NotificationsUnconnected: FC = ({ ); }; -const Notifications = connect( - mapStateToProps, - mapDispatchToProps -)(NotificationsUnconnected); +const Notifications = connect(mapStateToProps, mapDispatchToProps)(NotificationsUnconnected); export { Notifications }; diff --git a/src/components/main/UserButton/index.tsx b/src/components/main/UserButton/index.tsx index b0b3af3b..711b9319 100644 --- a/src/components/main/UserButton/index.tsx +++ b/src/components/main/UserButton/index.tsx @@ -15,10 +15,12 @@ interface IProps { const UserButton: FC = ({ user: { username, photo }, authOpenProfile, onLogout }) => { const onProfileOpen = useCallback(() => { + if (!username) return; authOpenProfile(username, 'profile'); }, [authOpenProfile, username]); const onSettingsOpen = useCallback(() => { + if (!username) return; authOpenProfile(username, 'settings'); }, [authOpenProfile, username]); diff --git a/src/components/media/AudioPlayer/index.tsx b/src/components/media/AudioPlayer/index.tsx index d5942f2f..a8419e2e 100644 --- a/src/components/media/AudioPlayer/index.tsx +++ b/src/components/media/AudioPlayer/index.tsx @@ -26,7 +26,7 @@ type Props = ReturnType & file: IFile; isEditing?: boolean; onDelete?: (id: IFile['id']) => void; - onTitleChange?: (file_id: IFile['id'], title: IFile['metadata']['title']) => void; + onTitleChange?: (file_id: IFile['id'], title: string) => void; }; const AudioPlayerUnconnected = memo( @@ -93,14 +93,18 @@ const AudioPlayerUnconnected = memo( [file.metadata] ); - const onRename = useCallback((val: string) => onTitleChange(file.id, val), [ - onTitleChange, - file.id, - ]); + const onRename = useCallback( + (val: string) => { + if (!onTitleChange) return; + + onTitleChange(file.id, val); + }, + [onTitleChange, file.id] + ); useEffect(() => { const active = current && current.id === file.id; - setPlaying(current && current.id === file.id); + setPlaying(!!current && current.id === file.id); if (active) Player.on('playprogress', onProgress); diff --git a/src/components/node/ImageSwitcher/index.tsx b/src/components/node/ImageSwitcher/index.tsx index dceb1eec..32c03ea3 100644 --- a/src/components/node/ImageSwitcher/index.tsx +++ b/src/components/node/ImageSwitcher/index.tsx @@ -19,7 +19,10 @@ const ImageSwitcher: FC = ({ total, current, onChange, loaded }) => {
{range(0, total).map(item => (
onChange(item)} /> diff --git a/src/components/node/NodeComments/index.tsx b/src/components/node/NodeComments/index.tsx index 899b337c..075c68a8 100644 --- a/src/components/node/NodeComments/index.tsx +++ b/src/components/node/NodeComments/index.tsx @@ -14,7 +14,7 @@ import { modalShowPhotoswipe } from '~/redux/modal/actions'; import { useDispatch } from 'react-redux'; interface IProps { - comments?: IComment[]; + comments: IComment[]; count: INodeState['comment_count']; user: IUser; order?: 'ASC' | 'DESC'; diff --git a/src/components/node/NodeImageSlideBlock/index.tsx b/src/components/node/NodeImageSlideBlock/index.tsx index ab844c35..85c5cbbd 100644 --- a/src/components/node/NodeImageSlideBlock/index.tsx +++ b/src/components/node/NodeImageSlideBlock/index.tsx @@ -36,8 +36,8 @@ const NodeImageSlideBlock: FC = ({ const [is_dragging, setIsDragging] = useState(false); const [drag_start, setDragStart] = useState(0); - const slide = useRef(); - const wrap = useRef(); + const slide = useRef(null); + const wrap = useRef(null); const setHeightThrottled = useCallback(throttle(100, setHeight), [setHeight]); @@ -221,6 +221,8 @@ const NodeImageSlideBlock: FC = ({ const changeCurrent = useCallback( (item: number) => { + if (!wrap.current) return; + const { width } = wrap.current.getBoundingClientRect(); setOffset(-1 * item * width); }, @@ -266,10 +268,10 @@ const NodeImageSlideBlock: FC = ({ [styles.is_active]: index === current, })} ref={setRef(index)} - key={node.updated_at + file.id} + key={`${node?.updated_at || ''} + ${file?.id || ''} + ${index}`} > = memo( ({ node, layout, can_edit, can_like, can_star, is_loading, onEdit, onLike, onStar, onLock }) => { const [stack, setStack] = useState(false); - const ref = useRef(null); + const ref = useRef(null); const getPlace = useCallback(() => { if (!ref.current) return; - const { bottom } = ref.current.getBoundingClientRect(); + const { bottom } = ref.current!.getBoundingClientRect(); setStack(bottom > window.innerHeight); }, [ref]); @@ -75,7 +75,7 @@ const NodePanel: FC = memo( can_edit={can_edit} can_like={can_like} can_star={can_star} - is_loading={is_loading} + is_loading={!!is_loading} />
); diff --git a/src/components/node/NodePanelInner/index.tsx b/src/components/node/NodePanelInner/index.tsx index 0ed58eab..41e66d05 100644 --- a/src/components/node/NodePanelInner/index.tsx +++ b/src/components/node/NodePanelInner/index.tsx @@ -96,7 +96,9 @@ const NodePanelInner: FC = memo( )} - {like_count > 0 &&
{like_count}
} + {!!like_count && like_count > 0 && ( +
{like_count}
+ )}
)}
diff --git a/src/components/node/NodeRelatedItem/index.tsx b/src/components/node/NodeRelatedItem/index.tsx index 5c71eb50..88cf1bcd 100644 --- a/src/components/node/NodeRelatedItem/index.tsx +++ b/src/components/node/NodeRelatedItem/index.tsx @@ -1,16 +1,16 @@ -import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import styles from "./styles.module.scss"; -import classNames from "classnames"; -import { INode } from "~/redux/types"; -import { PRESETS, URLS } from "~/constants/urls"; -import { RouteComponentProps, withRouter } from "react-router"; -import { getURL, stringToColour } from "~/utils/dom"; +import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import styles from './styles.module.scss'; +import classNames from 'classnames'; +import { INode } from '~/redux/types'; +import { PRESETS, URLS } from '~/constants/urls'; +import { RouteComponentProps, withRouter } from 'react-router'; +import { getURL, stringToColour } from '~/utils/dom'; type IProps = RouteComponentProps & { item: Partial; }; -type CellSize = 'small' | 'medium' | 'large' +type CellSize = 'small' | 'medium' | 'large'; const getTitleLetters = (title: string): string => { const words = (title && title.split(' ')) || []; @@ -43,17 +43,21 @@ const NodeRelatedItemUnconnected: FC = memo(({ item, history }) => { useEffect(() => { if (!ref.current) return; - const cb = () => setWidth(ref.current.getBoundingClientRect().width) + + const cb = () => setWidth(ref.current!.getBoundingClientRect().width); + window.addEventListener('resize', cb); + cb(); + return () => window.removeEventListener('resize', cb); - }, [ref.current]) + }, [ref.current]); const size = useMemo(() => { if (width > 90) return 'large'; if (width > 76) return 'medium'; return 'small'; - }, [width]) + }, [width]); return (
= ({ node }) => { - const content = useMemo(() => formatTextParagraphs(path(['blocks', 0, 'text'], node)), [ + const content = useMemo(() => formatTextParagraphs(path(['blocks', 0, 'text'], node) || ''), [ node.blocks, ]); diff --git a/src/components/node/NodeVideoBlock/index.tsx b/src/components/node/NodeVideoBlock/index.tsx index b4e663fc..2fd8df93 100644 --- a/src/components/node/NodeVideoBlock/index.tsx +++ b/src/components/node/NodeVideoBlock/index.tsx @@ -7,7 +7,7 @@ interface IProps extends INodeComponentProps {} const NodeVideoBlock: FC = ({ node }) => { const video = useMemo(() => { - const url: string = path(['blocks', 0, 'url'], node); + const url: string = path(['blocks', 0, 'url'], node) || ''; const match = url && url.match( diff --git a/src/components/notifications/NotificationMessage/index.tsx b/src/components/notifications/NotificationMessage/index.tsx index f75f3dfb..77849eec 100644 --- a/src/components/notifications/NotificationMessage/index.tsx +++ b/src/components/notifications/NotificationMessage/index.tsx @@ -21,7 +21,7 @@ const NotificationMessage: FC = ({
-
Сообщение от ~{from.username}:
+
Сообщение от ~{from?.username}:
{text}
diff --git a/src/components/profile/MessageForm/index.tsx b/src/components/profile/MessageForm/index.tsx index 2ac14bcc..0d6a018d 100644 --- a/src/components/profile/MessageForm/index.tsx +++ b/src/components/profile/MessageForm/index.tsx @@ -39,7 +39,7 @@ const MessageFormUnconnected: FC = ({ const onSuccess = useCallback(() => { setText(''); - if (isEditing) { + if (isEditing && onCancel) { onCancel(); } }, [setText, isEditing, onCancel]); @@ -50,7 +50,7 @@ const MessageFormUnconnected: FC = ({ const onKeyDown = useCallback>( ({ ctrlKey, key }) => { - if (!!ctrlKey && key === 'Enter') onSubmit(); + if (ctrlKey && key === 'Enter') onSubmit(); }, [onSubmit] ); diff --git a/src/components/profile/ProfileDescription/index.tsx b/src/components/profile/ProfileDescription/index.tsx index 4e471a97..f3b43ea1 100644 --- a/src/components/profile/ProfileDescription/index.tsx +++ b/src/components/profile/ProfileDescription/index.tsx @@ -17,15 +17,15 @@ const ProfileDescriptionUnconnected: FC = ({ profile: { user, is_loading return (
- {user.description && ( + {!!user?.description && ( )} - {!user.description && ( + {!user?.description && (
- {user.fullname || user.username} пока ничего не рассказал о себе + {user?.fullname || user?.username} пока ничего не рассказал о себе
)}
diff --git a/src/components/tags/Tag/index.tsx b/src/components/tags/Tag/index.tsx index 8207e65a..5045ba12 100644 --- a/src/components/tags/Tag/index.tsx +++ b/src/components/tags/Tag/index.tsx @@ -3,7 +3,7 @@ import { ITag } from '~/redux/types'; import { TagWrapper } from '~/components/tags/TagWrapper'; const getTagFeature = (tag: Partial) => { - if (tag.title.substr(0, 1) === '/') return 'green'; + if (tag?.title?.substr(0, 1) === '/') return 'green'; return ''; }; diff --git a/src/components/tags/TagAutocomplete/index.tsx b/src/components/tags/TagAutocomplete/index.tsx index 485a6fe1..2dd80338 100644 --- a/src/components/tags/TagAutocomplete/index.tsx +++ b/src/components/tags/TagAutocomplete/index.tsx @@ -87,7 +87,10 @@ const TagAutocompleteUnconnected: FC = ({ useEffect(() => { tagSetAutocomplete({ options: [] }); - return () => tagSetAutocomplete({ options: [] }); + + return () => { + tagSetAutocomplete({ options: [] }); + }; }, [tagSetAutocomplete]); useEffect(() => { diff --git a/src/components/tags/TagInput/index.tsx b/src/components/tags/TagInput/index.tsx index e7c5c767..b3fb0eeb 100644 --- a/src/components/tags/TagInput/index.tsx +++ b/src/components/tags/TagInput/index.tsx @@ -77,6 +77,10 @@ const TagInput: FC = ({ exclude, onAppend, onClearTag, onSubmit }) => { const onFocus = useCallback(() => setFocused(true), []); const onBlur = useCallback( event => { + if (!wrapper.current || !ref.current) { + return; + } + if (wrapper.current.contains(event.target)) { ref.current.focus(); return; @@ -126,7 +130,7 @@ const TagInput: FC = ({ exclude, onAppend, onClearTag, onSubmit }) => { /> - {onInput && focused && input?.length > 0 && ( + {onInput && focused && input?.length > 0 && ref.current && ( = ({ tags, is_editable, onTagsChange, onTagClick, const onSubmit = useCallback( (last: string[]) => { + if (!onTagsChange) { + return; + } + const exist = tags.map(tag => tag.title); - onTagsChange(uniq([...exist, ...data, ...last])); + onTagsChange(uniq([...exist, ...data, ...last]).filter(el => el) as string[]); }, [data] ); useEffect(() => { - setData(data.filter(title => !tags.some(tag => tag.title.trim() === title.trim()))); + setData(data.filter(title => !tags.some(tag => tag?.title?.trim() === title.trim()))); }, [tags]); const onAppendTag = useCallback( @@ -44,10 +48,10 @@ export const Tags: FC = ({ tags, is_editable, onTagsChange, onTagClick, return last; }, [data, setData]); - const exclude = useMemo(() => [...(data || []), ...(tags || []).map(({ title }) => title)], [ - data, - tags, - ]); + const exclude = useMemo( + () => [...(data || []), ...(tags || []).filter(el => el.title).map(({ title }) => title!)], + [data, tags] + ); return ( diff --git a/src/constants/urls.ts b/src/constants/urls.ts index 606e6407..67eb4a39 100644 --- a/src/constants/urls.ts +++ b/src/constants/urls.ts @@ -1,3 +1,5 @@ +import { INode } from '~/redux/types'; + export const URLS = { BASE: '/', BORIS: '/boris', @@ -12,7 +14,7 @@ export const URLS = { NOT_FOUND: '/lost', BACKEND_DOWN: '/oopsie', }, - NODE_URL: (id: number | string) => `/post${id}`, + NODE_URL: (id: INode['id'] | string) => `/post${id}`, NODE_TAG_URL: (id: number, tagName: string) => `/post${id}/tag/${tagName}`, PROFILE: (username: string) => `/~${username}`, PROFILE_PAGE: (username: string) => `/profile/${username}`, diff --git a/src/containers/dialogs/BetterScrollDialog/index.tsx b/src/containers/dialogs/BetterScrollDialog/index.tsx index 4f9708a8..5122fc98 100644 --- a/src/containers/dialogs/BetterScrollDialog/index.tsx +++ b/src/containers/dialogs/BetterScrollDialog/index.tsx @@ -1,9 +1,9 @@ -import React, { FC, MouseEventHandler, ReactElement, useEffect, useRef } from "react"; -import styles from "./styles.module.scss"; -import { clearAllBodyScrollLocks, disableBodyScroll } from "body-scroll-lock"; -import { Icon } from "~/components/input/Icon"; -import { LoaderCircle } from "~/components/input/LoaderCircle"; -import { useCloseOnEscape } from "~/utils/hooks"; +import React, { FC, MouseEventHandler, ReactElement, useEffect, useRef } from 'react'; +import styles from './styles.module.scss'; +import { clearAllBodyScrollLocks, disableBodyScroll } from 'body-scroll-lock'; +import { Icon } from '~/components/input/Icon'; +import { LoaderCircle } from '~/components/input/LoaderCircle'; +import { useCloseOnEscape } from '~/utils/hooks'; interface IProps { children: React.ReactChild; @@ -14,7 +14,7 @@ interface IProps { width?: number; error?: string; is_loading?: boolean; - overlay?: ReactElement; + overlay?: JSX.Element; onOverlayClick?: MouseEventHandler; onRefCapture?: (ref: any) => void; diff --git a/src/containers/dialogs/EditorDialog/index.tsx b/src/containers/dialogs/EditorDialog/index.tsx index 4a2fa1d4..a5a42243 100644 --- a/src/containers/dialogs/EditorDialog/index.tsx +++ b/src/containers/dialogs/EditorDialog/index.tsx @@ -1,4 +1,12 @@ -import React, { createElement, FC, FormEvent, useCallback, useEffect, useState } from 'react'; +import React, { + createElement, + FC, + FormEvent, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { connect } from 'react-redux'; import { IDialogProps } from '~/redux/modal/constants'; import { useCloseOnEscape } from '~/utils/hooks'; @@ -16,6 +24,7 @@ import { EMPTY_NODE, NODE_EDITORS } from '~/redux/node/constants'; import { BetterScrollDialog } from '../BetterScrollDialog'; import { CoverBackdrop } from '~/components/containers/CoverBackdrop'; import { IEditorComponentProps } from '~/redux/node/types'; +import { has, values } from 'ramda'; const mapStateToProps = state => { const { editor, errors } = selectNode(state); @@ -32,7 +41,7 @@ const mapDispatchToProps = { type IProps = IDialogProps & ReturnType & typeof mapDispatchToProps & { - type: keyof typeof NODE_EDITORS; + type: string; }; const EditorDialogUnconnected: FC = ({ @@ -44,7 +53,7 @@ const EditorDialogUnconnected: FC = ({ type, }) => { const [data, setData] = useState(EMPTY_NODE); - const [temp, setTemp] = useState([]); + const [temp, setTemp] = useState([]); useEffect(() => setData(editor), [editor]); @@ -93,9 +102,18 @@ const EditorDialogUnconnected: FC = ({ useCloseOnEscape(onRequestClose); - const error = errors && Object.values(errors)[0]; + const error = values(errors)[0]; + const component = useMemo(() => { + if (!has(type, NODE_EDITORS)) { + return undefined; + } - if (!Object.prototype.hasOwnProperty.call(NODE_EDITORS, type)) return null; + return NODE_EDITORS[type]; + }, [type]); + + if (!component) { + return null; + } return (
@@ -107,7 +125,7 @@ const EditorDialogUnconnected: FC = ({ onClose={onRequestClose} >
- {createElement(NODE_EDITORS[type], { + {createElement(component, { data, setData, temp, diff --git a/src/containers/dialogs/LoginDialog/index.tsx b/src/containers/dialogs/LoginDialog/index.tsx index 83c7bc16..23792200 100644 --- a/src/containers/dialogs/LoginDialog/index.tsx +++ b/src/containers/dialogs/LoginDialog/index.tsx @@ -80,7 +80,7 @@ const LoginDialogUnconnected: FC = ({ ); useEffect(() => { - if (error) userSetLoginError(null); + if (error) userSetLoginError(''); }, [username, password]); useEffect(() => { diff --git a/src/containers/dialogs/LoginDialogButtons/index.tsx b/src/containers/dialogs/LoginDialogButtons/index.tsx index f328f11d..45d55f50 100644 --- a/src/containers/dialogs/LoginDialogButtons/index.tsx +++ b/src/containers/dialogs/LoginDialogButtons/index.tsx @@ -3,9 +3,10 @@ import { Button } from '~/components/input/Button'; import { Grid } from '~/components/containers/Grid'; import { Group } from '~/components/containers/Group'; import styles from './styles.module.scss'; +import { ISocialProvider } from '~/redux/auth/types'; interface IProps { - openOauthWindow: (provider: string) => MouseEventHandler; + openOauthWindow: (provider: ISocialProvider) => MouseEventHandler; } const LoginDialogButtons: FC = ({ openOauthWindow }) => ( diff --git a/src/containers/dialogs/Modal/index.tsx b/src/containers/dialogs/Modal/index.tsx index dd7b768a..918e7786 100644 --- a/src/containers/dialogs/Modal/index.tsx +++ b/src/containers/dialogs/Modal/index.tsx @@ -24,7 +24,7 @@ const ModalUnconnected: FC = ({ }) => { const onRequestClose = useCallback(() => { modalSetShown(false); - modalSetDialog(null); + modalSetDialog(''); }, [modalSetShown, modalSetDialog]); if (!dialog || !DIALOG_CONTENT[dialog] || !is_shown) return null; @@ -43,10 +43,7 @@ const ModalUnconnected: FC = ({ ); }; -const Modal = connect( - mapStateToProps, - mapDispatchToProps -)(ModalUnconnected); +const Modal = connect(mapStateToProps, mapDispatchToProps)(ModalUnconnected); export { ModalUnconnected, Modal }; diff --git a/src/containers/dialogs/PhotoSwipe/index.tsx b/src/containers/dialogs/PhotoSwipe/index.tsx index 668c55eb..8094ef77 100644 --- a/src/containers/dialogs/PhotoSwipe/index.tsx +++ b/src/containers/dialogs/PhotoSwipe/index.tsx @@ -78,7 +78,9 @@ const PhotoSwipeUnconnected: FC = ({ photoswipe, modalSetShown }) => { useEffect(() => { window.location.hash = 'preview'; - return () => (window.location.hash = ''); + return () => { + window.location.hash = ''; + }; }, []); return ( diff --git a/src/containers/dialogs/RestorePasswordDialog/index.tsx b/src/containers/dialogs/RestorePasswordDialog/index.tsx index 905c6126..d768b1ba 100644 --- a/src/containers/dialogs/RestorePasswordDialog/index.tsx +++ b/src/containers/dialogs/RestorePasswordDialog/index.tsx @@ -1,4 +1,4 @@ -import React, { FC, useState, useMemo, useCallback, useEffect } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { IDialogProps } from '~/redux/types'; import { connect } from 'react-redux'; import { BetterScrollDialog } from '../BetterScrollDialog'; @@ -49,7 +49,7 @@ const RestorePasswordDialogUnconnected: FC = ({ useEffect(() => { if (error || is_succesfull) { - authSetRestore({ error: null, is_succesfull: false }); + authSetRestore({ error: '', is_succesfull: false }); } }, [password, password_again]); @@ -69,7 +69,7 @@ const RestorePasswordDialogUnconnected: FC = ({
Пароль обновлен
-
Добро пожаловать домой, ~{user.username}!
+
Добро пожаловать домой, ~{user?.username}!
@@ -77,14 +77,16 @@ const RestorePasswordDialogUnconnected: FC = ({ Ура! - ) : null, + ) : ( + undefined + ), [is_succesfull] ); - const not_ready = useMemo(() => (is_loading && !user ?
: null), [ - is_loading, - user, - ]); + const not_ready = useMemo( + () => (is_loading && !user ?
: undefined), + [is_loading, user] + ); const invalid_code = useMemo( () => @@ -100,7 +102,9 @@ const RestorePasswordDialogUnconnected: FC = ({ Очень жаль - ) : null, + ) : ( + undefined + ), [is_loading, user, error] ); @@ -135,7 +139,7 @@ const RestorePasswordDialogUnconnected: FC = ({ type="password" value={password_again} handler={setPasswordAgain} - error={password_again && doesnt_match && ERROR_LITERAL[ERRORS.DOESNT_MATCH]} + error={password_again && doesnt_match ? ERROR_LITERAL[ERRORS.DOESNT_MATCH] : ''} /> diff --git a/src/containers/dialogs/RestoreRequestDialog/index.tsx b/src/containers/dialogs/RestoreRequestDialog/index.tsx index 5a77c6d9..616bd55c 100644 --- a/src/containers/dialogs/RestoreRequestDialog/index.tsx +++ b/src/containers/dialogs/RestoreRequestDialog/index.tsx @@ -43,7 +43,7 @@ const RestoreRequestDialogUnconnected: FC = ({ useEffect(() => { if (error || is_succesfull) { - authSetRestore({ error: null, is_succesfull: false }); + authSetRestore({ error: '', is_succesfull: false }); } }, [field]); @@ -72,7 +72,9 @@ const RestoreRequestDialogUnconnected: FC = ({ Отлично! - ) : null, + ) : ( + undefined + ), [is_succesfull] ); diff --git a/src/containers/node/BorisLayout/index.tsx b/src/containers/node/BorisLayout/index.tsx index fb005d40..49ef20d2 100644 --- a/src/containers/node/BorisLayout/index.tsx +++ b/src/containers/node/BorisLayout/index.tsx @@ -37,6 +37,7 @@ const BorisLayout: FC = () => { if ( user.last_seen_boris && + last_comment.created_at && !isBefore(new Date(user.last_seen_boris), new Date(last_comment.created_at)) ) return; diff --git a/src/containers/node/NodeLayout/index.tsx b/src/containers/node/NodeLayout/index.tsx index a36e6ada..00c3ef7c 100644 --- a/src/containers/node/NodeLayout/index.tsx +++ b/src/containers/node/NodeLayout/index.tsx @@ -12,9 +12,14 @@ import { NodeNoComments } from '~/components/node/NodeNoComments'; import { NodeRelated } from '~/components/node/NodeRelated'; import { NodeComments } from '~/components/node/NodeComments'; import { NodeTags } from '~/components/node/NodeTags'; -import { INodeComponentProps, NODE_COMPONENTS, NODE_HEADS, NODE_INLINES } from '~/redux/node/constants'; +import { + INodeComponentProps, + NODE_COMPONENTS, + NODE_HEADS, + NODE_INLINES, +} from '~/redux/node/constants'; import { selectUser } from '~/redux/auth/selectors'; -import { pick } from 'ramda'; +import { path, pick, prop } from 'ramda'; import { NodeRelatedPlaceholder } from '~/components/node/NodeRelated/placeholder'; import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge'; import { NodeCommentForm } from '~/components/node/NodeCommentForm'; @@ -71,9 +76,6 @@ const NodeLayoutUnconnected: FC = memo( nodeStar, nodeLock, nodeSetCoverImage, - nodeLockComment, - nodeEditComment, - nodeLoadMoreComments, modalShowPhotoswipe, }) => { const [layout, setLayout] = useState({}); @@ -84,7 +86,6 @@ const NodeLayoutUnconnected: FC = memo( comments = [], current: node, related, - comment_data, comment_count, } = useShallowSelect(selectNode); const updateLayout = useCallback(() => setLayout({}), []); @@ -103,6 +104,10 @@ const NodeLayoutUnconnected: FC = memo( const onTagClick = useCallback( (tag: Partial) => { + if (!node?.id || !tag?.title) { + return; + } + history.push(URLS.NODE_TAG_URL(node.id, encodeURIComponent(tag.title))); }, [history, node.id] @@ -112,9 +117,9 @@ const NodeLayoutUnconnected: FC = memo( const can_like = useMemo(() => canLikeNode(node, user), [node, user]); const can_star = useMemo(() => canStarNode(node, user), [node, user]); - const head = node && node.type && NODE_HEADS[node.type]; - const block = node && node.type && NODE_COMPONENTS[node.type]; - const inline = node && node.type && NODE_INLINES[node.type]; + const head = useMemo(() => node?.type && prop(node?.type, NODE_HEADS), [node.type]); + const block = useMemo(() => node?.type && prop(node?.type, NODE_COMPONENTS), [node.type]); + const inline = useMemo(() => node?.type && prop(node?.type, NODE_INLINES), [node.type]); const onEdit = useCallback(() => nodeEdit(node.id), [nodeEdit, node]); const onLike = useCallback(() => nodeLike(node.id), [nodeLike, node]); @@ -147,10 +152,10 @@ const NodeLayoutUnconnected: FC = memo( return ( <> - {createNodeBlock(head)} + {!!head && createNodeBlock(head)} - {createNodeBlock(block)} + {!!block && createNodeBlock(block)} = memo( {!is_loading && related && related.albums && + !!node?.id && Object.keys(related.albums) .filter(album => related.albums[album].length > 0) .map(album => ( + {album} } diff --git a/src/containers/profile/ProfileAvatar/index.tsx b/src/containers/profile/ProfileAvatar/index.tsx index 07869b7d..4de56cd7 100644 --- a/src/containers/profile/ProfileAvatar/index.tsx +++ b/src/containers/profile/ProfileAvatar/index.tsx @@ -1,43 +1,42 @@ -import React, { FC, useCallback, useEffect, useState } from "react"; -import styles from "./styles.module.scss"; -import { connect } from "react-redux"; -import { getURL } from "~/utils/dom"; -import { pick } from "ramda"; -import { selectAuthProfile, selectAuthUser } from "~/redux/auth/selectors"; -import { PRESETS } from "~/constants/urls"; -import { selectUploads } from "~/redux/uploads/selectors"; -import { IFileWithUUID } from "~/redux/types"; -import uuid from "uuid4"; -import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from "~/redux/uploads/constants"; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import styles from './styles.module.scss'; +import { connect } from 'react-redux'; +import { getURL } from '~/utils/dom'; +import { pick } from 'ramda'; +import { selectAuthProfile, selectAuthUser } from '~/redux/auth/selectors'; +import { PRESETS } from '~/constants/urls'; +import { selectUploads } from '~/redux/uploads/selectors'; +import { IFileWithUUID } from '~/redux/types'; +import uuid from 'uuid4'; +import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from '~/redux/uploads/constants'; import { path } from 'ramda'; -import * as UPLOAD_ACTIONS from "~/redux/uploads/actions"; -import * as AUTH_ACTIONS from "~/redux/auth/actions"; -import { Icon } from "~/components/input/Icon"; +import * as UPLOAD_ACTIONS from '~/redux/uploads/actions'; +import * as AUTH_ACTIONS from '~/redux/auth/actions'; +import { Icon } from '~/components/input/Icon'; const mapStateToProps = state => ({ - user: pick(["id"], selectAuthUser(state)), - profile: pick(["is_loading", "user"], selectAuthProfile(state)), - uploads: pick(["statuses", "files"], selectUploads(state)) + user: pick(['id'], selectAuthUser(state)), + profile: pick(['is_loading', 'user'], selectAuthProfile(state)), + uploads: pick(['statuses', 'files'], selectUploads(state)), }); const mapDispatchToProps = { uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles, - authPatchUser: AUTH_ACTIONS.authPatchUser + authPatchUser: AUTH_ACTIONS.authPatchUser, }; -type IProps = ReturnType & - typeof mapDispatchToProps & {}; +type IProps = ReturnType & typeof mapDispatchToProps & {}; const ProfileAvatarUnconnected: FC = ({ user: { id }, profile: { is_loading, user }, uploads: { statuses, files }, uploadUploadFiles, - authPatchUser + authPatchUser, }) => { - const can_edit = !is_loading && id && id === user.id; + const can_edit = !is_loading && id && id === user?.id; - const [temp, setTemp] = useState(null); + const [temp, setTemp] = useState(''); useEffect(() => { if (!can_edit) return; @@ -45,7 +44,7 @@ const ProfileAvatarUnconnected: FC = ({ Object.entries(statuses).forEach(([id, status]) => { if (temp === id && !!status.uuid && files[status.uuid]) { authPatchUser({ photo: files[status.uuid] }); - setTemp(null); + setTemp(''); } }); }, [statuses, files, temp, can_edit, authPatchUser]); @@ -58,11 +57,11 @@ const ProfileAvatarUnconnected: FC = ({ temp_id: uuid(), subject: UPLOAD_SUBJECTS.AVATAR, target: UPLOAD_TARGETS.PROFILES, - type: UPLOAD_TYPES.IMAGE + type: UPLOAD_TYPES.IMAGE, }) ); - setTemp(path([0, "temp_id"], items)); + setTemp(path([0, 'temp_id'], items) || ''); uploadUploadFiles(items.slice(0, 1)); }, [uploadUploadFiles, setTemp] @@ -81,13 +80,15 @@ const ProfileAvatarUnconnected: FC = ({ [onUpload, can_edit] ); + const backgroundImage = is_loading + ? undefined + : `url("${user && getURL(user.photo, PRESETS.avatar)}")`; + return (
{can_edit && } @@ -100,9 +101,6 @@ const ProfileAvatarUnconnected: FC = ({ ); }; -const ProfileAvatar = connect( - mapStateToProps, - mapDispatchToProps -)(ProfileAvatarUnconnected); +const ProfileAvatar = connect(mapStateToProps, mapDispatchToProps)(ProfileAvatarUnconnected); export { ProfileAvatar }; diff --git a/src/containers/profile/ProfileInfo/index.tsx b/src/containers/profile/ProfileInfo/index.tsx index f5739f12..7cdacccc 100644 --- a/src/containers/profile/ProfileInfo/index.tsx +++ b/src/containers/profile/ProfileInfo/index.tsx @@ -1,5 +1,5 @@ import React, { FC, ReactNode } from 'react'; -import { IUser } from '~/redux/auth/types'; +import { IAuthState, IUser } from '~/redux/auth/types'; import styles from './styles.module.scss'; import { Group } from '~/components/containers/Group'; import { Placeholder } from '~/components/placeholders/Placeholder'; @@ -14,7 +14,7 @@ interface IProps { is_loading?: boolean; is_own?: boolean; - setTab?: (tab: string) => void; + setTab?: (tab: IAuthState['profile']['tab']) => void; content?: ReactNode; } @@ -26,16 +26,16 @@ const ProfileInfo: FC = ({ user, tab, is_loading, is_own, setTab, conten
- {is_loading ? : user.fullname || user.username} + {is_loading ? : user?.fullname || user?.username}
- {is_loading ? : getPrettyDate(user.last_seen)} + {is_loading ? : getPrettyDate(user?.last_seen)}
- + {content}
diff --git a/src/containers/profile/ProfileLayout/index.tsx b/src/containers/profile/ProfileLayout/index.tsx index 9db6fc73..24d623dd 100644 --- a/src/containers/profile/ProfileLayout/index.tsx +++ b/src/containers/profile/ProfileLayout/index.tsx @@ -20,10 +20,10 @@ const ProfileLayoutUnconnected: FC = ({ history, nodeSetCoverImage }) => const { params: { username }, } = useRouteMatch<{ username: string }>(); - const [user, setUser] = useState(null); + const [user, setUser] = useState(undefined); useEffect(() => { - if (user) setUser(null); + if (user) setUser(undefined); }, [username]); useEffect(() => { diff --git a/src/containers/profile/ProfileMessages/index.tsx b/src/containers/profile/ProfileMessages/index.tsx index 61948d2d..92357851 100644 --- a/src/containers/profile/ProfileMessages/index.tsx +++ b/src/containers/profile/ProfileMessages/index.tsx @@ -31,7 +31,7 @@ const ProfileMessagesUnconnected: FC = ({ messagesRefreshMessages, }) => { const wasAtBottom = useRef(true); - const [wrap, setWrap] = useState(null); + const [wrap, setWrap] = useState(undefined); const [editingMessageId, setEditingMessageId] = useState(0); const onEditMessage = useCallback((id: number) => setEditingMessageId(id), [setEditingMessageId]); @@ -95,31 +95,33 @@ const ProfileMessagesUnconnected: FC = ({ if (!messages.messages.length || profile.is_loading) return ; - return ( - messages.messages.length > 0 && ( -
- {messages.messages - .filter(message => !!message.text) - .map(( - message // TODO: show files / memo - ) => ( - - ))} + if (messages.messages.length <= 0) { + return null; + } - {!messages.is_loading_messages && messages.messages.length > 0 && ( -
Когда-нибудь здесь будут еще сообщения
- )} -
- ) + return ( +
+ {messages.messages + .filter(message => !!message.text) + .map(( + message // TODO: show files / memo + ) => ( + + ))} + + {!messages.is_loading_messages && messages.messages.length > 0 && ( +
Когда-нибудь здесь будут еще сообщения
+ )} +
); }; diff --git a/src/containers/profile/ProfilePageLeft/index.tsx b/src/containers/profile/ProfilePageLeft/index.tsx index dd4fdb58..fb2af296 100644 --- a/src/containers/profile/ProfilePageLeft/index.tsx +++ b/src/containers/profile/ProfilePageLeft/index.tsx @@ -1,11 +1,14 @@ import React, { FC, useMemo } from 'react'; -import styles from './styles.module.scss'; import { IAuthState } from '~/redux/auth/types'; -import { getURL } from '~/utils/dom'; +import { formatText, getURL } from '~/utils/dom'; import { PRESETS, URLS } from '~/constants/urls'; import { Placeholder } from '~/components/placeholders/Placeholder'; import { Link } from 'react-router-dom'; import { Icon } from '~/components/input/Icon'; +import classNames from 'classnames'; + +import styles from './styles.module.scss'; +import markdown from '~/styles/common/markdown.module.scss'; interface IProps { profile: IAuthState['profile']; @@ -26,11 +29,11 @@ const ProfilePageLeft: FC = ({ username, profile }) => {
- {profile.is_loading ? : profile.user.fullname} + {profile.is_loading ? : profile?.user?.fullname}
- {profile.is_loading ? : `~${profile.user.username}`} + {profile.is_loading ? : `~${profile?.user?.username}`}
@@ -53,7 +56,9 @@ const ProfilePageLeft: FC = ({ username, profile }) => {
{profile && profile.user && profile.user.description && false && ( -
{profile.user.description}
+
+ {formatText(profile?.user?.description || '')} +
)}
); diff --git a/src/containers/profile/ProfileTabs/index.tsx b/src/containers/profile/ProfileTabs/index.tsx index 4fe97bef..d576d4e2 100644 --- a/src/containers/profile/ProfileTabs/index.tsx +++ b/src/containers/profile/ProfileTabs/index.tsx @@ -1,38 +1,49 @@ -import React, { FC } from 'react'; +import React, { FC, useCallback } from 'react'; import styles from './styles.module.scss'; import classNames from 'classnames'; +import { IAuthState } from '~/redux/auth/types'; interface IProps { tab: string; is_own: boolean; - setTab: (tab: string) => void; + setTab?: (tab: IAuthState['profile']['tab']) => void; } -const ProfileTabs: FC = ({ tab, is_own, setTab }) => ( -
-
setTab('profile')} - > - Профиль +const ProfileTabs: FC = ({ tab, is_own, setTab }) => { + const changeTab = useCallback( + (tab: IAuthState['profile']['tab']) => () => { + if (!setTab) return; + setTab(tab); + }, + [setTab] + ); + + return ( +
+
+ Профиль +
+
+ Сообщения +
+ {is_own && ( + <> +
+ Настройки +
+ + )}
-
setTab('messages')} - > - Сообщения -
- {is_own && ( - <> -
setTab('settings')} - > - Настройки -
- - )} -
-); + ); +}; export { ProfileTabs }; diff --git a/src/containers/sidebars/ProfileSidebar/index.tsx b/src/containers/sidebars/ProfileSidebar/index.tsx index 308cad8f..9032856d 100644 --- a/src/containers/sidebars/ProfileSidebar/index.tsx +++ b/src/containers/sidebars/ProfileSidebar/index.tsx @@ -56,7 +56,7 @@ const ProfileSidebarUnconnected: FC = ({
- + {!!user && }
diff --git a/src/containers/sidebars/TagSidebar/index.tsx b/src/containers/sidebars/TagSidebar/index.tsx index e5b849ff..ff045a81 100644 --- a/src/containers/sidebars/TagSidebar/index.tsx +++ b/src/containers/sidebars/TagSidebar/index.tsx @@ -35,7 +35,10 @@ const TagSidebarUnconnected: FC = ({ nodes, tagLoadNodes, tagSetNodes }) useEffect(() => { tagLoadNodes(tag); - return () => tagSetNodes({ list: [], count: 0 }); + + return () => { + tagSetNodes({ list: [], count: 0 }); + }; }, [tag]); const loadMore = useCallback(() => { diff --git a/src/redux/boris/reducer.ts b/src/redux/boris/reducer.ts index cb2ff72e..2032c793 100644 --- a/src/redux/boris/reducer.ts +++ b/src/redux/boris/reducer.ts @@ -31,7 +31,7 @@ export type IStatBackend = { export type IBorisState = Readonly<{ stats: { git: Partial[]; - backend: IStatBackend; + backend?: IStatBackend; is_loading: boolean; }; }>; @@ -39,7 +39,7 @@ export type IBorisState = Readonly<{ const BORIS_INITIAL_STATE: IBorisState = { stats: { git: [], - backend: null, + backend: undefined, is_loading: false, }, }; diff --git a/src/redux/node/actions.ts b/src/redux/node/actions.ts index d2f95963..05012f98 100644 --- a/src/redux/node/actions.ts +++ b/src/redux/node/actions.ts @@ -17,7 +17,7 @@ export const nodeSetSaveErrors = (errors: IValidationErrors) => ({ type: NODE_ACTIONS.SET_SAVE_ERRORS, }); -export const nodeGotoNode = (id: number, node_type: INode['type']) => ({ +export const nodeGotoNode = (id: INode['id'], node_type: INode['type']) => ({ id, node_type, type: NODE_ACTIONS.GOTO_NODE, diff --git a/src/redux/node/constants.ts b/src/redux/node/constants.ts index acd3f348..b5b11e48 100644 --- a/src/redux/node/constants.ts +++ b/src/redux/node/constants.ts @@ -1,4 +1,4 @@ -import { FC } from 'react'; +import { FC, ReactElement } from 'react'; import { IComment, INode, ValueOf } from '../types'; import { NodeImageSlideBlock } from '~/components/node/NodeImageSlideBlock'; import { NodeTextBlock } from '~/components/node/NodeTextBlock'; @@ -13,7 +13,7 @@ import { EditorImageUploadButton } from '~/components/editors/EditorImageUploadB import { EditorAudioUploadButton } from '~/components/editors/EditorAudioUploadButton'; import { EditorUploadCoverButton } from '~/components/editors/EditorUploadCoverButton'; import { modalShowPhotoswipe } from '../modal/actions'; -import { IEditorComponentProps } from '~/redux/node/types'; +import { IEditorComponentProps, NodeEditorProps } from '~/redux/node/types'; import { EditorFiller } from '~/components/editors/EditorFiller'; const prefix = 'NODE.'; @@ -50,15 +50,13 @@ export const NODE_ACTIONS = { }; export const EMPTY_NODE: INode = { - id: null, - - user: null, - + id: 0, + user: undefined, title: '', files: [], - cover: null, - type: null, + cover: undefined, + type: undefined, blocks: [], tags: [], @@ -102,13 +100,16 @@ export const NODE_INLINES: INodeComponents = { }; export const EMPTY_COMMENT: IComment = { - id: null, + id: 0, text: '', files: [], - user: null, + user: undefined, }; -export const NODE_EDITORS = { +export const NODE_EDITORS: Record< + typeof NODE_TYPES[keyof typeof NODE_TYPES], + FC +> = { [NODE_TYPES.IMAGE]: ImageEditor, [NODE_TYPES.TEXT]: TextEditor, [NODE_TYPES.VIDEO]: VideoEditor, diff --git a/src/redux/node/reducer.ts b/src/redux/node/reducer.ts index eb0b3ecb..438524cd 100644 --- a/src/redux/node/reducer.ts +++ b/src/redux/node/reducer.ts @@ -8,12 +8,12 @@ export type INodeState = Readonly<{ current: INode; comments: IComment[]; related: { - albums: Record>; - similar: Partial; + albums: Record; + similar: INode[]; }; comment_data: Record; comment_count: number; - current_cover_image: IFile; + current_cover_image?: IFile; error: string; errors: Record; @@ -38,14 +38,17 @@ const INITIAL_STATE: INodeState = { }, comment_count: 0, comments: [], - related: null, - current_cover_image: null, + related: { + albums: {}, + similar: [], + }, + current_cover_image: undefined, is_loading: false, is_loading_comments: false, is_sending_comment: false, - error: null, + error: '', errors: {}, }; diff --git a/src/redux/node/sagas.ts b/src/redux/node/sagas.ts index 2e95ff8b..8a629dfe 100644 --- a/src/redux/node/sagas.ts +++ b/src/redux/node/sagas.ts @@ -51,6 +51,7 @@ import { selectNode } from './selectors'; import { Unwrap } from '../types'; import { NODE_EDITOR_DIALOGS } from '~/constants/dialogs'; import { DIALOGS } from '~/redux/modal/constants'; +import { has } from 'ramda'; export function* updateNodeEverywhere(node) { const { @@ -103,6 +104,9 @@ function* onNodeSave({ node }: ReturnType) { } function* onNodeGoto({ id, node_type }: ReturnType) { + if (!id) { + return; + } if (node_type) yield put(nodeSetCurrent({ ...EMPTY_NODE, type: node_type })); yield put(nodeLoadNode(id)); @@ -224,7 +228,7 @@ function* onUpdateTags({ id, tags }: ReturnType) { } function* onCreateSaga({ node_type: type }: ReturnType) { - if (!NODE_EDITOR_DIALOGS[type]) return; + if (!type || !has(type, NODE_EDITOR_DIALOGS)) return; yield put(nodeSetEditor({ ...EMPTY_NODE, ...(NODE_EDITOR_DATA[type] || {}), type })); yield put(modalShowDialog(NODE_EDITOR_DIALOGS[type])); @@ -240,6 +244,8 @@ function* onEditSaga({ id }: ReturnType) { const { node }: Unwrap = yield call(apiGetNode, { id }); + if (!node.type || !has(node.type, NODE_EDITOR_DIALOGS)) return; + if (!NODE_EDITOR_DIALOGS[node?.type]) { throw new Error('Unknown node type'); } diff --git a/src/redux/node/types.ts b/src/redux/node/types.ts index d7973326..4dba0cc3 100644 --- a/src/redux/node/types.ts +++ b/src/redux/node/types.ts @@ -83,3 +83,9 @@ export type ApiLockCommentRequest = { export type ApiLockcommentResult = { deleted_at: string; }; +export type NodeEditorProps = { + data: INode; + setData: (val: INode) => void; + temp: string[]; + setTemp: (val: string[]) => void; +}; diff --git a/src/redux/types.ts b/src/redux/types.ts index 9155b1ff..8dd92998 100644 --- a/src/redux/types.ts +++ b/src/redux/types.ts @@ -71,7 +71,7 @@ export interface IFile { url: string; size: number; - type: IUploadType; + type?: IUploadType; mime: string; metadata?: { id3title?: string; @@ -92,7 +92,7 @@ export interface IFileWithUUID { file: File; subject?: string; target: string; - type: string; + type?: string; onSuccess?: (file: IFile) => void; onFail?: () => void; } @@ -111,13 +111,13 @@ export type IBlock = IBlockText | IBlockEmbed; export interface INode { id?: number; - user: Partial; + user?: Partial; title: string; files: IFile[]; - cover: IFile; - type: string; + cover?: IFile; + type?: string; blocks: IBlock[]; thumbnail?: string; @@ -143,7 +143,7 @@ export interface IComment { id: number; text: string; files: IFile[]; - user: IUser; + user?: IUser; created_at?: string; update_at?: string; diff --git a/src/redux/uploads/constants.ts b/src/redux/uploads/constants.ts index 27f91abf..8d8cf134 100644 --- a/src/redux/uploads/constants.ts +++ b/src/redux/uploads/constants.ts @@ -15,9 +15,9 @@ export const UPLOAD_ACTIONS = { }; export const EMPTY_FILE: IFile = { - id: null, - user_id: null, - node_id: null, + id: undefined, + user_id: undefined, + node_id: undefined, name: '', orig_name: '', @@ -25,21 +25,21 @@ export const EMPTY_FILE: IFile = { full_path: '', url: '', size: 0, - type: null, + type: undefined, mime: '', }; export const EMPTY_UPLOAD_STATUS: IUploadStatus = { is_uploading: false, - preview: null, - error: null, - uuid: null, - url: null, + preview: '', + error: '', + uuid: 0, + url: '', progress: 0, - thumbnail_url: null, - type: null, - temp_id: null, - name: null, + thumbnail_url: '', + type: '', + temp_id: '', + name: '', }; // for targeted cancellation diff --git a/src/redux/uploads/handlers.ts b/src/redux/uploads/handlers.ts index fe3140bf..57a4b86b 100644 --- a/src/redux/uploads/handlers.ts +++ b/src/redux/uploads/handlers.ts @@ -2,38 +2,41 @@ import { assocPath } from 'ramda'; import { omit } from 'ramda'; import { UPLOAD_ACTIONS, EMPTY_UPLOAD_STATUS } from './constants'; -import { - uploadAddStatus, uploadDropStatus, uploadSetStatus, uploadAddFile -} from './actions'; +import { uploadAddStatus, uploadDropStatus, uploadSetStatus, uploadAddFile } from './actions'; import { IUploadState } from './reducer'; const addStatus = ( state: IUploadState, - { temp_id, status, }: ReturnType -): IUploadState => assocPath( - ['statuses'], - { ...state.statuses, [temp_id]: { ...EMPTY_UPLOAD_STATUS, ...status, }, }, - state -); + { temp_id, status }: ReturnType +): IUploadState => + assocPath( + ['statuses'], + { ...state.statuses, [temp_id]: { ...EMPTY_UPLOAD_STATUS, ...status } }, + state + ); const dropStatus = ( state: IUploadState, - { temp_id, }: ReturnType + { temp_id }: ReturnType ): IUploadState => assocPath(['statuses'], omit([temp_id], state.statuses), state); const setStatus = ( state: IUploadState, - { temp_id, status, }: ReturnType -): IUploadState => assocPath( - ['statuses'], - { - ...state.statuses, - [temp_id]: { ...(state.statuses[temp_id] || EMPTY_UPLOAD_STATUS), ...status, }, - }, - state -); + { temp_id, status }: ReturnType +): IUploadState => + assocPath( + ['statuses'], + { + ...state.statuses, + [temp_id]: { ...(state.statuses[temp_id] || EMPTY_UPLOAD_STATUS), ...status }, + }, + state + ); -const addFile = (state: IUploadState, { file, }: ReturnType): IUploadState => assocPath(['files'], { ...state.files, [file.id]: file, }, state); +const addFile = (state: IUploadState, { file }: ReturnType): IUploadState => { + if (!file.id) return state; + return assocPath(['files', file.id], file, state); +}; export const UPLOAD_HANDLERS = { [UPLOAD_ACTIONS.ADD_STATUS]: addStatus, diff --git a/src/redux/uploads/mocks.ts b/src/redux/uploads/mocks.ts deleted file mode 100644 index e8ccc9e9..00000000 --- a/src/redux/uploads/mocks.ts +++ /dev/null @@ -1,15 +0,0 @@ -import uuid from 'uuid4'; -import { IResultWithStatus, IFile, UUID } from '../types'; -import { HTTP_RESPONSES } from '~/utils/api'; -import { EMPTY_FILE } from './constants'; - -export const uploadMock = ({ temp_id, file }: { temp_id: UUID; file: File }): Promise> => ( - Promise.resolve({ - status: HTTP_RESPONSES.CREATED, - data: { - ...EMPTY_FILE, - id: uuid(), - temp_id, - }, - error: null, - })); diff --git a/src/redux/uploads/sagas.ts b/src/redux/uploads/sagas.ts index c7b168fd..ff1b68a9 100644 --- a/src/redux/uploads/sagas.ts +++ b/src/redux/uploads/sagas.ts @@ -73,7 +73,7 @@ function* uploadFile({ file, temp_id, type, target, onSuccess, onFail }: IFileWi if (!temp_id) return; try { - if (!file.type || !FILE_MIMES[type] || !FILE_MIMES[type].includes(file.type)) { + if (!file.type || !type || !FILE_MIMES[type] || !FILE_MIMES[type].includes(file.type)) { return { error: 'File_Not_Image', status: HTTP_RESPONSES.BAD_REQUEST, diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 6d414348..eae9655f 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -89,7 +89,10 @@ export const getURLFromString = ( return url.replace('REMOTE_CURRENT://', process.env.REACT_APP_REMOTE_CURRENT); }; -export const getURL = (file: Partial, size?: typeof PRESETS[keyof typeof PRESETS]) => { +export const getURL = ( + file: Partial | undefined, + size?: typeof PRESETS[keyof typeof PRESETS] +) => { return file?.url ? getURLFromString(file.url, size) : ''; }; diff --git a/src/utils/fn.ts b/src/utils/fn.ts index def79fbb..8f9a9487 100644 --- a/src/utils/fn.ts +++ b/src/utils/fn.ts @@ -10,16 +10,20 @@ export const objFromArray = (array: any[], key: string) => array.reduce((obj, el) => (key && el[key] ? { ...obj, [el[key]]: el } : obj), {}); export const groupCommentsByUser = ( - result: ICommentGroup[], + grouppedComments: ICommentGroup[], comment: IComment ): ICommentGroup[] => { - const last: ICommentGroup = path([result.length - 1], result) || null; + const last: ICommentGroup | undefined = path([grouppedComments.length - 1], grouppedComments); + + if (!comment.user) { + return grouppedComments; + } return [ ...(!last || path(['user', 'id'], last) !== path(['user', 'id'], comment) ? [ // add new group - ...result, + ...grouppedComments, { user: comment.user, comments: [comment], @@ -28,7 +32,7 @@ export const groupCommentsByUser = ( ] : [ // append to last group - ...result.slice(0, result.length - 1), + ...grouppedComments.slice(0, grouppedComments.length - 1), { ...last, comments: [...last.comments, comment], @@ -37,6 +41,3 @@ export const groupCommentsByUser = ( ]), ]; }; - -// const isSameComment = (comments, index) => -// comments[index - 1] && comments[index - 1].user.id === comments[index].user.id; diff --git a/src/utils/hooks/fileUploader.tsx b/src/utils/hooks/fileUploader.tsx index 207a86f3..3e2910e6 100644 --- a/src/utils/hooks/fileUploader.tsx +++ b/src/utils/hooks/fileUploader.tsx @@ -1,4 +1,12 @@ -import React, { createContext, FC, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import React, { + createContext, + FC, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; import { IFile, IFileWithUUID } from '~/redux/types'; import { UPLOAD_SUBJECTS, UPLOAD_TARGETS } from '~/redux/uploads/constants'; import { getFileType } from '~/utils/uploader'; @@ -7,6 +15,8 @@ import { useDispatch } from 'react-redux'; import { uploadUploadFiles } from '~/redux/uploads/actions'; import { useShallowSelect } from '~/utils/hooks/useShallowSelect'; import { selectUploads } from '~/redux/uploads/selectors'; +import { has, path } from 'ramda'; +import { IUploadStatus } from '~/redux/uploads/reducer'; export const useFileUploader = ( subject: typeof UPLOAD_SUBJECTS[keyof typeof UPLOAD_SUBJECTS], @@ -31,7 +41,7 @@ export const useFileUploader = ( }) ); - const temps = items.map(file => file.temp_id); + const temps = items.filter(el => !!el.temp_id).map(file => file.temp_id!); setPendingIDs([...pendingIDs, ...temps]); dispatch(uploadUploadFiles(items)); @@ -41,9 +51,10 @@ export const useFileUploader = ( useEffect(() => { const added = pendingIDs - .map(temp_uuid => statuses[temp_uuid] && statuses[temp_uuid].uuid) - .map(el => !!el && uploadedFiles[el]) - .filter(el => !!el && !files.some(file => file && file.id === el.id)); + .map(temp_uuid => path([temp_uuid, 'uuid'], statuses) as IUploadStatus['uuid']) + .filter(el => el) + .map(el => (path([String(el)], uploadedFiles) as IFile) || undefined) + .filter(el => !!el! && !files.some(file => file && file.id === el.id)); const newPending = pendingIDs.filter( temp_id => @@ -68,7 +79,7 @@ export const useFileUploader = ( }; export type FileUploader = ReturnType; -const FileUploaderContext = createContext(null); +const FileUploaderContext = createContext(undefined); export const FileUploaderProvider: FC<{ value: FileUploader; children }> = ({ value, diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 80230f2a..563bacb0 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect } from 'react'; -export const useCloseOnEscape = (onRequestClose: () => void, ignore_inputs = false) => { +export const useCloseOnEscape = (onRequestClose?: () => void, ignore_inputs = false) => { const onEscape = useCallback( event => { - if (event.key !== 'Escape') return; + if (event.key !== 'Escape' || !onRequestClose) return; if ( ignore_inputs && (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') diff --git a/src/utils/hooks/useCommentFormFormik.ts b/src/utils/hooks/useCommentFormFormik.ts index dd3fc139..cd222db5 100644 --- a/src/utils/hooks/useCommentFormFormik.ts +++ b/src/utils/hooks/useCommentFormFormik.ts @@ -12,7 +12,7 @@ const validationSchema = object().shape({ }); const onSuccess = ({ resetForm, setStatus, setSubmitting }: FormikHelpers) => ( - e: string + e?: string ) => { setSubmitting(false); diff --git a/src/utils/player.ts b/src/utils/player.ts index e7d747aa..82930dc4 100644 --- a/src/utils/player.ts +++ b/src/utils/player.ts @@ -32,15 +32,12 @@ export class PlayerClass { }); } - public current: number = 0; + public current = 0; + public total = 0; + public element = new Audio(); + public duration = 0; - public total: number = 0; - - public element: HTMLAudioElement = typeof Audio !== 'undefined' ? new Audio() : null; - - public duration: number = 0; - - public set = (src: string): void => { + public set = (src: string) => { this.element.src = src; }; diff --git a/src/utils/tag.ts b/src/utils/tag.ts index 2913ba95..2eb7fa49 100644 --- a/src/utils/tag.ts +++ b/src/utils/tag.ts @@ -3,11 +3,11 @@ import { ITag } from '~/redux/types'; export const separateTags = (tags: Partial[]): Partial[][] => (tags || []).reduce( (obj, tag) => - tag.title.substr(0, 1) === '/' ? [[...obj[0], tag], obj[1]] : [obj[0], [...obj[1], tag]], - [[], []] + tag?.title?.substr(0, 1) === '/' ? [[...obj[0], tag], obj[1]] : [obj[0], [...obj[1], tag]], + [[], []] as Partial[][] ); export const separateTagOptions = (options: string[]): string[][] => separateTags(options.map((title): Partial => ({ title }))).map(item => - item.map(({ title }) => title) + item.filter(tag => tag.title).map(({ title }) => title!) ); From b95d53791ca5e729e8912c75649223560b949e4d Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Thu, 4 Mar 2021 13:51:03 +0700 Subject: [PATCH 12/19] fixed node liking --- src/components/profile/ProfileDescription/index.tsx | 4 +++- src/redux/node/sagas.ts | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/profile/ProfileDescription/index.tsx b/src/components/profile/ProfileDescription/index.tsx index f3b43ea1..e083c47a 100644 --- a/src/components/profile/ProfileDescription/index.tsx +++ b/src/components/profile/ProfileDescription/index.tsx @@ -5,6 +5,8 @@ import { connect } from 'react-redux'; import { selectAuthProfile } from '~/redux/auth/selectors'; import { ProfileLoader } from '~/containers/profile/ProfileLoader'; import { Group } from '~/components/containers/Group'; +import markdown from '~/styles/common/markdown.module.scss'; +import classNames from 'classnames'; const mapStateToProps = state => ({ profile: selectAuthProfile(state), @@ -19,7 +21,7 @@ const ProfileDescriptionUnconnected: FC = ({ profile: { user, is_loading
{!!user?.description && ( )} diff --git a/src/redux/node/sagas.ts b/src/redux/node/sagas.ts index 8a629dfe..15cf8909 100644 --- a/src/redux/node/sagas.ts +++ b/src/redux/node/sagas.ts @@ -258,9 +258,9 @@ function* onEditSaga({ id }: ReturnType) { } function* onLikeSaga({ id }: ReturnType) { - try { - const { current }: ReturnType = yield select(selectNode); + const { current }: ReturnType = yield select(selectNode); + try { const count = current.like_count || 0; yield call(updateNodeEverywhere, { @@ -274,6 +274,7 @@ function* onLikeSaga({ id }: ReturnType) { yield call(updateNodeEverywhere, { ...current, is_liked: data.is_liked, + like_count: data.is_liked ? count + 1 : Math.max(count - 1, 0), }); } catch {} } From 0873e77ff4ec6aa24574bd117aa691cfc4e4acde Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Thu, 4 Mar 2021 13:57:00 +0700 Subject: [PATCH 13/19] fixed error handling --- src/redux/auth/sagas.ts | 18 +++++++++++------- src/redux/messages/sagas.ts | 8 ++++---- src/redux/node/sagas.ts | 4 ++-- src/redux/store.ts | 7 ++----- src/redux/uploads/sagas.ts | 2 +- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/redux/auth/sagas.ts b/src/redux/auth/sagas.ts index 47e26fa0..8b3ba089 100644 --- a/src/redux/auth/sagas.ts +++ b/src/redux/auth/sagas.ts @@ -234,7 +234,7 @@ function* requestRestoreCode({ field }: ReturnType) { yield put(authSetRestore({ user: data.user, code, is_loading: false })); yield put(modalShowDialog(DIALOGS.RESTORE_PASSWORD)); } catch (error) { - yield put(authSetRestore({ is_loading: false, error: error || ERRORS.CODE_IS_INVALID })); + yield put( + authSetRestore({ is_loading: false, error: error.message || ERRORS.CODE_IS_INVALID }) + ); yield put(modalShowDialog(DIALOGS.RESTORE_PASSWORD)); } } @@ -276,7 +278,9 @@ function* restorePassword({ password }: ReturnType) yield call(refreshUser); } catch (error) { - return yield put(authSetRestore({ is_loading: false, error: error || ERRORS.CODE_IS_INVALID })); + return yield put( + authSetRestore({ is_loading: false, error: error.message || ERRORS.CODE_IS_INVALID }) + ); } } @@ -286,7 +290,7 @@ function* getSocials() { const data: Unwrap = yield call(apiGetSocials); yield put(authSetSocials({ accounts: data.accounts })); } catch (error) { - yield put(authSetSocials({ error })); + yield put(authSetSocials({ error: error.message })); } finally { yield put(authSetSocials({ is_loading: false })); } @@ -303,7 +307,7 @@ function* dropSocial({ provider, id }: ReturnType) { yield call(getSocials); } catch (error) { - yield put(authSetSocials({ error })); + yield put(authSetSocials({ error: error.message })); } } @@ -355,7 +359,7 @@ function* loginWithSocial({ token }: ReturnType) { return; } } catch (error) { - yield put(userSetLoginError(error)); + yield put(userSetLoginError(error.message)); } } @@ -400,7 +404,7 @@ function* authRegisterSocial({ username, password }: ReturnType) { } } catch (error) { messagesSet({ - error: error || ERRORS.EMPTY_RESPONSE, + error: error.message || ERRORS.EMPTY_RESPONSE, }); } finally { yield put( @@ -110,7 +110,7 @@ function* sendMessage({ message, onSuccess }: ReturnType) { yield put(messagesSet({ messages: newMessages })); } catch (error) { messagesSet({ - error: error || ERRORS.EMPTY_RESPONSE, + error: error.message || ERRORS.EMPTY_RESPONSE, }); } finally { yield put( diff --git a/src/redux/node/sagas.ts b/src/redux/node/sagas.ts index 15cf8909..fa077989 100644 --- a/src/redux/node/sagas.ts +++ b/src/redux/node/sagas.ts @@ -99,7 +99,7 @@ function* onNodeSave({ node }: ReturnType) { return yield put(modalSetShown(false)); } catch (error) { - yield put(nodeSetSaveErrors({ error: error || ERRORS.CANT_SAVE_NODE })); + yield put(nodeSetSaveErrors({ error: error.message || ERRORS.CANT_SAVE_NODE })); } } @@ -214,7 +214,7 @@ function* onPostComment({ nodeId, comment, callback }: ReturnType) => { + api.interceptors.response.use(undefined, (error: AxiosError<{ error: string }>) => { if (error.response?.status === 401) { store.dispatch(authLogout()); } - console.log('Вот что случилось на сервере:', error); - throw new Error( - error?.response?.data?.message || error?.message || error?.response?.statusText - ); + throw new Error(error?.response?.data?.error || error?.message || error?.response?.statusText); }); return { store, persistor }; diff --git a/src/redux/uploads/sagas.ts b/src/redux/uploads/sagas.ts index ff1b68a9..480bcf9a 100644 --- a/src/redux/uploads/sagas.ts +++ b/src/redux/uploads/sagas.ts @@ -133,7 +133,7 @@ function* uploadFile({ file, temp_id, type, target, onSuccess, onFail }: IFileWi return yield put( uploadSetStatus(temp_id, { is_uploading: false, - error, + error: error.message, type, }) ); From 27665c462aadc228ac9181c1580e86ea0edd04f4 Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Thu, 4 Mar 2021 14:02:03 +0700 Subject: [PATCH 14/19] fixed error handling on profile --- src/redux/auth/sagas.ts | 14 +++++--------- src/redux/store.ts | 4 +++- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/redux/auth/sagas.ts b/src/redux/auth/sagas.ts index 8b3ba089..728677d9 100644 --- a/src/redux/auth/sagas.ts +++ b/src/redux/auth/sagas.ts @@ -50,14 +50,13 @@ import { selectAuthUser, } from './selectors'; import { OAUTH_EVENT_TYPES, Unwrap } from '../types'; -import { IAuthState } from './types'; import { REHYDRATE, RehydrateAction } from 'redux-persist'; import { selectModal } from '~/redux/modal/selectors'; -import { IModalState } from '~/redux/modal'; import { DIALOGS } from '~/redux/modal/constants'; import { ERRORS } from '~/constants/errors'; import { messagesSet } from '~/redux/messages/actions'; import { SagaIterator } from 'redux-saga'; +import { isEmpty } from 'ramda'; function* setTokenSaga({ token }: ReturnType) { localStorage.setItem('token', token); @@ -207,19 +206,16 @@ function* patchUser(payload: ReturnType) { const me: ReturnType = yield select(selectAuthUser); try { - const { user, errors }: Unwrap = yield call(apiUpdateUser, { + const { user }: Unwrap = yield call(apiUpdateUser, { user: payload.user, }); - if (errors && Object.keys(errors).length) { - yield put(authSetProfile({ patch_errors: errors })); - return; - } - yield put(authSetUser({ ...me, ...user })); yield put(authSetProfile({ user: { ...me, ...user }, tab: 'profile' })); } catch (error) { - return; + if (isEmpty(error.response.data.errors)) return; + + yield put(authSetProfile({ patch_errors: error.response.data.errors })); } } diff --git a/src/redux/store.ts b/src/redux/store.ts index 61e2393e..eb9c60ff 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -136,7 +136,9 @@ export function configureStore(): { store.dispatch(authLogout()); } - throw new Error(error?.response?.data?.error || error?.message || error?.response?.statusText); + error.message = error?.response?.data?.error || error?.response?.statusText || error.message; + + throw error; }); return { store, persistor }; From b91ff984913854afed8f398ea5a85fa5256c6a7c Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Fri, 5 Mar 2021 09:22:21 +0700 Subject: [PATCH 15/19] fixed api call for code request --- src/redux/auth/api.ts | 7 +++---- src/redux/auth/sagas.ts | 4 ++-- src/redux/node/api.ts | 12 ------------ 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/redux/auth/api.ts b/src/redux/auth/api.ts index 25f38508..02869974 100644 --- a/src/redux/auth/api.ts +++ b/src/redux/auth/api.ts @@ -42,11 +42,10 @@ export const apiAuthGetUpdates = ({ exclude_dialogs, last }: ApiAuthGetUpdatesRe export const apiUpdateUser = ({ user }: ApiUpdateUserRequest) => api.patch(API.USER.ME, user).then(cleanResult); -export const apiRequestRestoreCode = ({ field }): Promise> => +export const apiRequestRestoreCode = ({ field }: { field: string }) => api - .post(API.USER.REQUEST_CODE(), { field }) - .then(resultMiddleware) - .catch(errorMiddleware); + .post<{}>(API.USER.REQUEST_CODE(), { field }) + .then(cleanResult); export const apiCheckRestoreCode = ({ code }: ApiCheckRestoreCodeRequest) => api.get(API.USER.REQUEST_CODE(code)).then(cleanResult); diff --git a/src/redux/auth/sagas.ts b/src/redux/auth/sagas.ts index 728677d9..f0cbebd5 100644 --- a/src/redux/auth/sagas.ts +++ b/src/redux/auth/sagas.ts @@ -75,8 +75,8 @@ function* sendLoginRequestSaga({ username, password }: ReturnType api.post(API.NODE.SAVE, node).then(cleanResult); -export const getNodes = ({ - from, - access, -}: { - from?: string; - access: string; -}): Promise> => - api - .get(API.NODE.GET, configWithToken(access, { params: { from } })) - .then(resultMiddleware) - .catch(errorMiddleware); - export const getNodeDiff = ({ start, end, From 455c7ad90d78c3514573e54edff78ea53fffd626 Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Fri, 5 Mar 2021 09:51:38 +0700 Subject: [PATCH 16/19] fixed social registration on login --- .../comment/CommentTextBlock/styles.module.scss | 11 ----------- src/redux/auth/sagas.ts | 17 ++++++++++------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/components/comment/CommentTextBlock/styles.module.scss b/src/components/comment/CommentTextBlock/styles.module.scss index f42e2ef7..ec47d8fd 100644 --- a/src/components/comment/CommentTextBlock/styles.module.scss +++ b/src/components/comment/CommentTextBlock/styles.module.scss @@ -28,15 +28,4 @@ :global(.green) { color: $wisegreen; } - - //&:last-child { - // p { - // &::after { - // content: ''; - // display: inline-flex; - // height: 1em; - // width: 150px; - // } - // } - //} } diff --git a/src/redux/auth/sagas.ts b/src/redux/auth/sagas.ts index f0cbebd5..8f029a01 100644 --- a/src/redux/auth/sagas.ts +++ b/src/redux/auth/sagas.ts @@ -57,6 +57,7 @@ import { ERRORS } from '~/constants/errors'; import { messagesSet } from '~/redux/messages/actions'; import { SagaIterator } from 'redux-saga'; import { isEmpty } from 'ramda'; +import { AxiosError } from 'axios'; function* setTokenSaga({ token }: ReturnType) { localStorage.setItem('token', token); @@ -341,13 +342,6 @@ function* loginWithSocial({ token }: ReturnType) { token, }); - // Backend asks us for account registration - if (data?.needs_register) { - yield put(authSetRegisterSocial({ token })); - yield put(modalShowDialog(DIALOGS.LOGIN_SOCIAL_REGISTER)); - return; - } - if (data.token) { yield put(authSetToken(data.token)); yield call(refreshUser); @@ -355,6 +349,15 @@ function* loginWithSocial({ token }: ReturnType) { return; } } catch (error) { + const data = (error as AxiosError<{ needs_register: boolean }>).response?.data; + + // Backend asks us for account registration + if (data?.needs_register) { + yield put(authSetRegisterSocial({ token })); + yield put(modalShowDialog(DIALOGS.LOGIN_SOCIAL_REGISTER)); + return; + } + yield put(userSetLoginError(error.message)); } } From a7d890aeecbd8e6e2bcb71e2faf675e67fa4731d Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Fri, 5 Mar 2021 11:32:50 +0700 Subject: [PATCH 17/19] fixed error translation --- src/components/input/InputText/index.tsx | 7 ++++-- src/constants/errors.ts | 2 ++ src/containers/dialogs/LoginDialog/index.tsx | 8 +++++-- src/redux/auth/sagas.ts | 23 ++++++++++++++------ src/utils/hooks/useTranslatedError.ts | 17 +++++++++++++++ 5 files changed, 46 insertions(+), 11 deletions(-) create mode 100644 src/utils/hooks/useTranslatedError.ts diff --git a/src/components/input/InputText/index.tsx b/src/components/input/InputText/index.tsx index 5495c068..1d4f4153 100644 --- a/src/components/input/InputText/index.tsx +++ b/src/components/input/InputText/index.tsx @@ -4,6 +4,7 @@ import styles from '~/styles/common/inputs.module.scss'; import { Icon } from '~/components/input/Icon'; import { IInputTextProps } from '~/redux/types'; import { LoaderCircle } from '~/components/input/LoaderCircle'; +import { useTranslatedError } from '~/utils/hooks/useTranslatedError'; const InputText: FC = ({ wrapperClassName, @@ -36,6 +37,8 @@ const InputText: FC = ({ const onFocus = useCallback(() => setFocused(true), []); const onBlur = useCallback(() => setFocused(false), []); + const translatedError = useTranslatedError(error); + useEffect(() => { if (onRef) onRef(inner_ref); }, [inner_ref, onRef]); @@ -86,9 +89,9 @@ const InputText: FC = ({
)} - {error && ( + {!!translatedError && (
- {error} + {translatedError}
)} diff --git a/src/constants/errors.ts b/src/constants/errors.ts index fbb68c4e..bf88f347 100644 --- a/src/constants/errors.ts +++ b/src/constants/errors.ts @@ -42,6 +42,7 @@ export const ERRORS = { CANT_RESTORE_COMMENT: 'CantRestoreComment', MESSAGE_NOT_FOUND: 'MessageNotFound', COMMENT_TOO_LONG: 'CommentTooLong', + NETWORK_ERROR: 'Network Error', }; export const ERROR_LITERAL = { @@ -89,4 +90,5 @@ export const ERROR_LITERAL = { [ERRORS.CANT_RESTORE_COMMENT]: 'Не удалось восстановить комментарий', [ERRORS.MESSAGE_NOT_FOUND]: 'Сообщение не найдено', [ERRORS.COMMENT_TOO_LONG]: 'Комментарий слишком длинный', + [ERRORS.NETWORK_ERROR]: 'Подключение не удалось', }; diff --git a/src/containers/dialogs/LoginDialog/index.tsx b/src/containers/dialogs/LoginDialog/index.tsx index 23792200..9aa7d12b 100644 --- a/src/containers/dialogs/LoginDialog/index.tsx +++ b/src/containers/dialogs/LoginDialog/index.tsx @@ -1,4 +1,4 @@ -import React, { FC, FormEvent, useCallback, useEffect, useState } from 'react'; +import React, { FC, FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { connect } from 'react-redux'; import { DIALOGS, IDialogProps } from '~/redux/modal/constants'; import { useCloseOnEscape } from '~/utils/hooks'; @@ -18,6 +18,8 @@ import { pick } from 'ramda'; import { LoginDialogButtons } from '~/containers/dialogs/LoginDialogButtons'; import { OAUTH_EVENT_TYPES } from '~/redux/types'; import { DialogTitle } from '~/components/dialogs/DialogTitle'; +import { ERROR_LITERAL } from '~/constants/errors'; +import { useTranslatedError } from '~/utils/hooks/useTranslatedError'; const mapStateToProps = state => ({ ...pick(['error', 'is_registering'], selectAuthLogin(state)), @@ -90,12 +92,14 @@ const LoginDialogUnconnected: FC = ({ useCloseOnEscape(onRequestClose); + const translatedError = useTranslatedError(error); + return (
} backdrop={
} diff --git a/src/redux/auth/sagas.ts b/src/redux/auth/sagas.ts index 8f029a01..40d8912b 100644 --- a/src/redux/auth/sagas.ts +++ b/src/redux/auth/sagas.ts @@ -349,10 +349,14 @@ function* loginWithSocial({ token }: ReturnType) { return; } } catch (error) { - const data = (error as AxiosError<{ needs_register: boolean }>).response?.data; + const { dialog }: ReturnType = yield select(selectModal); + const data = (error as AxiosError<{ + needs_register: boolean; + errors: Record<'username' | 'password', string>; + }>).response?.data; // Backend asks us for account registration - if (data?.needs_register) { + if (dialog !== DIALOGS.LOGIN_SOCIAL_REGISTER && data?.needs_register) { yield put(authSetRegisterSocial({ token })); yield put(modalShowDialog(DIALOGS.LOGIN_SOCIAL_REGISTER)); return; @@ -391,11 +395,6 @@ function* authRegisterSocial({ username, password }: ReturnType; + }>).response?.data; + + if (data?.errors) { + yield put(authSetRegisterSocialErrors(data.errors)); + return; + } + yield put(authSetRegisterSocial({ error: error.message })); } } diff --git a/src/utils/hooks/useTranslatedError.ts b/src/utils/hooks/useTranslatedError.ts new file mode 100644 index 00000000..22ef367f --- /dev/null +++ b/src/utils/hooks/useTranslatedError.ts @@ -0,0 +1,17 @@ +import { ERROR_LITERAL } from '~/constants/errors'; +import { has } from 'ramda'; +import { useMemo } from 'react'; + +export const useTranslatedError = (error: string | undefined) => { + return useMemo(() => { + if (!error) { + return ''; + } + + if (!has(error, ERROR_LITERAL)) { + return error; + } + + return ERROR_LITERAL[error]; + }, [error]); +}; From 154a35c957880bd29f8b968012a201749f250813 Mon Sep 17 00:00:00 2001 From: Fedor Katurov Date: Fri, 5 Mar 2021 12:16:17 +0700 Subject: [PATCH 18/19] added hotkeys for textarea formatter --- .../CommentFormFormatButtons/index.tsx | 47 ++++++++++++++- src/utils/hooks/useFormatWrapper.ts | 60 +++++++++++-------- 2 files changed, 78 insertions(+), 29 deletions(-) diff --git a/src/components/comment/CommentFormFormatButtons/index.tsx b/src/components/comment/CommentFormFormatButtons/index.tsx index de39bbe0..be214c55 100644 --- a/src/components/comment/CommentFormFormatButtons/index.tsx +++ b/src/components/comment/CommentFormFormatButtons/index.tsx @@ -1,7 +1,7 @@ -import React, { FC, useCallback } from 'react'; +import React, { FC, useCallback, useEffect } from 'react'; import { ButtonGroup } from '~/components/input/ButtonGroup'; import { Button } from '~/components/input/Button'; -import { useFormatWrapper } from '~/utils/hooks/useFormatWrapper'; +import { useFormatWrapper, wrapTextInsideInput } from '~/utils/hooks/useFormatWrapper'; import styles from './styles.module.scss'; interface IProps { @@ -15,10 +15,51 @@ const CommentFormFormatButtons: FC = ({ element, handler }) => { [element, handler] ); + const wrapBold = useCallback( + event => { + event.preventDefault(); + wrapTextInsideInput(element, '**', '**', handler); + }, + [wrap, handler] + ); + + const wrapItalic = useCallback( + event => { + event.preventDefault(); + wrapTextInsideInput(element, '*', '*', handler); + }, + [wrap, handler] + ); + + const onKeyPress = useCallback( + (event: KeyboardEvent) => { + if (!event.ctrlKey) return; + + if (event.code === 'KeyB') { + wrapBold(event); + } + + if (event.code === 'KeyI') { + wrapItalic(event); + } + }, + [wrapBold, wrapItalic] + ); + + useEffect(() => { + if (!element) { + return; + } + + element.addEventListener('keypress', onKeyPress); + + return () => element.removeEventListener('keypress', onKeyPress); + }, [element, onKeyPress]); + return (