1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-04-25 12:56:41 +07:00

Merge branch 'develop' into 23-labs

This commit is contained in:
Fedor Katurov 2021-03-05 17:31:33 +07:00
commit 44bbc4cd4c
147 changed files with 3292 additions and 2627 deletions

View file

@ -1,131 +1,72 @@
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<IResultWithStatus<{ token: string; status?: number }>> =>
export const apiUserLogin = ({ username, password }: ApiUserLoginRequest) =>
api
.post(API.USER.LOGIN, { username, password })
.then(resultMiddleware)
.catch(errorMiddleware)
.then(userLoginTransform);
.post<ApiUserLoginResult>(API.USER.LOGIN, { username, password })
.then(cleanResult);
export const apiAuthGetUser = ({ access }): Promise<IResultWithStatus<{ user: IUser }>> =>
api
.get(API.USER.ME, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiAuthGetUser = () => api.get<ApiAuthGetUserResult>(API.USER.ME).then(cleanResult);
export const apiAuthGetUserProfile = ({
access,
username,
}): Promise<IResultWithStatus<{ user: IUser }>> =>
api
.get(API.USER.PROFILE(username), configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiAuthGetUserProfile = ({ username }: ApiAuthGetUserProfileRequest) =>
api.get<ApiAuthGetUserProfileResult>(API.USER.PROFILE(username)).then(cleanResult);
export const apiAuthGetUpdates = ({
access,
exclude_dialogs,
last,
}): Promise<IResultWithStatus<{
notifications: INotification[];
boris: { commented_at: string };
}>> =>
export const apiAuthGetUpdates = ({ exclude_dialogs, last }: ApiAuthGetUpdatesRequest) =>
api
.get(API.USER.GET_UPDATES, configWithToken(access, { params: { exclude_dialogs, last } }))
.then(resultMiddleware)
.catch(errorMiddleware);
.get<ApiAuthGetUpdatesResult>(API.USER.GET_UPDATES, { params: { exclude_dialogs, last } })
.then(cleanResult);
export const apiUpdateUser = ({ access, user }): Promise<IResultWithStatus<{ user: IUser }>> =>
api
.patch(API.USER.ME, user, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiUpdateUser = ({ user }: ApiUpdateUserRequest) =>
api.patch<ApiUpdateUserResult>(API.USER.ME, user).then(cleanResult);
export const apiRequestRestoreCode = ({ field }): Promise<IResultWithStatus<{}>> =>
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 }): Promise<IResultWithStatus<{}>> =>
api
.get(API.USER.REQUEST_CODE(code))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiCheckRestoreCode = ({ code }: ApiCheckRestoreCodeRequest) =>
api.get<ApiCheckRestoreCodeResult>(API.USER.REQUEST_CODE(code)).then(cleanResult);
export const apiRestoreCode = ({ code, password }): Promise<IResultWithStatus<{}>> =>
export const apiRestoreCode = ({ code, password }: ApiRestoreCodeRequest) =>
api
.post(API.USER.REQUEST_CODE(code), { password })
.then(resultMiddleware)
.catch(errorMiddleware);
.post<ApiRestoreCodeResult>(API.USER.REQUEST_CODE(code), { password })
.then(cleanResult);
export const apiGetSocials = ({
access,
}: {
access: string;
}): Promise<IResultWithStatus<{
accounts: ISocialAccount[];
}>> =>
api
.get(API.USER.GET_SOCIALS, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiGetSocials = () =>
api.get<ApiGetSocialsResult>(API.USER.GET_SOCIALS).then(cleanResult);
export const apiDropSocial = ({
access,
id,
provider,
}: {
access: string;
id: string;
provider: string;
}): Promise<IResultWithStatus<{
accounts: ISocialAccount[];
}>> =>
api
.delete(API.USER.DROP_SOCIAL(provider, id), configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiDropSocial = ({ id, provider }: ApiDropSocialRequest) =>
api.delete<ApiDropSocialResult>(API.USER.DROP_SOCIAL(provider, id)).then(cleanResult);
export const apiAttachSocial = ({
access,
token,
}: {
access: string;
token: string;
}): Promise<IResultWithStatus<{
account: ISocialAccount;
}>> =>
export const apiAttachSocial = ({ token }: ApiAttachSocialRequest) =>
api
.post(API.USER.ATTACH_SOCIAL, { token }, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
.post<ApiAttachSocialResult>(API.USER.ATTACH_SOCIAL, { token })
.then(cleanResult);
export const apiLoginWithSocial = ({
token,
username,
password,
}: {
token: string;
username?: string;
password?: string;
}): Promise<IResultWithStatus<{
token: string;
error: string;
errors: Record<string, string>;
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<ApiLoginWithSocialResult>(API.USER.LOGIN_WITH_SOCIAL, { token, username, password })
.then(cleanResult);

View file

@ -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 {

View file

@ -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,
},
};

View file

@ -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 { 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';
import { AxiosError } from 'axios';
export function* reqWrapper(requestAction, props = {}): ReturnType<typeof requestAction> {
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<typeof authSetToken>) {
localStorage.setItem('token', token);
}
function* sendLoginRequestSaga({ username, password }: ReturnType<typeof userSendLoginRequest>) {
if (!username || !password) return;
const {
error,
data: { token, user },
}: IResultWithStatus<{ token: string; user: IUser }> = yield call(apiUserLogin, {
username,
password,
});
try {
const { token, user }: Unwrap<typeof apiUserLogin> = 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 (error) {
yield put(userSetLoginError(error.message));
}
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<typeof apiAuthGetUser> = 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<typeof gotAuthPostMessage>) {
yield put(authSetToken(token));
yield call(refreshUser);
const { is_shown, dialog }: IModalState = yield select(selectModal);
const { is_shown, dialog }: ReturnType<typeof selectModal> = 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<typeof authLoadProfile>) {
function* loadProfile({ username }: ReturnType<typeof authLoadProfile>): SagaIterator<boolean> {
yield put(authSetProfile({ is_loading: true }));
const {
error,
data: { user },
} = yield call(reqWrapper, apiAuthGetUserProfile, { username });
try {
const { user }: Unwrap<typeof apiAuthGetUserProfile> = 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<typeof authOpenProfile>) {
yield put(modalShowDialog(DIALOGS.PROFILE));
yield put(authSetProfile({ tab }));
const success: boolean = yield call(loadProfile, authLoadProfile(username));
const success: Unwrap<typeof loadProfile> = yield call(loadProfile, authLoadProfile(username));
if (!success) {
return yield put(modalSetShown(false));
@ -171,42 +153,41 @@ function* openProfile({ username, tab = 'profile' }: ReturnType<typeof authOpenP
}
function* getUpdates() {
const user: ReturnType<typeof selectAuthUser> = yield select(selectAuthUser);
try {
const user: ReturnType<typeof selectAuthUser> = 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 { error, data }: Unwrap<ReturnType<typeof apiAuthGetUpdates>> = yield call(
reqWrapper,
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,
})
const modal: ReturnType<typeof selectModal> = yield select(selectModal);
const profile: ReturnType<typeof selectAuthProfile> = yield select(selectAuthProfile);
const { last, boris_commented_at }: ReturnType<typeof selectAuthUpdates> = yield select(
selectAuthUpdates
);
}
const exclude_dialogs =
modal.is_shown && modal.dialog === DIALOGS.PROFILE && profile.user?.id ? profile.user.id : 0;
if (data.boris && data.boris.commented_at && boris_commented_at !== data.boris.commented_at) {
yield put(
authSetUpdates({
boris_commented_at: data.boris.commented_at,
})
);
}
const data: Unwrap<typeof apiAuthGetUpdates> = yield call(apiAuthGetUpdates, {
exclude_dialogs,
last: last || user.last_seen_messages,
});
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,
})
);
}
} catch (error) {}
}
function* startPollingSaga() {
@ -219,148 +200,137 @@ function* startPollingSaga() {
function* setLastSeenMessages({ last_seen_messages }: ReturnType<typeof authSetLastSeenMessages>) {
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<typeof authPatchUser>) {
const me = yield select(selectAuthUser);
function* patchUser(payload: ReturnType<typeof authPatchUser>) {
const me: ReturnType<typeof selectAuthUser> = yield select(selectAuthUser);
const { error, data } = yield call(reqWrapper, apiUpdateUser, { user });
try {
const { user }: Unwrap<typeof apiUpdateUser> = yield call(apiUpdateUser, {
user: payload.user,
});
if (error || !data.user || data.errors) {
return yield put(authSetProfile({ patch_errors: data.errors }));
yield put(authSetUser({ ...me, ...user }));
yield put(authSetProfile({ user: { ...me, ...user }, tab: 'profile' }));
} catch (error) {
if (isEmpty(error.response.data.errors)) return;
yield put(authSetProfile({ patch_errors: error.response.data.errors }));
}
yield put(authSetUser({ ...me, ...data.user }));
yield put(authSetProfile({ user: { ...me, ...data.user }, tab: 'profile' }));
}
function* requestRestoreCode({ field }: ReturnType<typeof authRequestRestoreCode>) {
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: error.message }));
}
yield put(authSetRestore({ is_loading: false, is_succesfull: true }));
}
function* showRestoreModal({ code }: ReturnType<typeof authShowRestoreModal>) {
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: null, is_loading: true }));
yield put(authSetRestore({ user: undefined, is_loading: true }));
const { error, data } = yield call(apiCheckRestoreCode, { code });
const data: Unwrap<typeof apiCheckRestoreCode> = yield call(apiCheckRestoreCode, { code });
if (data.error || error || !data.user) {
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: data.error || error || ERRORS.CODE_IS_INVALID })
authSetRestore({ is_loading: false, error: error.message || ERRORS.CODE_IS_INVALID })
);
return yield put(modalShowDialog(DIALOGS.RESTORE_PASSWORD));
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<typeof authRestorePassword>) {
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 }: ReturnType<typeof selectAuthRestore> = 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 { error, data } = yield call(apiRestoreCode, { code, password });
const data: Unwrap<typeof apiRestoreCode> = yield call(apiRestoreCode, { code, password });
if (data.error || error || !data.user || !data.token) {
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: data.error || error || ERRORS.CODE_IS_INVALID })
authSetRestore({ is_loading: false, error: error.message || 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<ReturnType<typeof apiGetSocials>> = 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<typeof apiGetSocials> = yield call(apiGetSocials);
yield put(authSetSocials({ accounts: data.accounts }));
} catch (error) {
yield put(authSetSocials({ error: error.message }));
} finally {
yield put(authSetSocials({ is_loading: false }));
}
}
// TODO: start from here
function* dropSocial({ provider, id }: ReturnType<typeof authDropSocial>) {
try {
yield put(authSetSocials({ error: '' }));
const { error }: Unwrap<ReturnType<typeof apiDropSocial>> = 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: error.message }));
}
}
function* attachSocial({ token }: ReturnType<typeof authAttachSocial>) {
if (!token) return;
try {
if (!token) return;
yield put(authSetSocials({ error: '', is_loading: true }));
const { data, error }: Unwrap<ReturnType<typeof apiAttachSocial>> = yield call(
reqWrapper,
apiAttachSocial,
{ token }
);
if (error) {
throw new Error(error);
}
const data: Unwrap<typeof apiAttachSocial> = yield call(apiAttachSocial, {
token,
});
const {
socials: { accounts },
}: ReturnType<typeof selectAuthProfile> = 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,21 +338,9 @@ function* loginWithSocial({ token }: ReturnType<typeof authLoginWithSocial>) {
try {
yield put(userSetLoginError(''));
const {
data,
error,
}: Unwrap<ReturnType<typeof apiLoginWithSocial>> = yield call(apiLoginWithSocial, { token });
// Backend asks us for account registration
if (data?.needs_register) {
yield put(authSetRegisterSocial({ token }));
yield put(modalShowDialog(DIALOGS.LOGIN_SOCIAL_REGISTER));
return;
}
if (error) {
throw new Error(error);
}
const data: Unwrap<typeof apiLoginWithSocial> = yield call(apiLoginWithSocial, {
token,
});
if (data.token) {
yield put(authSetToken(data.token));
@ -390,8 +348,21 @@ function* loginWithSocial({ token }: ReturnType<typeof authLoginWithSocial>) {
yield put(modalSetShown(false));
return;
}
} catch (e) {
yield put(userSetLoginError(e.message));
} catch (error) {
const { dialog }: ReturnType<typeof selectModal> = 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 (dialog !== DIALOGS.LOGIN_SOCIAL_REGISTER && data?.needs_register) {
yield put(authSetRegisterSocial({ token }));
yield put(modalShowDialog(DIALOGS.LOGIN_SOCIAL_REGISTER));
return;
}
yield put(userSetLoginError(error.message));
}
}
@ -414,24 +385,15 @@ function* authRegisterSocial({ username, password }: ReturnType<typeof authSendR
try {
yield put(authSetRegisterSocial({ error: '' }));
const { token }: Unwrap<ReturnType<typeof selectAuthRegisterSocial>> = yield select(
const { token }: ReturnType<typeof selectAuthRegisterSocial> = yield select(
selectAuthRegisterSocial
);
const { data, error }: Unwrap<ReturnType<typeof apiLoginWithSocial>> = yield call(
apiLoginWithSocial,
{
token,
username,
password,
}
);
if (data?.errors) {
yield put(authSetRegisterSocialErrors(data.errors));
} else if (data?.error) {
throw new Error(error);
}
const data: Unwrap<typeof apiLoginWithSocial> = yield call(apiLoginWithSocial, {
token,
username,
password,
});
if (data.token) {
yield put(authSetToken(data.token));
@ -439,8 +401,18 @@ function* authRegisterSocial({ username, password }: ReturnType<typeof authSendR
yield put(modalSetShown(false));
return;
}
} catch (e) {
yield put(authSetRegisterSocial({ error: e.message }));
} catch (error) {
const data = (error as AxiosError<{
needs_register: boolean;
errors: Record<'username' | 'password', string>;
}>).response?.data;
if (data?.errors) {
yield put(authSetRegisterSocialErrors(data.errors));
return;
}
yield put(authSetRegisterSocial({ error: error.message }));
}
}
@ -449,6 +421,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);

View file

@ -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;

View file

@ -1,13 +1,18 @@
import { IResultWithStatus } from '~/redux/types';
import { HTTP_RESPONSES } from '~/utils/api';
export const userLoginTransform = ({ status, data, error }: IResultWithStatus<any>): IResultWithStatus<any> => {
export const userLoginTransform = ({
status,
data,
error,
}: IResultWithStatus<any>): IResultWithStatus<any> => {
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 || 'Неизвестная ошибка' };

View file

@ -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<string, string>;
socials: {
@ -65,7 +65,7 @@ export type IAuthState = Readonly<{
restore: {
code: string;
user: Pick<IUser, 'username' | 'photo'>;
user?: Pick<IUser, 'username' | 'photo'>;
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<IUser> };
export type ApiUpdateUserResult = { user: IUser; errors: Record<Partial<keyof IUser>, 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<string, string>;
needs_register: boolean;
};

View file

@ -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<IBorisState['stats']['git']> => Promise.resolve(git);
export const getBorisGitStats = () => Promise.resolve<IBorisState['stats']['git']>(git);
export const getBorisBackendStats = (): Promise<IResultWithStatus<IStatBackend>> =>
api
.get(API.BORIS.GET_BACKEND_STATS)
.then(resultMiddleware)
.catch(errorMiddleware);
export const getBorisBackendStats = () =>
api.get<IStatBackend>(API.BORIS.GET_BACKEND_STATS).then(cleanResult);

View file

@ -31,7 +31,7 @@ export type IStatBackend = {
export type IBorisState = Readonly<{
stats: {
git: Partial<IStatGitRow>[];
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,
},
};

View file

@ -5,17 +5,17 @@ import { getBorisGitStats, getBorisBackendStats } from './api';
import { Unwrap } from '../types';
function* loadStats() {
yield put(borisSetStats({ is_loading: true }));
try {
const git: Unwrap<ReturnType<typeof getBorisGitStats>> = yield call(getBorisGitStats);
const backend: Unwrap<ReturnType<typeof getBorisBackendStats>> = yield call(
getBorisBackendStats
);
yield put(borisSetStats({ is_loading: true }));
yield put(borisSetStats({ git, backend: backend.data, is_loading: false }));
const git: Unwrap<typeof getBorisGitStats> = yield call(getBorisGitStats);
const backend: Unwrap<typeof getBorisBackendStats> = yield call(getBorisBackendStats);
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 }));
}
}

View file

@ -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<IResultWithStatus<INode>> =>
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<typeof flowSetCellView> & { access: string }): Promise<IResultWithStatus<{
is_liked: INode['is_liked'];
}>> =>
export const postCellView = ({ id, flow }: PostCellViewRequest) =>
api
.post(API.NODE.SET_CELL_VIEW(id), { flow }, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
.post<PostCellViewResult>(API.NODE.SET_CELL_VIEW(id), { flow })
.then(cleanResult);
export const getSearchResults = ({
access,
text,
skip = 0,
}: IFlowState['search'] & {
access: string;
skip: number;
}): Promise<IResultWithStatus<{ nodes: INode[]; total: number }>> =>
export const getSearchResults = ({ text, skip = 0 }: GetSearchResultsRequest) =>
api
.get(API.SEARCH.NODES, configWithToken(access, { params: { text, skip } }))
.then(resultMiddleware)
.catch(errorMiddleware);
.get<GetSearchResultsResult>(API.SEARCH.NODES, { params: { text, skip } })
.then(cleanResult);

View file

@ -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);

View file

@ -1,182 +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 { reqWrapper } 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(reqWrapper, 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<typeof flowSetCellView>) {
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 });
// 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(reqWrapper, 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<typeof flowChangeSearch>) {
yield put(
flowSetSearch({
...search,
is_loading: !!search.text,
})
);
if (!search.text) return;
yield delay(500);
const { data, error }: Unwrap<ReturnType<typeof getSearchResults>> = yield call(
reqWrapper,
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<typeof selectFlowNodes> = yield select(selectFlowNodes);
if (stored.length) {
hideLoader();
}
yield put(flowSetFlow({ is_loading: true }));
const {
before = [],
after = [],
heroes = [],
recent = [],
updated = [],
}: Unwrap<typeof getNodeDiff> = 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<typeof flowSetCellView>) {
try {
const nodes: ReturnType<typeof selectFlowNodes> = 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<typeof selectFlowNodes> = 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<typeof getNodeDiff> = 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<typeof flowChangeSearch>) {
try {
yield put(
flowSetSearch({
...search,
is_loading: !!search.text,
})
);
if (!search.text) return;
yield delay(500);
const data: Unwrap<typeof getSearchResults> = 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<typeof selectFlow> = yield select(selectFlow);
const { search }: ReturnType<typeof selectFlow> = yield select(selectFlow);
const {
result,
delay,
}: { result: Unwrap<ReturnType<typeof getSearchResults>>; delay: any } = yield race({
result: call(reqWrapper, getSearchResults, {
...search,
skip: search.results.length,
}),
delay: take(FLOW_ACTIONS.CHANGE_SEARCH),
});
const { result, delay }: { result: Unwrap<typeof getSearchResults>; 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() {

10
src/redux/flow/types.ts Normal file
View file

@ -0,0 +1,10 @@
import { INode } from '~/redux/types';
export type GetSearchResultsRequest = {
text: string;
skip?: number;
};
export type GetSearchResultsResult = {
nodes: INode[];
total: number;
};

View file

@ -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<IResultWithStatus<{ messages: IMessage[] }>> =>
export const apiGetUserMessages = ({ username, after, before }: ApiGetUserMessagesRequest) =>
api
.get(API.USER.MESSAGES(username), configWithToken(access, { params: { after, before } }))
.then(resultMiddleware)
.catch(errorMiddleware);
.get<ApiGetUserMessagesResponse>(API.USER.MESSAGES(username), {
params: { after, before },
})
.then(cleanResult);
export const apiMessagesSendMessage = ({
access,
username,
message,
}): Promise<IResultWithStatus<{ message: IMessage }>> =>
export const apiSendMessage = ({ username, message }: ApiSendMessageRequest) =>
api
.post(API.USER.MESSAGE_SEND(username), { message }, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
.post<ApiSendMessageResult>(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<IResultWithStatus<{ message: IMessage }>> =>
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<ApiDeleteMessageResult>(API.USER.MESSAGE_DELETE(username, id), {
params: { is_locked },
})
.then(cleanResult);

View file

@ -12,7 +12,7 @@ export interface IMessagesState {
const INITIAL_STATE: IMessagesState = {
is_loading_messages: true,
is_sending_messages: false,
error: null,
error: '',
messages: [],
};

View file

@ -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 { reqWrapper } from '~/redux/auth/sagas';
import {
messagesDeleteMessage,
messagesGetMessages,
@ -25,191 +20,188 @@ import { selectMessages } from '~/redux/messages/selectors';
import { sortCreatedAtDesc } from '~/utils/date';
function* getMessages({ username }: ReturnType<typeof messagesGetMessages>) {
const { messages }: ReturnType<typeof selectMessages> = yield select(selectMessages);
try {
const { messages }: ReturnType<typeof selectMessages> = 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<ReturnType<typeof apiMessagesGetUserMessages>> = yield call(
reqWrapper,
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<typeof apiGetUserMessages> = yield call(apiGetUserMessages, {
username,
});
const { notifications }: ReturnType<typeof selectAuthUpdates> = 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<typeof selectAuthUpdates> = 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.message || ERRORS.EMPTY_RESPONSE,
});
} finally {
yield put(
messagesSet({
is_loading_messages: false,
})
);
}
}
function* sendMessage({ message, onSuccess }: ReturnType<typeof messagesSendMessage>) {
const username: ReturnType<typeof selectAuthProfileUsername> = yield select(
selectAuthProfileUsername
);
try {
const username: ReturnType<typeof selectAuthProfileUsername> = 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<ReturnType<typeof apiMessagesSendMessage>> = yield call(
reqWrapper,
apiMessagesSendMessage,
{
const data: Unwrap<typeof apiSendMessage> = yield call(apiSendMessage, {
username,
message,
});
const { user }: ReturnType<typeof selectAuthProfile> = 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<typeof selectMessages> = yield select(selectMessages);
const { user }: ReturnType<typeof selectAuthProfile> = 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<typeof selectMessages> = yield select(selectMessages);
if (message.id > 0) {
// modified
onSuccess();
} catch (error) {
messagesSet({
error: error.message || 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<typeof messagesDeleteMessage>) {
const username: ReturnType<typeof selectAuthProfileUsername> = yield select(
selectAuthProfileUsername
);
try {
const username: ReturnType<typeof selectAuthProfileUsername> = 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<ReturnType<typeof apiMessagesDeleteMessage>> = yield call(
reqWrapper,
apiMessagesDeleteMessage,
{
const data: Unwrap<typeof apiDeleteMessage> = yield call(apiDeleteMessage, {
username,
id,
is_locked,
}
);
});
if (error || !data.message) {
return yield put(
const currentUsername: ReturnType<typeof selectAuthProfileUsername> = yield select(
selectAuthProfileUsername
);
if (currentUsername !== username) {
return yield put(messagesSet({ is_sending_messages: false }));
}
const { messages }: ReturnType<typeof selectMessages> = 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.message || ERRORS.EMPTY_RESPONSE,
});
} finally {
yield put(
messagesSet({
is_loading_messages: false,
})
);
}
const currentUsername: ReturnType<typeof selectAuthProfileUsername> = yield select(
selectAuthProfileUsername
);
if (currentUsername !== username) {
return yield put(messagesSet({ is_sending_messages: false }));
}
const { messages }: ReturnType<typeof selectMessages> = yield select(selectMessages);
yield put(
messagesSet({
is_sending_messages: false,
messages: messages.map(item => (item.id === id ? data.message : item)),
})
);
}
function* refreshMessages({}: ReturnType<typeof messagesRefreshMessages>) {
const username: ReturnType<typeof selectAuthProfileUsername> = yield select(
selectAuthProfileUsername
);
try {
const username: ReturnType<typeof selectAuthProfileUsername> = yield select(
selectAuthProfileUsername
);
if (!username) return;
if (!username) return;
const { messages }: ReturnType<typeof selectMessages> = yield select(selectMessages);
const { messages }: ReturnType<typeof selectMessages> = 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<ReturnType<typeof apiMessagesGetUserMessages>> = yield call(
reqWrapper,
apiMessagesGetUserMessages,
{ username, after }
);
const data: Unwrap<typeof apiGetUserMessages> = 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.message || 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*() {

View file

@ -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<IMessage>;
};
export type ApiSendMessageResult = {
message: IMessage;
};
export type ApiDeleteMessageRequest = {
username: string;
id: number;
is_locked: boolean;
};
export type ApiDeleteMessageResult = {
message: IMessage;
};

View file

@ -14,7 +14,7 @@ export interface IModalState {
const INITIAL_STATE: IModalState = {
is_shown: false,
dialog: null,
dialog: '',
photoswipe: {
images: [],
index: 0,

View file

@ -1,5 +1,5 @@
import { INode, IValidationErrors, IComment, ITag, IFile } from '../types';
import { NODE_ACTIONS, NODE_TYPES } from './constants';
import { IComment, IFile, INode, ITag, IValidationErrors } from '../types';
import { NODE_ACTIONS } from './constants';
import { INodeState } from './reducer';
export const nodeSet = (node: Partial<INodeState>) => ({
@ -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,
@ -44,17 +44,17 @@ export const nodeSetCurrent = (current: INodeState['current']) => ({
type: NODE_ACTIONS.SET_CURRENT,
});
export const nodePostComment = (id: number, is_before: boolean) => ({
id,
is_before,
export const nodePostLocalComment = (
nodeId: INode['id'],
comment: IComment,
callback: (e?: string) => void
) => ({
nodeId,
comment,
callback,
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,

View file

@ -1,181 +1,102 @@
import { api, configWithToken, resultMiddleware, errorMiddleware } 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 {
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<IResultWithStatus<INode>> =>
api
.post(API.NODE.SAVE, node, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
errors: Record<string, string>;
};
export const getNodes = ({
from = null,
access,
}: {
from?: string;
access: string;
}): Promise<IResultWithStatus<{ nodes: INode[] }>> =>
api
.get(API.NODE.GET, configWithToken(access, { params: { from } }))
.then(resultMiddleware)
.catch(errorMiddleware);
export type ApiGetNodeCommentsRequest = {
id: number;
take?: number;
skip?: number;
};
export type ApiGetNodeCommentsResponse = { comments: IComment[]; comment_count: number };
export const apiPostNode = ({ node }: ApiPostNodeRequest) =>
api.post<ApiPostNodeResult>(API.NODE.SAVE, node).then(cleanResult);
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<IResultWithStatus<{ nodes: INode[] }>> =>
}: 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<GetNodeDiffResult>(API.NODE.GET_DIFF, {
params: {
start,
end,
take,
with_heroes,
with_updated,
with_recent,
with_valid,
},
})
.then(cleanResult);
export const getNode = ({
id,
access,
}: {
id: string | number;
access: string;
}): Promise<IResultWithStatus<{ nodes: INode[] }>> =>
api
.get(API.NODE.GET_NODE(id), configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiGetNode = ({ id }: ApiGetNodeRequest) =>
api.get<ApiGetNodeResult>(API.NODE.GET_NODE(id)).then(cleanResult);
export const postNodeComment = ({
id,
data,
access,
}: {
access: string;
id: number;
data: IComment;
}): Promise<IResultWithStatus<{ comment: Comment }>> =>
api
.post(API.NODE.COMMENT(id), data, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiPostComment = ({ id, data }: ApiPostCommentRequest) =>
api.post<ApiPostCommentResult>(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<IResultWithStatus<{ comments: IComment[]; comment_count: number }>> =>
}: ApiGetNodeCommentsRequest) =>
api
.get(API.NODE.COMMENT(id), configWithToken(access, { params: { take, skip } }))
.then(resultMiddleware)
.catch(errorMiddleware);
.get<ApiGetNodeCommentsResponse>(API.NODE.COMMENT(id), { params: { take, skip } })
.then(cleanResult);
export const getNodeRelated = ({
id,
access,
}: {
id: number;
access: string;
}): Promise<IResultWithStatus<{ related: INodeState['related'] }>> =>
api
.get(API.NODE.RELATED(id), configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiGetNodeRelated = ({ id }: ApiGetNodeRelatedRequest) =>
api.get<ApiGetNodeRelatedResult>(API.NODE.RELATED(id)).then(cleanResult);
export const updateNodeTags = ({
id,
tags,
access,
}: ReturnType<typeof nodeUpdateTags> & { access: string }): Promise<IResultWithStatus<{
node: INode;
}>> =>
export const apiPostNodeTags = ({ id, tags }: ApiPostNodeTagsRequest) =>
api
.post(API.NODE.UPDATE_TAGS(id), { tags }, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
.post<ApiPostNodeTagsResult>(API.NODE.UPDATE_TAGS(id), { tags })
.then(cleanResult);
export const postNodeLike = ({
id,
access,
}: ReturnType<typeof nodeLike> & { access: string }): Promise<IResultWithStatus<{
is_liked: INode['is_liked'];
}>> =>
api
.post(API.NODE.POST_LIKE(id), {}, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiPostNodeLike = ({ id }: ApiPostNodeLikeRequest) =>
api.post<ApiPostNodeLikeResult>(API.NODE.POST_LIKE(id)).then(cleanResult);
export const postNodeStar = ({
id,
access,
}: ReturnType<typeof nodeStar> & { access: string }): Promise<IResultWithStatus<{
is_liked: INode['is_liked'];
}>> =>
api
.post(API.NODE.POST_STAR(id), {}, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
export const apiPostNodeHeroic = ({ id }: ApiPostNodeHeroicRequest) =>
api.post<ApiPostNodeHeroicResponse>(API.NODE.POST_HEROIC(id)).then(cleanResult);
export const postNodeLock = ({
id,
is_locked,
access,
}: ReturnType<typeof nodeLock> & { access: string }): Promise<IResultWithStatus<{
deleted_at: INode['deleted_at'];
}>> =>
export const apiLockNode = ({ id, is_locked }: ApiLockNodeRequest) =>
api
.post(API.NODE.POST_LOCK(id), { is_locked }, configWithToken(access))
.then(resultMiddleware)
.catch(errorMiddleware);
.post<ApiLockNodeResult>(API.NODE.POST_LOCK(id), { is_locked })
.then(cleanResult);
export const postNodeLockComment = ({
id,
is_locked,
current,
access,
}: ReturnType<typeof nodeLockComment> & {
access: string;
current: INode['id'];
}): Promise<IResultWithStatus<{ deleted_at: INode['deleted_at'] }>> =>
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<ApiLockcommentResult>(API.NODE.LOCK_COMMENT(current, id), { is_locked })
.then(cleanResult);

View file

@ -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';
import { EditorPublicSwitch } from '~/components/editors/EditorPublicSwitch';
@ -30,7 +30,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`,
@ -42,7 +41,7 @@ export const NODE_ACTIONS = {
SET_COMMENT_DATA: `${prefix}SET_COMMENT_DATA`,
SET_EDITOR: `${prefix}SET_EDITOR`,
POST_COMMENT: `${prefix}POST_COMMENT`,
POST_COMMENT: `${prefix}POST_LOCAL_COMMENT`,
SET_COMMENTS: `${prefix}SET_COMMENTS`,
SET_RELATED: `${prefix}SET_RELATED`,
@ -52,15 +51,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: [],
@ -106,16 +103,16 @@ export const NODE_INLINES: INodeComponents = {
};
export const EMPTY_COMMENT: IComment = {
id: null,
id: 0,
text: '',
files: [],
temp_ids: [],
is_private: false,
user: null,
error: '',
user: undefined,
};
export const NODE_EDITORS = {
export const NODE_EDITORS: Record<
typeof NODE_TYPES[keyof typeof NODE_TYPES],
FC<NodeEditorProps>
> = {
[NODE_TYPES.IMAGE]: ImageEditor,
[NODE_TYPES.TEXT]: TextEditor,
[NODE_TYPES.VIDEO]: VideoEditor,

View file

@ -8,12 +8,12 @@ export type INodeState = Readonly<{
current: INode;
comments: IComment[];
related: {
albums: Record<string, Partial<INode[]>>;
similar: Partial<INode[]>;
albums: Record<string, INode[]>;
similar: INode[];
};
comment_data: Record<number, IComment>;
comment_count: number;
current_cover_image: IFile;
current_cover_image?: IFile;
error: string;
errors: Record<string, string>;
@ -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: {},
};

View file

@ -1,69 +1,64 @@
import { takeLatest, call, put, select, delay, all, 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 {
NODE_ACTIONS,
EMPTY_NODE,
EMPTY_COMMENT,
NODE_EDITOR_DATA,
COMMENTS_DISPLAY,
EMPTY_COMMENT,
EMPTY_NODE,
NODE_ACTIONS,
NODE_EDITOR_DATA,
} from './constants';
import {
nodeSave,
nodeSetSaveErrors,
nodeLoadNode,
nodeSetLoading,
nodeSetCurrent,
nodeSetLoadingComments,
nodePostComment,
nodeSetSendingComment,
nodeSetComments,
nodeSetCommentData,
nodeUpdateTags,
nodeSetTags,
nodeCreate,
nodeSetEditor,
nodeEdit,
nodeLike,
nodeSetRelated,
nodeGotoNode,
nodeLike,
nodeLoadNode,
nodeLock,
nodeLockComment,
nodeEditComment,
nodePostLocalComment,
nodeSave,
nodeSet,
nodeCancelCommentEdit,
nodeSetCommentData,
nodeSetComments,
nodeSetCurrent,
nodeSetEditor,
nodeSetLoading,
nodeSetLoadingComments,
nodeSetRelated,
nodeSetSaveErrors,
nodeSetTags,
nodeUpdateTags,
} from './actions';
import {
postNode,
getNode,
postNodeComment,
getNodeComments,
updateNodeTags,
postNodeLike,
postNodeStar,
getNodeRelated,
postNodeLock,
postNodeLockComment,
apiGetNode,
apiGetNodeComments,
apiGetNodeRelated,
apiLockComment,
apiLockNode,
apiPostComment,
apiPostNode,
apiPostNodeHeroic,
apiPostNodeLike,
apiPostNodeTags,
} from './api';
import { reqWrapper } from '../auth/sagas';
import { flowSetNodes, flowSetUpdated } from '../flow/actions';
import { ERRORS } from '~/constants/errors';
import { modalSetShown, modalShowDialog } from '../modal/actions';
import { selectFlowNodes, selectFlow } from '../flow/selectors';
import { selectFlow, selectFlowNodes } from '../flow/selectors';
import { URLS } from '~/constants/urls';
import { selectNode } from './selectors';
import { IResultWithStatus, INode, 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';
import { has } from 'ramda';
export function* updateNodeEverywhere(node) {
const {
current: { id },
}: INodeState = yield select(selectNode);
const flow_nodes: IFlowState['nodes'] = yield select(selectFlowNodes);
}: ReturnType<typeof selectNode> = yield select(selectNode);
const flow_nodes: ReturnType<typeof selectFlowNodes> = yield select(selectFlowNodes);
if (id === node.id) {
yield put(nodeSetCurrent(node));
@ -79,278 +74,282 @@ export function* updateNodeEverywhere(node) {
}
function* onNodeSave({ node }: ReturnType<typeof nodeSave>) {
yield put(nodeSetSaveErrors({}));
try {
yield put(nodeSetSaveErrors({}));
const {
error,
data: { errors, node: result },
} = yield call(reqWrapper, postNode, { node });
const { errors, node: result }: Unwrap<typeof apiPostNode> = 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<typeof selectFlowNodes> = 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.message || 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<typeof nodeGotoNode>) {
if (!id) {
return;
}
if (node_type) yield put(nodeSetCurrent({ ...EMPTY_NODE, type: node_type }));
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<typeof selectNode> = yield select(selectNode);
try {
const {
current: { id },
comments,
}: ReturnType<typeof selectNode> = yield select(selectNode);
const { data, error }: Unwrap<ReturnType<typeof getNodeComments>> = yield call(
reqWrapper,
getNodeComments,
{
if (!id) {
return;
}
const data: Unwrap<typeof apiGetNodeComments> = yield call(apiGetNodeComments, {
id,
take: COMMENTS_DISPLAY,
skip: comments.length,
});
const current: ReturnType<typeof selectNode> = yield select(selectNode);
if (!data || current.current.id != id) {
return;
}
);
const current: ReturnType<typeof selectNode> = yield select(selectNode);
if (!data || error || current.current.id != id) {
return;
}
yield put(
nodeSet({
comments: [...comments, ...data.comments],
comment_count: data.comment_count,
})
);
yield put(
nodeSet({
comments: [...comments, ...data.comments],
comment_count: data.comment_count,
})
);
} catch (error) {}
}
function* onNodeLoad({ id, order = 'ASC' }: ReturnType<typeof nodeLoadNode>) {
yield put(nodeSetLoading(true));
yield put(nodeSetLoadingComments(true));
function* onNodeLoad({ id }: ReturnType<typeof nodeLoadNode>) {
// Get node body
try {
yield put(nodeSetLoading(true));
yield put(nodeSetLoadingComments(true));
const {
data: { node, error },
} = yield call(reqWrapper, getNode, { id });
const { node }: Unwrap<typeof apiGetNode> = 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<typeof apiGetNodeComments>,
Unwrap<typeof apiGetNodeRelated>
] = 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(reqWrapper, getNodeComments, { id, take: COMMENTS_DISPLAY, skip: 0 }),
related: call(reqWrapper, 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({ id }: ReturnType<typeof nodePostComment>) {
const { current, comment_data } = yield select(selectNode);
function* onPostComment({ nodeId, comment, callback }: ReturnType<typeof nodePostLocalComment>) {
try {
const data: Unwrap<typeof apiPostComment> = yield call(apiPostComment, {
data: comment,
id: nodeId,
});
yield put(nodeSetSendingComment(true));
const {
data: { comment },
error,
} = yield call(reqWrapper, postNodeComment, { data: comment_data[id], id: current.id });
yield put(nodeSetSendingComment(false));
const { current }: ReturnType<typeof selectNode> = yield select(selectNode);
if (error || !comment) {
return yield put(nodeSetCommentData(id, { error }));
}
if (current?.id === nodeId) {
const { comments }: ReturnType<typeof selectNode> = yield select(selectNode);
const { current: current_node } = 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)),
})
);
}
if (current_node && current_node.id === current.id) {
const { comments, comment_data: current_comment_data } = yield select(selectNode);
if (id === 0) {
yield put(nodeSetCommentData(0, { ...EMPTY_COMMENT }));
yield put(nodeSetComments([comment, ...comments]));
} else {
yield put(
nodeSet({
comment_data: omit([id.toString()], current_comment_data),
comments: comments.map(item => (item.id === id ? comment : item)),
})
);
callback();
}
} catch (error) {
return callback(error.message);
}
}
function* onCancelCommentEdit({ id }: ReturnType<typeof nodeCancelCommentEdit>) {
const { comment_data } = yield select(selectNode);
yield put(
nodeSet({
comment_data: omit([id.toString()], comment_data),
})
);
}
function* onUpdateTags({ id, tags }: ReturnType<typeof nodeUpdateTags>) {
yield delay(100);
const {
data: { node },
}: IResultWithStatus<{ node: INode }> = yield call(reqWrapper, 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<typeof apiPostNodeTags> = yield call(apiPostNodeTags, { id, tags });
const { current }: ReturnType<typeof selectNode> = yield select(selectNode);
if (!node || !node.id || node.id !== current.id) return;
yield put(nodeSetTags(node.tags));
} catch {}
}
function* onCreateSaga({ node_type: type }: ReturnType<typeof nodeCreate>) {
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]));
}
function* onEditSaga({ id }: ReturnType<typeof nodeEdit>) {
yield put(modalShowDialog(DIALOGS.LOADING));
try {
if (!id) {
return;
}
const {
data: { node },
error,
} = yield call(reqWrapper, 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<typeof apiGetNode> = yield call(apiGetNode, { id });
yield put(nodeSetEditor(node));
yield put(modalShowDialog(NODE_EDITOR_DIALOGS[node.type]));
if (!node.type || !has(node.type, NODE_EDITOR_DIALOGS)) return;
return true;
if (!NODE_EDITOR_DIALOGS[node?.type]) {
throw new Error('Unknown node type');
}
yield put(nodeSetEditor(node));
yield put(modalShowDialog(NODE_EDITOR_DIALOGS[node.type]));
} catch (error) {
yield put(modalSetShown(false));
}
}
function* onLikeSaga({ id }: ReturnType<typeof nodeLike>) {
const {
current,
current: { is_liked, like_count },
} = yield select(selectNode);
const { current }: ReturnType<typeof selectNode> = yield select(selectNode);
yield call(updateNodeEverywhere, {
...current,
is_liked: !is_liked,
like_count: is_liked ? Math.max(like_count - 1, 0) : like_count + 1,
});
try {
const count = current.like_count || 0;
const { data, error } = yield call(reqWrapper, 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<typeof apiPostNodeLike> = yield call(apiPostNodeLike, { id });
yield call(updateNodeEverywhere, { ...current, is_liked, like_count });
yield call(updateNodeEverywhere, {
...current,
is_liked: data.is_liked,
like_count: data.is_liked ? count + 1 : Math.max(count - 1, 0),
});
} catch {}
}
function* onStarSaga({ id }: ReturnType<typeof nodeLike>) {
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(reqWrapper, postNodeStar, { id });
const data: Unwrap<typeof apiPostNodeHeroic> = 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<typeof nodeLock>) {
const {
current,
current: { deleted_at },
} = yield select(selectNode);
const { current }: ReturnType<typeof selectNode> = 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(reqWrapper, postNodeLock, { id, is_locked });
const data: Unwrap<typeof apiLockNode> = 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<typeof nodeLockComment>) {
const { current, comments } = yield select(selectNode);
const { current, comments }: ReturnType<typeof selectNode> = 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(reqWrapper, postNodeLockComment, { current: current.id, id, is_locked });
}
const data: Unwrap<typeof apiLockComment> = yield call(apiLockComment, {
current: current.id,
id,
is_locked,
});
function* onEditCommentSaga({ id }: ReturnType<typeof nodeEditComment>) {
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() {
@ -358,7 +357,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);
@ -366,6 +364,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);
}

View file

@ -1,10 +1,5 @@
import { IState } from '../store';
import { INodeState } from './reducer';
import { IResultWithStatus, INode } from '../types';
export const selectNode = (state: IState): INodeState => state.node;
// export const catchNodeErrors = (data: IResultWithStatus<INode>): IResultWithStatus<INode> => ({
// data,
// errors: data.errors,
// })
export const selectNode = (state: IState) => state.node;
export const selectNodeComments = (state: IState) => state.node.comments;
export const selectNodeCurrent = (state: IState) => state.node.current;

View file

@ -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;
@ -6,3 +7,85 @@ 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
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;
};
export type NodeEditorProps = {
data: INode;
setData: (val: INode) => void;
temp: string[];
setTemp: (val: string[]) => void;
};

View file

@ -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<IResultWithStatus<{ items: Record<string, IEmbed> }>> =>
export const apiGetEmbedYoutube = (ids: string[]) =>
api
.get(API.EMBED.YOUTUBE, { params: { ids: ids.join(',') } })
.then(resultMiddleware)
.catch(errorMiddleware);
.get<ApiGetEmbedYoutubeResult>(API.EMBED.YOUTUBE, { params: { ids: ids.join(',') } })
.then(cleanResult);

View file

@ -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<string, IEmbed>;
}>;
const INITIAL_STATE: IPlayerState = {
status: PLAYER_STATES.UNSET,
file: null,
file: undefined,
youtubes: {},
};

View file

@ -10,11 +10,16 @@ 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<typeof playerSetFile>) {
if (!file) {
return;
}
yield put(playerSetFile(file));
Player.set(getURL(file));
Player.play();
}
@ -37,7 +42,7 @@ function seekSaga({ seek }: ReturnType<typeof playerSeek>) {
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<typeof playerGetYoutubeInfo>; 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<ReturnType<typeof getEmbedYoutube>> = 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<typeof apiGetEmbedYoutube> = yield call(apiGetEmbedYoutube, ids);
if (data.items && Object.keys(data.items).length) {
const { youtubes }: ReturnType<typeof selectPlayer> = 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);
}

View file

@ -0,0 +1,3 @@
import { IEmbed } from '~/redux/types';
export type ApiGetEmbedYoutubeResult = { items: Record<string, IEmbed> };

View file

@ -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,27 @@ 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<{ error: string }>) => {
if (error.response?.status === 401) {
store.dispatch(authLogout());
}
error.message = error?.response?.data?.error || error?.response?.statusText || error.message;
throw error;
});
return { store, persistor };
}

View file

@ -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<IResultWithStatus<{ nodes: INode[]; count: number }>> =>
export const apiGetNodesOfTag = ({ tag, offset, limit }: ApiGetNodesOfTagRequest) =>
api
.get(API.TAG.NODES, configWithToken(access, { params: { name: tag, offset, limit } }))
.then(resultMiddleware)
.catch(errorMiddleware);
.get<ApiGetNodesOfTagResult>(API.TAG.NODES, { params: { name: tag, offset, limit } })
.then(cleanResult);
export const getTagAutocomplete = ({
search,
exclude,
access,
}: {
access: string;
search: string;
exclude: string[];
}): Promise<IResultWithStatus<{ tags: string[] }>> =>
export const apiGetTagSuggestions = ({ search, exclude }: ApiGetTagSuggestionsRequest) =>
api
.get(API.TAG.AUTOCOMPLETE, configWithToken(access, { params: { search, exclude } }))
.then(resultMiddleware)
.catch(errorMiddleware);
.get<ApiGetTagSuggestionsResult>(API.TAG.AUTOCOMPLETE, { params: { search, exclude } })
.then(cleanResult);

View file

@ -1,48 +1,48 @@
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 { 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<typeof tagLoadNodes>) {
yield put(tagSetNodes({ isLoading: true }));
yield put(tagSetNodes({ isLoading: true, list: [] }));
try {
const { list }: ReturnType<typeof selectTagNodes> = yield select(selectTagNodes);
const { data, error }: Unwrap<ReturnType<typeof getTagNodes>> = yield call(
reqWrapper,
getTagNodes,
{ tag, limit: 18, offset: list.length }
);
const data: Unwrap<typeof apiGetNodesOfTag> = 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<typeof tagLoadAutocomplete>) {
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<ReturnType<typeof getTagAutocomplete>> = yield call(
reqWrapper,
getTagAutocomplete,
{ search, exclude }
);
const data: Unwrap<typeof apiGetTagSuggestions> = 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 }));
}
}

16
src/redux/tag/types.ts Normal file
View file

@ -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[];
};

View file

@ -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<IUser>;
user?: Partial<IUser>;
title: string;
files: IFile[];
cover: IFile;
type: string;
cover?: IFile;
type?: string;
blocks: IBlock[];
thumbnail?: string;
@ -144,11 +144,8 @@ export interface INode {
export interface IComment {
id: number;
text: string;
temp_ids?: string[];
files: IFile[];
is_private: boolean;
user: IUser;
error?: string;
user?: IUser;
created_at?: string;
update_at?: string;
@ -197,7 +194,13 @@ export type INodeNotification = {
export type INotification = IMessageNotification | ICommentNotification;
export type Unwrap<T> = T extends Promise<infer U> ? U : T;
export type Unwrap<T> = T extends (...args: any) => Promise<any>
? T extends (...args: any) => Promise<infer U>
? U
: T
: T extends () => Iterator<any, infer U, any>
? U
: any;
export interface IEmbed {
provider: string;

View file

@ -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<IResultWithStatus<IFile>> => {
}: 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<ApiUploadFIleResult>(API.USER.UPLOAD(target, type), data, {
onUploadProgress: onProgress,
})
.then(cleanResult);
};

View file

@ -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

View file

@ -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<typeof uploadAddStatus>
): IUploadState => assocPath(
['statuses'],
{ ...state.statuses, [temp_id]: { ...EMPTY_UPLOAD_STATUS, ...status, }, },
state
);
{ temp_id, status }: ReturnType<typeof uploadAddStatus>
): IUploadState =>
assocPath(
['statuses'],
{ ...state.statuses, [temp_id]: { ...EMPTY_UPLOAD_STATUS, ...status } },
state
);
const dropStatus = (
state: IUploadState,
{ temp_id, }: ReturnType<typeof uploadDropStatus>
{ temp_id }: ReturnType<typeof uploadDropStatus>
): IUploadState => assocPath(['statuses'], omit([temp_id], state.statuses), state);
const setStatus = (
state: IUploadState,
{ temp_id, status, }: ReturnType<typeof uploadSetStatus>
): IUploadState => assocPath(
['statuses'],
{
...state.statuses,
[temp_id]: { ...(state.statuses[temp_id] || EMPTY_UPLOAD_STATUS), ...status, },
},
state
);
{ temp_id, status }: ReturnType<typeof uploadSetStatus>
): IUploadState =>
assocPath(
['statuses'],
{
...state.statuses,
[temp_id]: { ...(state.statuses[temp_id] || EMPTY_UPLOAD_STATUS), ...status },
},
state
);
const addFile = (state: IUploadState, { file, }: ReturnType<typeof uploadAddFile>): IUploadState => assocPath(['files'], { ...state.files, [file.id]: file, }, state);
const addFile = (state: IUploadState, { file }: ReturnType<typeof uploadAddFile>): IUploadState => {
if (!file.id) return state;
return assocPath(['files', file.id], file, state);
};
export const UPLOAD_HANDLERS = {
[UPLOAD_ACTIONS.ADD_STATUS]: addStatus,

View file

@ -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<IResultWithStatus<IFile>> => (
Promise.resolve({
status: HTTP_RESPONSES.CREATED,
data: {
...EMPTY_FILE,
id: uuid(),
temp_id,
},
error: null,
}));

View file

@ -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 { reqWrapper } 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(reqWrapper, postUploadFile, {
const data: Unwrap<typeof apiUploadFile> = 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<Unwrap<typeof uploadCall>> {
const [promise, chan] = createUploader<Partial<IFileWithUUID>, Partial<IFileWithUUID>>(
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 || !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<typeof uploadGetThumb> = 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<typeof uploadCall>,
Unwrap<typeof uploadCancelWorker>
] = 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: error.message,
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<typeof uploadUploadFiles>) {

View file

@ -0,0 +1,6 @@
import { IFile, IFileWithUUID, IUploadProgressHandler } from '~/redux/types';
export type ApiUploadFileRequest = IFileWithUUID & {
onProgress: IUploadProgressHandler;
};
export type ApiUploadFIleResult = IFile;