diff --git a/package.json b/package.json index 03f37c0f..4374ead7 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "start": "craco start", "build": "craco build", "test": "craco test", - "eject": "craco eject" + "ts-check": "tsc -p tsconfig.json --noEmit" }, "eslintConfig": { "extends": [ diff --git a/src/components/comment/CommentContent/index.tsx b/src/components/comment/CommentContent/index.tsx index b215379e..318380f3 100644 --- a/src/components/comment/CommentContent/index.tsx +++ b/src/components/comment/CommentContent/index.tsx @@ -33,7 +33,8 @@ const CommentContent: FC = memo(({ comment, can_edit, onDelete, modalSho const groupped = useMemo>( () => reduce( - (group, file) => assocPath([file.type], append(file, group[file.type]), group), + (group, file) => + file.type ? assocPath([file.type], append(file, group[file.type]), group) : group, {}, comment.files ), diff --git a/src/components/comment/CommentEmbedBlock/index.tsx b/src/components/comment/CommentEmbedBlock/index.tsx index 46bdd08a..77a374d2 100644 --- a/src/components/comment/CommentEmbedBlock/index.tsx +++ b/src/components/comment/CommentEmbedBlock/index.tsx @@ -6,6 +6,7 @@ import { selectPlayer } from '~/redux/player/selectors'; import { connect } from 'react-redux'; import * as PLAYER_ACTIONS from '~/redux/player/actions'; import { Icon } from '~/components/input/Icon'; +import { path } from 'ramda'; const mapStateToProps = state => ({ youtubes: selectPlayer(state).youtubes, @@ -21,30 +22,32 @@ type Props = ReturnType & const CommentEmbedBlockUnconnected: FC = memo( ({ block, youtubes, playerGetYoutubeInfo }) => { - const link = useMemo( - () => - block.content.match( - /https?:\/\/(www\.)?(youtube\.com|youtu\.be)\/(watch)?(\?v=)?([\w\-\=]+)/ - ), - [block.content] - ); + const id = useMemo(() => { + const match = block.content.match( + /https?:\/\/(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch)?(?:\?v=)?([\w\-\=]+)/ + ); + + return (match && match[1]) || ''; + }, [block.content]); const preview = useMemo(() => getYoutubeThumb(block.content), [block.content]); useEffect(() => { - if (!link[5] || youtubes[link[5]]) return; - playerGetYoutubeInfo(link[5]); - }, [link, playerGetYoutubeInfo]); + if (!id) return; + playerGetYoutubeInfo(id); + }, [id, playerGetYoutubeInfo]); - const title = useMemo( - () => - (youtubes[link[5]] && youtubes[link[5]].metadata && youtubes[link[5]].metadata.title) || '', - [link, youtubes] - ); + const title = useMemo(() => { + if (!id) { + return block.content; + } + + return path([id, 'metadata', 'title'], youtubes) || block.content; + }, [id, youtubes, block.content]); return ( diff --git a/src/components/comment/CommentForm/index.tsx b/src/components/comment/CommentForm/index.tsx index 8b0a1c8c..b644509d 100644 --- a/src/components/comment/CommentForm/index.tsx +++ b/src/components/comment/CommentForm/index.tsx @@ -67,7 +67,13 @@ const CommentForm: FC = ({ comment, nodeId, onCancelEdit }) => { - + + {!!textarea && ( + + )} {isLoading && } diff --git a/src/components/comment/CommentFormAttaches/index.tsx b/src/components/comment/CommentFormAttaches/index.tsx index 1c764f14..7422c134 100644 --- a/src/components/comment/CommentFormAttaches/index.tsx +++ b/src/components/comment/CommentFormAttaches/index.tsx @@ -10,7 +10,8 @@ import { COMMENT_FILE_TYPES, UPLOAD_TYPES } from '~/redux/uploads/constants'; import { useFileUploaderContext } from '~/utils/hooks/fileUploader'; const CommentFormAttaches: FC = () => { - const { files, pending, setFiles, uploadFiles } = useFileUploaderContext(); + const uploader = useFileUploaderContext(); + const { files, pending, setFiles, uploadFiles } = uploader!; const images = useMemo(() => files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), [ files, @@ -70,7 +71,7 @@ const CommentFormAttaches: FC = () => { ); const onAudioTitleChange = useCallback( - (fileId: IFile['id'], title: IFile['metadata']['title']) => { + (fileId: IFile['id'], title: string) => { setFiles( files.map(file => file.id === fileId ? { ...file, metadata: { ...file.metadata, title } } : file @@ -80,36 +81,36 @@ const CommentFormAttaches: FC = () => { [files, setFiles] ); - return ( - hasAttaches && ( -
- {hasImageAttaches && ( - - )} + if (!hasAttaches) return null; - {hasAudioAttaches && ( - - )} -
- ) + return ( +
+ {hasImageAttaches && ( + + )} + + {hasAudioAttaches && ( + + )} +
); }; diff --git a/src/components/comment/CommentFormFormatButtons/index.tsx b/src/components/comment/CommentFormFormatButtons/index.tsx index de39bbe0..df2d2c0f 100644 --- a/src/components/comment/CommentFormFormatButtons/index.tsx +++ b/src/components/comment/CommentFormFormatButtons/index.tsx @@ -1,7 +1,7 @@ -import React, { FC, useCallback } from 'react'; +import React, { FC, useCallback, useEffect } from 'react'; import { ButtonGroup } from '~/components/input/ButtonGroup'; import { Button } from '~/components/input/Button'; -import { useFormatWrapper } from '~/utils/hooks/useFormatWrapper'; +import { useFormatWrapper, wrapTextInsideInput } from '~/utils/hooks/useFormatWrapper'; import styles from './styles.module.scss'; interface IProps { @@ -15,16 +15,57 @@ const CommentFormFormatButtons: FC = ({ element, handler }) => { [element, handler] ); + const wrapBold = useCallback( + event => { + event.preventDefault(); + wrapTextInsideInput(element, '**', '**', handler); + }, + [wrap, handler] + ); + + const wrapItalic = useCallback( + event => { + event.preventDefault(); + wrapTextInsideInput(element, '*', '*', handler); + }, + [wrap, handler] + ); + + const onKeyPress = useCallback( + (event: KeyboardEvent) => { + if (!event.ctrlKey) return; + + if (event.code === 'KeyB') { + wrapBold(event); + } + + if (event.code === 'KeyI') { + wrapItalic(event); + } + }, + [wrapBold, wrapItalic] + ); + + useEffect(() => { + if (!element) { + return; + } + + element.addEventListener('keypress', onKeyPress); + + return () => element.removeEventListener('keypress', onKeyPress); + }, [element, onKeyPress]); + return (
- ) : null, + ) : ( + undefined + ), [is_succesfull] ); - const not_ready = useMemo(() => (is_loading && !user ?
: null), [ - is_loading, - user, - ]); + const not_ready = useMemo( + () => (is_loading && !user ?
: undefined), + [is_loading, user] + ); const invalid_code = useMemo( () => @@ -100,7 +102,9 @@ const RestorePasswordDialogUnconnected: FC = ({ Очень жаль - ) : null, + ) : ( + undefined + ), [is_loading, user, error] ); @@ -135,7 +139,7 @@ const RestorePasswordDialogUnconnected: FC = ({ type="password" value={password_again} handler={setPasswordAgain} - error={password_again && doesnt_match && ERROR_LITERAL[ERRORS.DOESNT_MATCH]} + error={password_again && doesnt_match ? ERROR_LITERAL[ERRORS.DOESNT_MATCH] : ''} /> diff --git a/src/containers/dialogs/RestoreRequestDialog/index.tsx b/src/containers/dialogs/RestoreRequestDialog/index.tsx index 5a77c6d9..616bd55c 100644 --- a/src/containers/dialogs/RestoreRequestDialog/index.tsx +++ b/src/containers/dialogs/RestoreRequestDialog/index.tsx @@ -43,7 +43,7 @@ const RestoreRequestDialogUnconnected: FC = ({ useEffect(() => { if (error || is_succesfull) { - authSetRestore({ error: null, is_succesfull: false }); + authSetRestore({ error: '', is_succesfull: false }); } }, [field]); @@ -72,7 +72,9 @@ const RestoreRequestDialogUnconnected: FC = ({ Отлично! - ) : null, + ) : ( + undefined + ), [is_succesfull] ); diff --git a/src/containers/node/BorisLayout/index.tsx b/src/containers/node/BorisLayout/index.tsx index fb005d40..49ef20d2 100644 --- a/src/containers/node/BorisLayout/index.tsx +++ b/src/containers/node/BorisLayout/index.tsx @@ -37,6 +37,7 @@ const BorisLayout: FC = () => { if ( user.last_seen_boris && + last_comment.created_at && !isBefore(new Date(user.last_seen_boris), new Date(last_comment.created_at)) ) return; diff --git a/src/containers/node/NodeLayout/index.tsx b/src/containers/node/NodeLayout/index.tsx index a36e6ada..00c3ef7c 100644 --- a/src/containers/node/NodeLayout/index.tsx +++ b/src/containers/node/NodeLayout/index.tsx @@ -12,9 +12,14 @@ import { NodeNoComments } from '~/components/node/NodeNoComments'; import { NodeRelated } from '~/components/node/NodeRelated'; import { NodeComments } from '~/components/node/NodeComments'; import { NodeTags } from '~/components/node/NodeTags'; -import { INodeComponentProps, NODE_COMPONENTS, NODE_HEADS, NODE_INLINES } from '~/redux/node/constants'; +import { + INodeComponentProps, + NODE_COMPONENTS, + NODE_HEADS, + NODE_INLINES, +} from '~/redux/node/constants'; import { selectUser } from '~/redux/auth/selectors'; -import { pick } from 'ramda'; +import { path, pick, prop } from 'ramda'; import { NodeRelatedPlaceholder } from '~/components/node/NodeRelated/placeholder'; import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge'; import { NodeCommentForm } from '~/components/node/NodeCommentForm'; @@ -71,9 +76,6 @@ const NodeLayoutUnconnected: FC = memo( nodeStar, nodeLock, nodeSetCoverImage, - nodeLockComment, - nodeEditComment, - nodeLoadMoreComments, modalShowPhotoswipe, }) => { const [layout, setLayout] = useState({}); @@ -84,7 +86,6 @@ const NodeLayoutUnconnected: FC = memo( comments = [], current: node, related, - comment_data, comment_count, } = useShallowSelect(selectNode); const updateLayout = useCallback(() => setLayout({}), []); @@ -103,6 +104,10 @@ const NodeLayoutUnconnected: FC = memo( const onTagClick = useCallback( (tag: Partial) => { + if (!node?.id || !tag?.title) { + return; + } + history.push(URLS.NODE_TAG_URL(node.id, encodeURIComponent(tag.title))); }, [history, node.id] @@ -112,9 +117,9 @@ const NodeLayoutUnconnected: FC = memo( const can_like = useMemo(() => canLikeNode(node, user), [node, user]); const can_star = useMemo(() => canStarNode(node, user), [node, user]); - const head = node && node.type && NODE_HEADS[node.type]; - const block = node && node.type && NODE_COMPONENTS[node.type]; - const inline = node && node.type && NODE_INLINES[node.type]; + const head = useMemo(() => node?.type && prop(node?.type, NODE_HEADS), [node.type]); + const block = useMemo(() => node?.type && prop(node?.type, NODE_COMPONENTS), [node.type]); + const inline = useMemo(() => node?.type && prop(node?.type, NODE_INLINES), [node.type]); const onEdit = useCallback(() => nodeEdit(node.id), [nodeEdit, node]); const onLike = useCallback(() => nodeLike(node.id), [nodeLike, node]); @@ -147,10 +152,10 @@ const NodeLayoutUnconnected: FC = memo( return ( <> - {createNodeBlock(head)} + {!!head && createNodeBlock(head)} - {createNodeBlock(block)} + {!!block && createNodeBlock(block)} = memo( {!is_loading && related && related.albums && + !!node?.id && Object.keys(related.albums) .filter(album => related.albums[album].length > 0) .map(album => ( + {album} } diff --git a/src/containers/profile/ProfileAvatar/index.tsx b/src/containers/profile/ProfileAvatar/index.tsx index 07869b7d..4de56cd7 100644 --- a/src/containers/profile/ProfileAvatar/index.tsx +++ b/src/containers/profile/ProfileAvatar/index.tsx @@ -1,43 +1,42 @@ -import React, { FC, useCallback, useEffect, useState } from "react"; -import styles from "./styles.module.scss"; -import { connect } from "react-redux"; -import { getURL } from "~/utils/dom"; -import { pick } from "ramda"; -import { selectAuthProfile, selectAuthUser } from "~/redux/auth/selectors"; -import { PRESETS } from "~/constants/urls"; -import { selectUploads } from "~/redux/uploads/selectors"; -import { IFileWithUUID } from "~/redux/types"; -import uuid from "uuid4"; -import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from "~/redux/uploads/constants"; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import styles from './styles.module.scss'; +import { connect } from 'react-redux'; +import { getURL } from '~/utils/dom'; +import { pick } from 'ramda'; +import { selectAuthProfile, selectAuthUser } from '~/redux/auth/selectors'; +import { PRESETS } from '~/constants/urls'; +import { selectUploads } from '~/redux/uploads/selectors'; +import { IFileWithUUID } from '~/redux/types'; +import uuid from 'uuid4'; +import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from '~/redux/uploads/constants'; import { path } from 'ramda'; -import * as UPLOAD_ACTIONS from "~/redux/uploads/actions"; -import * as AUTH_ACTIONS from "~/redux/auth/actions"; -import { Icon } from "~/components/input/Icon"; +import * as UPLOAD_ACTIONS from '~/redux/uploads/actions'; +import * as AUTH_ACTIONS from '~/redux/auth/actions'; +import { Icon } from '~/components/input/Icon'; const mapStateToProps = state => ({ - user: pick(["id"], selectAuthUser(state)), - profile: pick(["is_loading", "user"], selectAuthProfile(state)), - uploads: pick(["statuses", "files"], selectUploads(state)) + user: pick(['id'], selectAuthUser(state)), + profile: pick(['is_loading', 'user'], selectAuthProfile(state)), + uploads: pick(['statuses', 'files'], selectUploads(state)), }); const mapDispatchToProps = { uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles, - authPatchUser: AUTH_ACTIONS.authPatchUser + authPatchUser: AUTH_ACTIONS.authPatchUser, }; -type IProps = ReturnType & - typeof mapDispatchToProps & {}; +type IProps = ReturnType & typeof mapDispatchToProps & {}; const ProfileAvatarUnconnected: FC = ({ user: { id }, profile: { is_loading, user }, uploads: { statuses, files }, uploadUploadFiles, - authPatchUser + authPatchUser, }) => { - const can_edit = !is_loading && id && id === user.id; + const can_edit = !is_loading && id && id === user?.id; - const [temp, setTemp] = useState(null); + const [temp, setTemp] = useState(''); useEffect(() => { if (!can_edit) return; @@ -45,7 +44,7 @@ const ProfileAvatarUnconnected: FC = ({ Object.entries(statuses).forEach(([id, status]) => { if (temp === id && !!status.uuid && files[status.uuid]) { authPatchUser({ photo: files[status.uuid] }); - setTemp(null); + setTemp(''); } }); }, [statuses, files, temp, can_edit, authPatchUser]); @@ -58,11 +57,11 @@ const ProfileAvatarUnconnected: FC = ({ temp_id: uuid(), subject: UPLOAD_SUBJECTS.AVATAR, target: UPLOAD_TARGETS.PROFILES, - type: UPLOAD_TYPES.IMAGE + type: UPLOAD_TYPES.IMAGE, }) ); - setTemp(path([0, "temp_id"], items)); + setTemp(path([0, 'temp_id'], items) || ''); uploadUploadFiles(items.slice(0, 1)); }, [uploadUploadFiles, setTemp] @@ -81,13 +80,15 @@ const ProfileAvatarUnconnected: FC = ({ [onUpload, can_edit] ); + const backgroundImage = is_loading + ? undefined + : `url("${user && getURL(user.photo, PRESETS.avatar)}")`; + return (
{can_edit && } @@ -100,9 +101,6 @@ const ProfileAvatarUnconnected: FC = ({ ); }; -const ProfileAvatar = connect( - mapStateToProps, - mapDispatchToProps -)(ProfileAvatarUnconnected); +const ProfileAvatar = connect(mapStateToProps, mapDispatchToProps)(ProfileAvatarUnconnected); export { ProfileAvatar }; diff --git a/src/containers/profile/ProfileInfo/index.tsx b/src/containers/profile/ProfileInfo/index.tsx index f5739f12..7cdacccc 100644 --- a/src/containers/profile/ProfileInfo/index.tsx +++ b/src/containers/profile/ProfileInfo/index.tsx @@ -1,5 +1,5 @@ import React, { FC, ReactNode } from 'react'; -import { IUser } from '~/redux/auth/types'; +import { IAuthState, IUser } from '~/redux/auth/types'; import styles from './styles.module.scss'; import { Group } from '~/components/containers/Group'; import { Placeholder } from '~/components/placeholders/Placeholder'; @@ -14,7 +14,7 @@ interface IProps { is_loading?: boolean; is_own?: boolean; - setTab?: (tab: string) => void; + setTab?: (tab: IAuthState['profile']['tab']) => void; content?: ReactNode; } @@ -26,16 +26,16 @@ const ProfileInfo: FC = ({ user, tab, is_loading, is_own, setTab, conten
- {is_loading ? : user.fullname || user.username} + {is_loading ? : user?.fullname || user?.username}
- {is_loading ? : getPrettyDate(user.last_seen)} + {is_loading ? : getPrettyDate(user?.last_seen)}
- + {content}
diff --git a/src/containers/profile/ProfileLayout/index.tsx b/src/containers/profile/ProfileLayout/index.tsx index 9db6fc73..24d623dd 100644 --- a/src/containers/profile/ProfileLayout/index.tsx +++ b/src/containers/profile/ProfileLayout/index.tsx @@ -20,10 +20,10 @@ const ProfileLayoutUnconnected: FC = ({ history, nodeSetCoverImage }) => const { params: { username }, } = useRouteMatch<{ username: string }>(); - const [user, setUser] = useState(null); + const [user, setUser] = useState(undefined); useEffect(() => { - if (user) setUser(null); + if (user) setUser(undefined); }, [username]); useEffect(() => { diff --git a/src/containers/profile/ProfileMessages/index.tsx b/src/containers/profile/ProfileMessages/index.tsx index 61948d2d..92357851 100644 --- a/src/containers/profile/ProfileMessages/index.tsx +++ b/src/containers/profile/ProfileMessages/index.tsx @@ -31,7 +31,7 @@ const ProfileMessagesUnconnected: FC = ({ messagesRefreshMessages, }) => { const wasAtBottom = useRef(true); - const [wrap, setWrap] = useState(null); + const [wrap, setWrap] = useState(undefined); const [editingMessageId, setEditingMessageId] = useState(0); const onEditMessage = useCallback((id: number) => setEditingMessageId(id), [setEditingMessageId]); @@ -95,31 +95,33 @@ const ProfileMessagesUnconnected: FC = ({ if (!messages.messages.length || profile.is_loading) return ; - return ( - messages.messages.length > 0 && ( -
- {messages.messages - .filter(message => !!message.text) - .map(( - message // TODO: show files / memo - ) => ( - - ))} + if (messages.messages.length <= 0) { + return null; + } - {!messages.is_loading_messages && messages.messages.length > 0 && ( -
Когда-нибудь здесь будут еще сообщения
- )} -
- ) + return ( +
+ {messages.messages + .filter(message => !!message.text) + .map(( + message // TODO: show files / memo + ) => ( + + ))} + + {!messages.is_loading_messages && messages.messages.length > 0 && ( +
Когда-нибудь здесь будут еще сообщения
+ )} +
); }; diff --git a/src/containers/profile/ProfilePageLeft/index.tsx b/src/containers/profile/ProfilePageLeft/index.tsx index dd4fdb58..fb2af296 100644 --- a/src/containers/profile/ProfilePageLeft/index.tsx +++ b/src/containers/profile/ProfilePageLeft/index.tsx @@ -1,11 +1,14 @@ import React, { FC, useMemo } from 'react'; -import styles from './styles.module.scss'; import { IAuthState } from '~/redux/auth/types'; -import { getURL } from '~/utils/dom'; +import { formatText, getURL } from '~/utils/dom'; import { PRESETS, URLS } from '~/constants/urls'; import { Placeholder } from '~/components/placeholders/Placeholder'; import { Link } from 'react-router-dom'; import { Icon } from '~/components/input/Icon'; +import classNames from 'classnames'; + +import styles from './styles.module.scss'; +import markdown from '~/styles/common/markdown.module.scss'; interface IProps { profile: IAuthState['profile']; @@ -26,11 +29,11 @@ const ProfilePageLeft: FC = ({ username, profile }) => {
- {profile.is_loading ? : profile.user.fullname} + {profile.is_loading ? : profile?.user?.fullname}
- {profile.is_loading ? : `~${profile.user.username}`} + {profile.is_loading ? : `~${profile?.user?.username}`}
@@ -53,7 +56,9 @@ const ProfilePageLeft: FC = ({ username, profile }) => {
{profile && profile.user && profile.user.description && false && ( -
{profile.user.description}
+
+ {formatText(profile?.user?.description || '')} +
)}
); diff --git a/src/containers/profile/ProfileTabs/index.tsx b/src/containers/profile/ProfileTabs/index.tsx index 4fe97bef..d576d4e2 100644 --- a/src/containers/profile/ProfileTabs/index.tsx +++ b/src/containers/profile/ProfileTabs/index.tsx @@ -1,38 +1,49 @@ -import React, { FC } from 'react'; +import React, { FC, useCallback } from 'react'; import styles from './styles.module.scss'; import classNames from 'classnames'; +import { IAuthState } from '~/redux/auth/types'; interface IProps { tab: string; is_own: boolean; - setTab: (tab: string) => void; + setTab?: (tab: IAuthState['profile']['tab']) => void; } -const ProfileTabs: FC = ({ tab, is_own, setTab }) => ( -
-
setTab('profile')} - > - Профиль +const ProfileTabs: FC = ({ tab, is_own, setTab }) => { + const changeTab = useCallback( + (tab: IAuthState['profile']['tab']) => () => { + if (!setTab) return; + setTab(tab); + }, + [setTab] + ); + + return ( +
+
+ Профиль +
+
+ Сообщения +
+ {is_own && ( + <> +
+ Настройки +
+ + )}
-
setTab('messages')} - > - Сообщения -
- {is_own && ( - <> -
setTab('settings')} - > - Настройки -
- - )} -
-); + ); +}; export { ProfileTabs }; diff --git a/src/containers/sidebars/ProfileSidebar/index.tsx b/src/containers/sidebars/ProfileSidebar/index.tsx index 308cad8f..9032856d 100644 --- a/src/containers/sidebars/ProfileSidebar/index.tsx +++ b/src/containers/sidebars/ProfileSidebar/index.tsx @@ -56,7 +56,7 @@ const ProfileSidebarUnconnected: FC = ({
- + {!!user && }
diff --git a/src/containers/sidebars/TagSidebar/index.tsx b/src/containers/sidebars/TagSidebar/index.tsx index e5b849ff..ff045a81 100644 --- a/src/containers/sidebars/TagSidebar/index.tsx +++ b/src/containers/sidebars/TagSidebar/index.tsx @@ -35,7 +35,10 @@ const TagSidebarUnconnected: FC = ({ nodes, tagLoadNodes, tagSetNodes }) useEffect(() => { tagLoadNodes(tag); - return () => tagSetNodes({ list: [], count: 0 }); + + return () => { + tagSetNodes({ list: [], count: 0 }); + }; }, [tag]); const loadMore = useCallback(() => { diff --git a/src/redux/auth/api.ts b/src/redux/auth/api.ts index 18cb240c..02869974 100644 --- a/src/redux/auth/api.ts +++ b/src/redux/auth/api.ts @@ -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> => +export const apiUserLogin = ({ username, password }: ApiUserLoginRequest) => api - .post(API.USER.LOGIN, { username, password }) - .then(resultMiddleware) - .catch(errorMiddleware) - .then(userLoginTransform); + .post(API.USER.LOGIN, { username, password }) + .then(cleanResult); -export const apiAuthGetUser = ({ access }): Promise> => - api - .get(API.USER.ME, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiAuthGetUser = () => api.get(API.USER.ME).then(cleanResult); -export const apiAuthGetUserProfile = ({ - access, - username, -}): Promise> => - api - .get(API.USER.PROFILE(username), configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiAuthGetUserProfile = ({ username }: ApiAuthGetUserProfileRequest) => + api.get(API.USER.PROFILE(username)).then(cleanResult); -export const apiAuthGetUpdates = ({ - access, - exclude_dialogs, - last, -}): Promise> => +export const apiAuthGetUpdates = ({ exclude_dialogs, last }: ApiAuthGetUpdatesRequest) => api - .get(API.USER.GET_UPDATES, configWithToken(access, { params: { exclude_dialogs, last } })) - .then(resultMiddleware) - .catch(errorMiddleware); + .get(API.USER.GET_UPDATES, { params: { exclude_dialogs, last } }) + .then(cleanResult); -export const apiUpdateUser = ({ access, user }): Promise> => - api - .patch(API.USER.ME, user, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiUpdateUser = ({ user }: ApiUpdateUserRequest) => + api.patch(API.USER.ME, user).then(cleanResult); -export const apiRequestRestoreCode = ({ field }): Promise> => +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> => - api - .get(API.USER.REQUEST_CODE(code)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiCheckRestoreCode = ({ code }: ApiCheckRestoreCodeRequest) => + api.get(API.USER.REQUEST_CODE(code)).then(cleanResult); -export const apiRestoreCode = ({ code, password }): Promise> => +export const apiRestoreCode = ({ code, password }: ApiRestoreCodeRequest) => api - .post(API.USER.REQUEST_CODE(code), { password }) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.USER.REQUEST_CODE(code), { password }) + .then(cleanResult); -export const apiGetSocials = ({ - access, -}: { - access: string; -}): Promise> => - api - .get(API.USER.GET_SOCIALS, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiGetSocials = () => + api.get(API.USER.GET_SOCIALS).then(cleanResult); -export const apiDropSocial = ({ - access, - id, - provider, -}: { - access: string; - id: string; - provider: string; -}): Promise> => - api - .delete(API.USER.DROP_SOCIAL(provider, id), configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiDropSocial = ({ id, provider }: ApiDropSocialRequest) => + api.delete(API.USER.DROP_SOCIAL(provider, id)).then(cleanResult); -export const apiAttachSocial = ({ - access, - token, -}: { - access: string; - token: string; -}): Promise> => +export const apiAttachSocial = ({ token }: ApiAttachSocialRequest) => api - .post(API.USER.ATTACH_SOCIAL, { token }, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.USER.ATTACH_SOCIAL, { token }) + .then(cleanResult); -export const apiLoginWithSocial = ({ - token, - username, - password, -}: { - token: string; - username?: string; - password?: string; -}): Promise; - needs_register: boolean; -}>> => +export const apiLoginWithSocial = ({ token, username, password }: ApiLoginWithSocialRequest) => api - .post(API.USER.LOGIN_WITH_SOCIAL, { token, username, password }) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.USER.LOGIN_WITH_SOCIAL, { token, username, password }) + .then(cleanResult); diff --git a/src/redux/auth/constants.ts b/src/redux/auth/constants.ts index 5c66e845..d2959fdc 100644 --- a/src/redux/auth/constants.ts +++ b/src/redux/auth/constants.ts @@ -53,26 +53,26 @@ export const USER_ROLES = { }; export const EMPTY_TOKEN: IToken = { - access: null, - refresh: null, + access: '', + refresh: '', }; export const EMPTY_USER: IUser = { - id: null, + id: 0, role: USER_ROLES.GUEST, - email: null, - name: null, - username: null, - photo: null, - cover: null, + email: '', + name: '', + username: '', + photo: undefined, + cover: undefined, is_activated: false, is_user: false, - fullname: null, - description: null, + fullname: '', + description: '', - last_seen: null, - last_seen_messages: null, - last_seen_boris: null, + last_seen: '', + last_seen_messages: '', + last_seen_boris: '', }; export interface IApiUser { diff --git a/src/redux/auth/index.ts b/src/redux/auth/index.ts index 008d581f..357cb37e 100644 --- a/src/redux/auth/index.ts +++ b/src/redux/auth/index.ts @@ -8,17 +8,17 @@ const HANDLERS = { }; const INITIAL_STATE: IAuthState = { - token: null, + token: '', user: { ...EMPTY_USER }, updates: { - last: null, + last: '', notifications: [], - boris_commented_at: null, + boris_commented_at: '', }, login: { - error: null, + error: '', is_loading: false, is_registering: true, }, @@ -27,7 +27,7 @@ const INITIAL_STATE: IAuthState = { tab: 'profile', is_loading: true, - user: null, + user: undefined, patch_errors: {}, socials: { @@ -39,20 +39,19 @@ const INITIAL_STATE: IAuthState = { restore: { code: '', - user: null, + user: undefined, is_loading: false, is_succesfull: false, - error: null, + error: '', }, register_social: { errors: { - username: 'and this', - password: 'dislike this', + username: '', + password: '', }, - error: 'dont like this one', - token: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJEYXRhIjp7IlByb3ZpZGVyIjoiZ29vZ2xlIiwiSWQiOiJma2F0dXJvdkBpY2Vyb2NrZGV2LmNvbSIsIkVtYWlsIjoiZmthdHVyb3ZAaWNlcm9ja2Rldi5jb20iLCJUb2tlbiI6InlhMjkuYTBBZkg2U01EeXFGdlRaTExXckhsQm1QdGZIOFNIVGQteWlSYTFKSXNmVXluY2F6MTZ5UGhjRmxydTlDMWFtTEg0aHlHRzNIRkhrVGU0SXFUS09hVVBEREdqR2JQRVFJbGpPME9UbUp2T2RrdEtWNDVoUGpJcTB1cHVLc003UWJLSm1oRWhkMEFVa3YyejVHWlNSMjhaM2VOZVdwTEVYSGV0MW1yNyIsIkZldGNoZWQiOnsiUHJvdmlkZXIiOiJnb29nbGUiLCJJZCI6OTIyMzM3MjAzNjg1NDc3NTgwNywiTmFtZSI6IkZlZG9yIEthdHVyb3YiLCJQaG90byI6Imh0dHBzOi8vbGg2Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8ta1VMYXh0VV9jZTAvQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQU1adXVjbkEycTFReU1WLUN0RUtBclRhQzgydE52NTM2QS9waG90by5qcGcifX0sIlR5cGUiOiJvYXV0aF9jbGFpbSJ9.r1MY994BC_g4qRDoDoyNmwLs0qRzBLx6_Ez-3mHQtwg', + error: '', + token: '', is_loading: false, }, }; diff --git a/src/redux/auth/sagas.ts b/src/redux/auth/sagas.ts index 89c75b15..40d8912b 100644 --- a/src/redux/auth/sagas.ts +++ b/src/redux/auth/sagas.ts @@ -1,5 +1,5 @@ import { call, delay, put, select, takeEvery, takeLatest } from 'redux-saga/effects'; -import { AUTH_USER_ACTIONS, EMPTY_USER, USER_ERRORS, USER_ROLES } from '~/redux/auth/constants'; +import { AUTH_USER_ACTIONS, EMPTY_USER, USER_ROLES } from '~/redux/auth/constants'; import { authAttachSocial, authDropSocial, @@ -48,49 +48,37 @@ import { selectAuthRestore, selectAuthUpdates, selectAuthUser, - selectToken, } from './selectors'; -import { IResultWithStatus, OAUTH_EVENT_TYPES, Unwrap } from '../types'; -import { IAuthState, IUser } from './types'; +import { OAUTH_EVENT_TYPES, Unwrap } from '../types'; import { 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 { - const access = yield select(selectToken); - - const result = yield call(requestAction, { access, ...props }); - - if (result && result.status === 401) { - return { error: USER_ERRORS.UNAUTHORIZED, data: {} }; - } - - return result; +function* setTokenSaga({ token }: ReturnType) { + localStorage.setItem('token', token); } function* sendLoginRequestSaga({ username, password }: ReturnType) { if (!username || !password) return; - const { - error, - data: { token, user }, - }: IResultWithStatus<{ token: string; user: IUser }> = yield call(apiUserLogin, { - username, - password, - }); + try { + const { token, user }: Unwrap = yield call(apiUserLogin, { + username, + password, + }); - if (error) { - yield put(userSetLoginError(error)); - return; + yield put(authSetToken(token)); + yield put(authSetUser({ ...user, is_user: true })); + yield put(authLoggedIn()); + yield put(modalSetShown(false)); + } catch (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 = yield call(apiAuthGetUser); - if (error) { + yield put(authSetUser({ ...user, is_user: true })); + } catch (e) { yield put( authSetUser({ ...EMPTY_USER, is_user: false, }) ); - - return; } - - yield put(authSetUser({ ...user, is_user: true })); } function* checkUserSaga({ key }: RehydrateAction) { @@ -126,44 +109,43 @@ function* gotPostMessageSaga({ token }: ReturnType) { yield put(authSetToken(token)); yield call(refreshUser); - const { is_shown, dialog }: IModalState = yield select(selectModal); + const { is_shown, dialog }: ReturnType = yield select(selectModal); if (is_shown && dialog === DIALOGS.LOGIN) yield put(modalSetShown(false)); } function* logoutSaga() { - yield put(authSetToken(null)); + yield put(authSetToken('')); yield put(authSetUser({ ...EMPTY_USER })); yield put( authSetUpdates({ - last: null, + last: '', notifications: [], }) ); } -function* loadProfile({ username }: ReturnType) { +function* loadProfile({ username }: ReturnType): SagaIterator { yield put(authSetProfile({ is_loading: true })); - const { - error, - data: { user }, - } = yield call(reqWrapper, apiAuthGetUserProfile, { username }); + try { + const { user }: Unwrap = yield call(apiAuthGetUserProfile, { + username, + }); - if (error || !user) { + yield put(authSetProfile({ is_loading: false, user })); + yield put(messagesSet({ messages: [] })); + return true; + } catch (error) { return false; } - - yield put(authSetProfile({ is_loading: false, user })); - yield put(messagesSet({ messages: [] })); - return true; } function* openProfile({ username, tab = 'profile' }: ReturnType) { yield put(modalShowDialog(DIALOGS.PROFILE)); yield put(authSetProfile({ tab })); - const success: boolean = yield call(loadProfile, authLoadProfile(username)); + const success: Unwrap = yield call(loadProfile, authLoadProfile(username)); if (!success) { return yield put(modalSetShown(false)); @@ -171,42 +153,41 @@ function* openProfile({ username, tab = 'profile' }: ReturnType = yield select(selectAuthUser); + try { + const user: ReturnType = yield select(selectAuthUser); - if (!user || !user.is_user || user.role === USER_ROLES.GUEST || !user.id) return; + if (!user || !user.is_user || user.role === USER_ROLES.GUEST || !user.id) return; - const modal: IModalState = yield select(selectModal); - const profile: IAuthState['profile'] = yield select(selectAuthProfile); - const { last, boris_commented_at }: IAuthState['updates'] = yield select(selectAuthUpdates); - const exclude_dialogs = - modal.is_shown && modal.dialog === DIALOGS.PROFILE && profile.user.id ? profile.user.id : null; - - const { error, data }: Unwrap> = 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 = yield select(selectModal); + const profile: ReturnType = yield select(selectAuthProfile); + const { last, boris_commented_at }: ReturnType = yield select( + selectAuthUpdates ); - } + const exclude_dialogs = + modal.is_shown && modal.dialog === DIALOGS.PROFILE && profile.user?.id ? profile.user.id : 0; - 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 = 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) { if (!Date.parse(last_seen_messages)) return; - yield call(reqWrapper, apiUpdateUser, { user: { last_seen_messages } }); + yield call(apiUpdateUser, { user: { last_seen_messages } }); } -function* patchUser({ user }: ReturnType) { - const me = yield select(selectAuthUser); +function* patchUser(payload: ReturnType) { + const me: ReturnType = yield select(selectAuthUser); - const { error, data } = yield call(reqWrapper, apiUpdateUser, { user }); + try { + const { user }: Unwrap = 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) { 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) { - 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 = 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) { - 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 = 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 = 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> = yield call( - reqWrapper, - apiGetSocials, - {} - ); - - if (error) { - throw new Error(error); - } - - yield put(authSetSocials({ is_loading: false, accounts: data.accounts, error: '' })); - } catch (e) { - yield put(authSetSocials({ is_loading: false, error: e.toString() })); + yield put(authSetSocials({ is_loading: true, error: '' })); + const data: Unwrap = yield call(apiGetSocials); + yield put(authSetSocials({ accounts: data.accounts })); + } catch (error) { + yield put(authSetSocials({ error: error.message })); + } finally { + yield put(authSetSocials({ is_loading: false })); } } +// TODO: start from here function* dropSocial({ provider, id }: ReturnType) { try { yield put(authSetSocials({ error: '' })); - const { error }: Unwrap> = yield call( - reqWrapper, - apiDropSocial, - { id, provider } - ); - - if (error) { - throw new Error(error); - } + yield call(apiDropSocial, { + id, + provider, + }); yield call(getSocials); - } catch (e) { - yield put(authSetSocials({ error: e.message })); + } catch (error) { + yield put(authSetSocials({ error: error.message })); } } function* attachSocial({ token }: ReturnType) { - if (!token) return; - try { + if (!token) return; + yield put(authSetSocials({ error: '', is_loading: true })); - const { data, error }: Unwrap> = yield call( - reqWrapper, - apiAttachSocial, - { token } - ); - - if (error) { - throw new Error(error); - } + const data: Unwrap = yield call(apiAttachSocial, { + token, + }); const { socials: { accounts }, }: ReturnType = yield select(selectAuthProfile); if (accounts.some(it => it.id === data.account.id && it.provider === data.account.provider)) { - yield put(authSetSocials({ is_loading: false })); - } else { - yield put(authSetSocials({ is_loading: false, accounts: [...accounts, data.account] })); + return; } + + yield put(authSetSocials({ accounts: [...accounts, data.account] })); } catch (e) { - yield put(authSetSocials({ is_loading: false, error: e.message })); + yield put(authSetSocials({ error: e.message })); + } finally { + yield put(authSetSocials({ is_loading: false })); } } @@ -368,21 +338,9 @@ function* loginWithSocial({ token }: ReturnType) { try { yield put(userSetLoginError('')); - const { - data, - error, - }: Unwrap> = 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 = yield call(apiLoginWithSocial, { + token, + }); if (data.token) { yield put(authSetToken(data.token)); @@ -390,8 +348,21 @@ function* loginWithSocial({ token }: ReturnType) { yield put(modalSetShown(false)); return; } - } catch (e) { - yield put(userSetLoginError(e.message)); + } catch (error) { + const { dialog }: ReturnType = yield select(selectModal); + const data = (error as AxiosError<{ + needs_register: boolean; + errors: Record<'username' | 'password', string>; + }>).response?.data; + + // Backend asks us for account registration + if (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> = yield select( + const { token }: ReturnType = yield select( selectAuthRegisterSocial ); - const { data, error }: Unwrap> = 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 = yield call(apiLoginWithSocial, { + token, + username, + password, + }); if (data.token) { yield put(authSetToken(data.token)); @@ -439,8 +401,18 @@ function* authRegisterSocial({ username, password }: ReturnType; + }>).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); diff --git a/src/redux/auth/selectors.ts b/src/redux/auth/selectors.ts index aa3aa475..6f6ed43b 100644 --- a/src/redux/auth/selectors.ts +++ b/src/redux/auth/selectors.ts @@ -5,7 +5,7 @@ export const selectUser = (state: IState) => state.auth.user; export const selectToken = (state: IState) => state.auth.token; export const selectAuthLogin = (state: IState) => state.auth.login; export const selectAuthProfile = (state: IState) => state.auth.profile; -export const selectAuthProfileUsername = (state: IState) => state.auth.profile.user.username; +export const selectAuthProfileUsername = (state: IState) => state.auth.profile.user?.username; export const selectAuthUser = (state: IState) => state.auth.user; export const selectAuthUpdates = (state: IState) => state.auth.updates; export const selectAuthRestore = (state: IState) => state.auth.restore; diff --git a/src/redux/auth/transforms.ts b/src/redux/auth/transforms.ts index 513b07fe..69c68fc8 100644 --- a/src/redux/auth/transforms.ts +++ b/src/redux/auth/transforms.ts @@ -1,13 +1,18 @@ import { IResultWithStatus } from '~/redux/types'; import { HTTP_RESPONSES } from '~/utils/api'; -export const userLoginTransform = ({ status, data, error }: IResultWithStatus): IResultWithStatus => { +export const userLoginTransform = ({ + status, + data, + error, +}: IResultWithStatus): IResultWithStatus => { switch (true) { - case (status === HTTP_RESPONSES.UNAUTHORIZED || !data.token) && status !== HTTP_RESPONSES.CONNECTION_REFUSED: + case (status === HTTP_RESPONSES.UNAUTHORIZED || !data.token) && + status !== HTTP_RESPONSES.CONNECTION_REFUSED: return { status, data, error: 'Пользователь не найден' }; case status === 200: - return { status, data, error: null }; + return { status, data, error: '' }; default: return { status, data, error: error || 'Неизвестная ошибка' }; diff --git a/src/redux/auth/types.ts b/src/redux/auth/types.ts index 52d417d4..55d7ae82 100644 --- a/src/redux/auth/types.ts +++ b/src/redux/auth/types.ts @@ -1,4 +1,4 @@ -import { IFile, INotification } from '../types'; +import { IFile, INotification, IResultWithStatus } from '../types'; export interface IToken { access: string; @@ -10,8 +10,8 @@ export interface IUser { username: string; email: string; role: string; - photo: IFile; - cover: IFile; + photo?: IFile; + cover?: IFile; name: string; fullname: string; description: string; @@ -53,7 +53,7 @@ export type IAuthState = Readonly<{ tab: 'profile' | 'messages' | 'settings'; is_loading: boolean; - user: IUser; + user?: IUser; patch_errors: Record; socials: { @@ -65,7 +65,7 @@ export type IAuthState = Readonly<{ restore: { code: string; - user: Pick; + user?: Pick; is_loading: boolean; is_succesfull: boolean; error: string; @@ -81,3 +81,52 @@ export type IAuthState = Readonly<{ is_loading: boolean; }; }>; + +export type ApiWithTokenRequest = { access: string }; + +export type ApiUserLoginRequest = Record<'username' | 'password', string>; +export type ApiUserLoginResult = { token: string; user: IUser }; + +export type ApiAuthGetUserRequest = {}; +export type ApiAuthGetUserResult = { user: IUser }; + +export type ApiUpdateUserRequest = { user: Partial }; +export type ApiUpdateUserResult = { user: IUser; errors: Record, string> }; + +export type ApiAuthGetUserProfileRequest = { username: string }; +export type ApiAuthGetUserProfileResult = { user: IUser }; + +export type ApiAuthGetUpdatesRequest = { + exclude_dialogs: number; + last: string; +}; +export type ApiAuthGetUpdatesResult = { + notifications: INotification[]; + boris: { commented_at: string }; +}; + +export type ApiCheckRestoreCodeRequest = { code: string }; +export type ApiCheckRestoreCodeResult = { user: IUser }; + +export type ApiRestoreCodeRequest = { code: string; password: string }; +export type ApiRestoreCodeResult = { token: string; user: IUser }; + +export type ApiGetSocialsResult = { accounts: ISocialAccount[] }; + +export type ApiDropSocialRequest = { id: string; provider: string }; +export type ApiDropSocialResult = { accounts: ISocialAccount[] }; + +export type ApiAttachSocialRequest = { token: string }; +export type ApiAttachSocialResult = { account: ISocialAccount }; + +export type ApiLoginWithSocialRequest = { + token: string; + username?: string; + password?: string; +}; + +export type ApiLoginWithSocialResult = { + token: string; + errors: Record; + needs_register: boolean; +}; diff --git a/src/redux/boris/api.ts b/src/redux/boris/api.ts index 3a8bd25f..c1bd5a72 100644 --- a/src/redux/boris/api.ts +++ b/src/redux/boris/api.ts @@ -1,13 +1,10 @@ import git from '~/stats/git.json'; import { API } from '~/constants/api'; -import { api, resultMiddleware, errorMiddleware } from '~/utils/api'; +import { api, resultMiddleware, errorMiddleware, cleanResult } from '~/utils/api'; import { IBorisState, IStatBackend } from './reducer'; import { IResultWithStatus } from '../types'; -export const getBorisGitStats = (): Promise => Promise.resolve(git); +export const getBorisGitStats = () => Promise.resolve(git); -export const getBorisBackendStats = (): Promise> => - api - .get(API.BORIS.GET_BACKEND_STATS) - .then(resultMiddleware) - .catch(errorMiddleware); +export const getBorisBackendStats = () => + api.get(API.BORIS.GET_BACKEND_STATS).then(cleanResult); diff --git a/src/redux/boris/reducer.ts b/src/redux/boris/reducer.ts index cb2ff72e..2032c793 100644 --- a/src/redux/boris/reducer.ts +++ b/src/redux/boris/reducer.ts @@ -31,7 +31,7 @@ export type IStatBackend = { export type IBorisState = Readonly<{ stats: { git: Partial[]; - backend: IStatBackend; + backend?: IStatBackend; is_loading: boolean; }; }>; @@ -39,7 +39,7 @@ export type IBorisState = Readonly<{ const BORIS_INITIAL_STATE: IBorisState = { stats: { git: [], - backend: null, + backend: undefined, is_loading: false, }, }; diff --git a/src/redux/boris/sagas.ts b/src/redux/boris/sagas.ts index 1f92b6a3..a0b1d003 100644 --- a/src/redux/boris/sagas.ts +++ b/src/redux/boris/sagas.ts @@ -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> = yield call(getBorisGitStats); - const backend: Unwrap> = yield call( - getBorisBackendStats - ); + yield put(borisSetStats({ is_loading: true })); - yield put(borisSetStats({ git, backend: backend.data, is_loading: false })); + const git: Unwrap = yield call(getBorisGitStats); + const backend: Unwrap = 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 })); } } diff --git a/src/redux/flow/api.ts b/src/redux/flow/api.ts index f23a8101..d826fb6b 100644 --- a/src/redux/flow/api.ts +++ b/src/redux/flow/api.ts @@ -1,8 +1,8 @@ -import { api, configWithToken, resultMiddleware, errorMiddleware } from '~/utils/api'; +import { api, cleanResult, configWithToken } from '~/utils/api'; import { INode, IResultWithStatus } from '../types'; import { API } from '~/constants/api'; -import { flowSetCellView } from '~/redux/flow/actions'; -import { IFlowState } from './reducer'; +import { PostCellViewRequest, PostCellViewResult } from '~/redux/node/types'; +import { GetSearchResultsRequest, GetSearchResultsResult } from '~/redux/flow/types'; export const postNode = ({ access, @@ -11,32 +11,14 @@ export const postNode = ({ access: string; node: INode; }): Promise> => - api - .post(API.NODE.SAVE, { node }, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + api.post(API.NODE.SAVE, { node }, configWithToken(access)).then(cleanResult); -export const postCellView = ({ - id, - flow, - access, -}: ReturnType & { access: string }): Promise> => +export const postCellView = ({ id, flow }: PostCellViewRequest) => api - .post(API.NODE.SET_CELL_VIEW(id), { flow }, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.NODE.SET_CELL_VIEW(id), { flow }) + .then(cleanResult); -export const getSearchResults = ({ - access, - text, - skip = 0, -}: IFlowState['search'] & { - access: string; - skip: number; -}): Promise> => +export const getSearchResults = ({ text, skip = 0 }: GetSearchResultsRequest) => api - .get(API.SEARCH.NODES, configWithToken(access, { params: { text, skip } })) - .then(resultMiddleware) - .catch(errorMiddleware); + .get(API.SEARCH.NODES, { params: { text, skip } }) + .then(cleanResult); diff --git a/src/redux/flow/reducer.ts b/src/redux/flow/reducer.ts index 1bf96683..7ab73585 100644 --- a/src/redux/flow/reducer.ts +++ b/src/redux/flow/reducer.ts @@ -31,7 +31,7 @@ const INITIAL_STATE: IFlowState = { is_loading_more: false, }, is_loading: false, - error: null, + error: '', }; export default createReducer(INITIAL_STATE, FLOW_HANDLERS); diff --git a/src/redux/flow/sagas.ts b/src/redux/flow/sagas.ts index 0929999b..09f4ac8d 100644 --- a/src/redux/flow/sagas.ts +++ b/src/redux/flow/sagas.ts @@ -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) { - 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) { - yield put( - flowSetSearch({ - ...search, - is_loading: !!search.text, - }) - ); - - if (!search.text) return; - - yield delay(500); - - const { data, error }: Unwrap> = 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 = yield select(selectFlowNodes); + + if (stored.length) { + hideLoader(); + } + + yield put(flowSetFlow({ is_loading: true })); + + const { + before = [], + after = [], + heroes = [], + recent = [], + updated = [], + }: Unwrap = yield call(getNodeDiff, { + start: new Date().toISOString(), + end: new Date().toISOString(), + with_heroes: true, + with_updated: true, + with_recent: true, + with_valid: false, + }); + + const result = uniq([...(before || []), ...(after || [])]); + + yield put(flowSetFlow({ is_loading: false, nodes: result })); + + if (heroes.length) yield put(flowSetHeroes(heroes)); + if (recent.length) yield put(flowSetRecent(recent)); + if (updated.length) yield put(flowSetUpdated(updated)); + + if (!stored.length) hideLoader(); + } catch (error) { + console.log(error); + } +} + +function* onSetCellView({ id, flow }: ReturnType) { + try { + const nodes: ReturnType = yield select(selectFlowNodes); + yield put(flowSetNodes(nodes.map(node => (node.id === id ? { ...node, flow } : node)))); + yield call(postCellView, { id, flow }); + } catch (error) { + console.log(error); + } +} + +function* getMore() { + try { + yield put(flowSetFlow({ is_loading: true })); + const nodes: ReturnType = yield select(selectFlowNodes); + + const start = nodes && nodes[0] && nodes[0].created_at; + const end = nodes && nodes[nodes.length - 1] && nodes[nodes.length - 1].created_at; + + const data: Unwrap = yield call(getNodeDiff, { + start, + end, + with_heroes: false, + with_updated: true, + with_recent: true, + with_valid: true, + }); + + const result = uniq([ + ...(data.before || []), + ...(data.valid ? nodes.filter(node => data.valid.includes(node.id)) : nodes), + ...(data.after || []), + ]); + + yield put( + flowSetFlow({ + is_loading: false, + nodes: result, + ...(data.recent ? { recent: data.recent } : {}), + ...(data.updated ? { updated: data.updated } : {}), + }) + ); + + yield delay(1000); + } catch (error) {} +} + +function* changeSearch({ search }: ReturnType) { + try { + yield put( + flowSetSearch({ + ...search, + is_loading: !!search.text, + }) + ); + + if (!search.text) return; + + yield delay(500); + + const data: Unwrap = yield call(getSearchResults, { + text: search.text, + }); + + yield put( + flowSetSearch({ + results: data.nodes, + total: data.total, + }) + ); + } catch (error) { + yield put(flowSetSearch({ results: [], total: 0 })); + } finally { + yield put(flowSetSearch({ is_loading: false })); + } } function* loadMoreSearch() { - yield put( - flowSetSearch({ - is_loading_more: true, - }) - ); + try { + yield put( + flowSetSearch({ + is_loading_more: true, + }) + ); - const { search }: ReturnType = yield select(selectFlow); + const { search }: ReturnType = yield select(selectFlow); - const { - result, - delay, - }: { result: Unwrap>; delay: any } = yield race({ - result: call(reqWrapper, getSearchResults, { - ...search, - skip: search.results.length, - }), - delay: take(FLOW_ACTIONS.CHANGE_SEARCH), - }); + const { result, delay }: { result: Unwrap; delay: any } = yield race({ + result: call(getSearchResults, { + ...search, + skip: search.results.length, + }), + delay: take(FLOW_ACTIONS.CHANGE_SEARCH), + }); - if (delay || result.error) { - return put(flowSetSearch({ is_loading_more: false })); + if (delay) { + return; + } + + yield put( + flowSetSearch({ + results: [...search.results, ...result.nodes], + total: result.total, + }) + ); + } catch (error) { + yield put( + flowSetSearch({ + is_loading_more: false, + }) + ); } - - yield put( - flowSetSearch({ - results: [...search.results, ...result.data.nodes], - total: result.data.total, - is_loading_more: false, - }) - ); } export default function* nodeSaga() { diff --git a/src/redux/flow/types.ts b/src/redux/flow/types.ts new file mode 100644 index 00000000..8a5fc788 --- /dev/null +++ b/src/redux/flow/types.ts @@ -0,0 +1,10 @@ +import { INode } from '~/redux/types'; + +export type GetSearchResultsRequest = { + text: string; + skip?: number; +}; +export type GetSearchResultsResult = { + nodes: INode[]; + total: number; +}; diff --git a/src/redux/messages/api.ts b/src/redux/messages/api.ts index ff69487d..2e224ab3 100644 --- a/src/redux/messages/api.ts +++ b/src/redux/messages/api.ts @@ -1,48 +1,29 @@ -import { IMessage, IResultWithStatus } from '~/redux/types'; -import { api, configWithToken, errorMiddleware, resultMiddleware } from '~/utils/api'; +import { api, cleanResult } from '~/utils/api'; import { API } from '~/constants/api'; +import { + ApiDeleteMessageRequest, + ApiDeleteMessageResult, + ApiGetUserMessagesRequest, + ApiGetUserMessagesResponse, + ApiSendMessageRequest, + ApiSendMessageResult, +} from '~/redux/messages/types'; -export const apiMessagesGetUserMessages = ({ - access, - username, - after, - before, -}: { - access: string; - username: string; - after?: string; - before?: string; -}): Promise> => +export const apiGetUserMessages = ({ username, after, before }: ApiGetUserMessagesRequest) => api - .get(API.USER.MESSAGES(username), configWithToken(access, { params: { after, before } })) - .then(resultMiddleware) - .catch(errorMiddleware); + .get(API.USER.MESSAGES(username), { + params: { after, before }, + }) + .then(cleanResult); -export const apiMessagesSendMessage = ({ - access, - username, - message, -}): Promise> => +export const apiSendMessage = ({ username, message }: ApiSendMessageRequest) => api - .post(API.USER.MESSAGE_SEND(username), { message }, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.USER.MESSAGE_SEND(username), { message }) + .then(cleanResult); -export const apiMessagesDeleteMessage = ({ - access, - username, - id, - is_locked, -}: { - access: string; - username: string; - id: number; - is_locked: boolean; -}): Promise> => +export const apiDeleteMessage = ({ username, id, is_locked }: ApiDeleteMessageRequest) => api - .delete( - API.USER.MESSAGE_DELETE(username, id), - configWithToken(access, { params: { is_locked } }) - ) - .then(resultMiddleware) - .catch(errorMiddleware); + .delete(API.USER.MESSAGE_DELETE(username, id), { + params: { is_locked }, + }) + .then(cleanResult); diff --git a/src/redux/messages/index.ts b/src/redux/messages/index.ts index c868715b..914eb0e3 100644 --- a/src/redux/messages/index.ts +++ b/src/redux/messages/index.ts @@ -12,7 +12,7 @@ export interface IMessagesState { const INITIAL_STATE: IMessagesState = { is_loading_messages: true, is_sending_messages: false, - error: null, + error: '', messages: [], }; diff --git a/src/redux/messages/sagas.ts b/src/redux/messages/sagas.ts index c45bc6ee..6c2f44d7 100644 --- a/src/redux/messages/sagas.ts +++ b/src/redux/messages/sagas.ts @@ -5,14 +5,9 @@ import { selectAuthProfileUsername, selectAuthUpdates, } from '~/redux/auth/selectors'; -import { - apiMessagesDeleteMessage, - apiMessagesGetUserMessages, - apiMessagesSendMessage, -} from '~/redux/messages/api'; +import { apiDeleteMessage, apiGetUserMessages, apiSendMessage } from '~/redux/messages/api'; import { ERRORS } from '~/constants/errors'; import { IMessageNotification, Unwrap } from '~/redux/types'; -import { 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) { - const { messages }: ReturnType = yield select(selectMessages); + try { + const { messages }: ReturnType = yield select(selectMessages); - yield put( - messagesSet({ - is_loading_messages: true, - messages: - messages && - messages.length > 0 && - (messages[0].to.username === username || messages[0].from.username === username) - ? messages - : [], - }) - ); - - const { - error, - data, - }: Unwrap> = yield call( - 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 = yield call(apiGetUserMessages, { + username, + }); - const { notifications }: ReturnType = yield select(selectAuthUpdates); + yield put(messagesSet({ is_loading_messages: false, messages: data.messages })); - // clear viewed message from notifcation list - const filtered = notifications.filter( - notification => - notification.type !== 'message' || - (notification as IMessageNotification).content.from.username !== username - ); + const { notifications }: ReturnType = yield select(selectAuthUpdates); - if (filtered.length !== notifications.length) { - yield put(authSetUpdates({ notifications: filtered })); + // clear viewed message from notifcation list + const filtered = notifications.filter( + notification => + notification.type !== 'message' || + (notification as IMessageNotification)?.content?.from?.username !== username + ); + + if (filtered.length !== notifications.length) { + yield put(authSetUpdates({ notifications: filtered })); + } + } catch (error) { + messagesSet({ + error: error.message || ERRORS.EMPTY_RESPONSE, + }); + } finally { + yield put( + messagesSet({ + is_loading_messages: false, + }) + ); } } function* sendMessage({ message, onSuccess }: ReturnType) { - const username: ReturnType = yield select( - selectAuthProfileUsername - ); + try { + const username: ReturnType = yield select( + selectAuthProfileUsername + ); - if (!username) return; + if (!username) return; - yield put(messagesSet({ is_sending_messages: true, error: null })); + yield put(messagesSet({ is_sending_messages: true, error: '' })); - const { error, data }: Unwrap> = yield call( - reqWrapper, - apiMessagesSendMessage, - { + const data: Unwrap = yield call(apiSendMessage, { username, message, + }); + + const { user }: ReturnType = yield select(selectAuthProfile); + + if (user?.username !== username) { + return yield put(messagesSet({ is_sending_messages: false })); } - ); - if (error || !data.message) { - return yield put( - messagesSet({ - is_sending_messages: false, - error: error || ERRORS.EMPTY_RESPONSE, - }) - ); - } + const { messages }: ReturnType = yield select(selectMessages); - const { user }: ReturnType = yield select(selectAuthProfile); + if (message.id && message.id > 0) { + // modified + yield put( + messagesSet({ + is_sending_messages: false, + messages: messages.map(item => (item.id === message.id ? data.message : item)), + }) + ); + } else { + // created + yield put( + messagesSet({ + is_sending_messages: false, + messages: [data.message, ...messages], + }) + ); + } - if (user.username !== username) { - return yield put(messagesSet({ is_sending_messages: false })); - } - - const { messages }: ReturnType = yield select(selectMessages); - - if (message.id > 0) { - // modified + onSuccess(); + } catch (error) { + messagesSet({ + error: error.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) { - const username: ReturnType = yield select( - selectAuthProfileUsername - ); + try { + const username: ReturnType = yield select( + selectAuthProfileUsername + ); - if (!username) return; + if (!username) return; - yield put(messagesSet({ is_sending_messages: true, error: null })); + yield put(messagesSet({ is_sending_messages: true, error: '' })); - const { error, data }: Unwrap> = yield call( - reqWrapper, - apiMessagesDeleteMessage, - { + const data: Unwrap = yield call(apiDeleteMessage, { username, id, is_locked, - } - ); + }); - if (error || !data.message) { - return yield put( + const currentUsername: ReturnType = yield select( + selectAuthProfileUsername + ); + + if (currentUsername !== username) { + return yield put(messagesSet({ is_sending_messages: false })); + } + + const { messages }: ReturnType = yield select(selectMessages); + + yield put( messagesSet({ is_sending_messages: false, + messages: messages.map(item => (item.id === id ? data.message : item)), + }) + ); + } catch (error) { + messagesSet({ + error: error.message || ERRORS.EMPTY_RESPONSE, + }); + } finally { + yield put( + messagesSet({ + is_loading_messages: false, }) ); } - - const currentUsername: ReturnType = yield select( - selectAuthProfileUsername - ); - - if (currentUsername !== username) { - return yield put(messagesSet({ is_sending_messages: false })); - } - - const { messages }: ReturnType = yield select(selectMessages); - - yield put( - messagesSet({ - is_sending_messages: false, - messages: messages.map(item => (item.id === id ? data.message : item)), - }) - ); } function* refreshMessages({}: ReturnType) { - const username: ReturnType = yield select( - selectAuthProfileUsername - ); + try { + const username: ReturnType = yield select( + selectAuthProfileUsername + ); - if (!username) return; + if (!username) return; - const { messages }: ReturnType = yield select(selectMessages); + const { messages }: ReturnType = yield select(selectMessages); - yield put(messagesSet({ is_loading_messages: true })); + yield put(messagesSet({ is_loading_messages: true })); - const after = messages.length > 0 ? messages[0].created_at : undefined; + const after = messages.length > 0 ? messages[0].created_at : undefined; - const { - data, - error, - }: Unwrap> = yield call( - reqWrapper, - apiMessagesGetUserMessages, - { username, after } - ); + const data: Unwrap = yield call(apiGetUserMessages, { + username, + after, + }); - yield put(messagesSet({ is_loading_messages: false })); + yield put(messagesSet({ is_loading_messages: false })); - if (error) { - return yield put( + if (!data.messages || !data.messages.length) return; + + const newMessages = [...data.messages, ...messages].sort(sortCreatedAtDesc); + yield put(messagesSet({ messages: newMessages })); + } catch (error) { + messagesSet({ + error: error.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*() { diff --git a/src/redux/messages/types.ts b/src/redux/messages/types.ts new file mode 100644 index 00000000..519e7057 --- /dev/null +++ b/src/redux/messages/types.ts @@ -0,0 +1,26 @@ +import { IMessage } from '~/redux/types'; + +export type ApiGetUserMessagesRequest = { + username: string; + after?: string; + before?: string; +}; +export type ApiGetUserMessagesResponse = { messages: IMessage[] }; + +export type ApiSendMessageRequest = { + username: string; + message: Partial; +}; +export type ApiSendMessageResult = { + message: IMessage; +}; + +export type ApiDeleteMessageRequest = { + username: string; + id: number; + is_locked: boolean; +}; + +export type ApiDeleteMessageResult = { + message: IMessage; +}; diff --git a/src/redux/modal/index.ts b/src/redux/modal/index.ts index 7b9dfaee..95aa0cb0 100644 --- a/src/redux/modal/index.ts +++ b/src/redux/modal/index.ts @@ -14,7 +14,7 @@ export interface IModalState { const INITIAL_STATE: IModalState = { is_shown: false, - dialog: null, + dialog: '', photoswipe: { images: [], index: 0, diff --git a/src/redux/node/actions.ts b/src/redux/node/actions.ts index 776cb852..05012f98 100644 --- a/src/redux/node/actions.ts +++ b/src/redux/node/actions.ts @@ -17,7 +17,7 @@ export const nodeSetSaveErrors = (errors: IValidationErrors) => ({ type: NODE_ACTIONS.SET_SAVE_ERRORS, }); -export const nodeGotoNode = (id: number, node_type: INode['type']) => ({ +export const nodeGotoNode = (id: INode['id'], node_type: INode['type']) => ({ id, node_type, type: NODE_ACTIONS.GOTO_NODE, @@ -55,11 +55,6 @@ export const nodePostLocalComment = ( type: NODE_ACTIONS.POST_COMMENT, }); -export const nodeCancelCommentEdit = (id: number) => ({ - id, - type: NODE_ACTIONS.CANCEL_COMMENT_EDIT, -}); - export const nodeSetSendingComment = (is_sending_comment: boolean) => ({ is_sending_comment, type: NODE_ACTIONS.SET_SENDING_COMMENT, diff --git a/src/redux/node/api.ts b/src/redux/node/api.ts index dc13ccc7..0ae8cb4b 100644 --- a/src/redux/node/api.ts +++ b/src/redux/node/api.ts @@ -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> => - api - .post(API.NODE.SAVE, node, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + errors: Record; +}; -export const getNodes = ({ - from = null, - access, -}: { - from?: string; - access: string; -}): Promise> => - 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(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> => +}: GetNodeDiffRequest) => api - .get( - API.NODE.GET_DIFF, - configWithToken(access, { - params: { - start, - end, - take, - with_heroes, - with_updated, - with_recent, - with_valid, - }, - }) - ) - .then(resultMiddleware) - .catch(errorMiddleware); + .get(API.NODE.GET_DIFF, { + params: { + start, + end, + take, + with_heroes, + with_updated, + with_recent, + with_valid, + }, + }) + .then(cleanResult); -export const getNode = ({ - id, - access, -}: { - id: string | number; - access: string; -}): Promise> => - api - .get(API.NODE.GET_NODE(id), configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiGetNode = ({ id }: ApiGetNodeRequest) => + api.get(API.NODE.GET_NODE(id)).then(cleanResult); -export const postNodeComment = ({ - id, - data, - access, -}: { - access: string; - id: number; - data: IComment; -}): Promise> => - api - .post(API.NODE.COMMENT(id), data, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiPostComment = ({ id, data }: ApiPostCommentRequest) => + api.post(API.NODE.COMMENT(id), data).then(cleanResult); -export const getNodeComments = ({ +export const apiGetNodeComments = ({ id, - access, take = COMMENTS_DISPLAY, skip = 0, -}: { - id: number; - access: string; - take?: number; - skip?: number; -}): Promise> => +}: ApiGetNodeCommentsRequest) => api - .get(API.NODE.COMMENT(id), configWithToken(access, { params: { take, skip } })) - .then(resultMiddleware) - .catch(errorMiddleware); + .get(API.NODE.COMMENT(id), { params: { take, skip } }) + .then(cleanResult); -export const getNodeRelated = ({ - id, - access, -}: { - id: number; - access: string; -}): Promise> => - api - .get(API.NODE.RELATED(id), configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiGetNodeRelated = ({ id }: ApiGetNodeRelatedRequest) => + api.get(API.NODE.RELATED(id)).then(cleanResult); -export const updateNodeTags = ({ - id, - tags, - access, -}: ReturnType & { access: string }): Promise> => +export const apiPostNodeTags = ({ id, tags }: ApiPostNodeTagsRequest) => api - .post(API.NODE.UPDATE_TAGS(id), { tags }, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.NODE.UPDATE_TAGS(id), { tags }) + .then(cleanResult); -export const postNodeLike = ({ - id, - access, -}: ReturnType & { access: string }): Promise> => - api - .post(API.NODE.POST_LIKE(id), {}, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiPostNodeLike = ({ id }: ApiPostNodeLikeRequest) => + api.post(API.NODE.POST_LIKE(id)).then(cleanResult); -export const postNodeStar = ({ - id, - access, -}: ReturnType & { access: string }): Promise> => - api - .post(API.NODE.POST_STAR(id), {}, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); +export const apiPostNodeHeroic = ({ id }: ApiPostNodeHeroicRequest) => + api.post(API.NODE.POST_HEROIC(id)).then(cleanResult); -export const postNodeLock = ({ - id, - is_locked, - access, -}: ReturnType & { access: string }): Promise> => +export const apiLockNode = ({ id, is_locked }: ApiLockNodeRequest) => api - .post(API.NODE.POST_LOCK(id), { is_locked }, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.NODE.POST_LOCK(id), { is_locked }) + .then(cleanResult); -export const postNodeLockComment = ({ - id, - is_locked, - current, - access, -}: ReturnType & { - access: string; - current: INode['id']; -}): Promise> => +export const apiLockComment = ({ id, is_locked, current }: ApiLockCommentRequest) => api - .post(API.NODE.POST_LOCK_COMMENT(current, id), { is_locked }, configWithToken(access)) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.NODE.LOCK_COMMENT(current, id), { is_locked }) + .then(cleanResult); diff --git a/src/redux/node/constants.ts b/src/redux/node/constants.ts index 81ef9a79..b5b11e48 100644 --- a/src/redux/node/constants.ts +++ b/src/redux/node/constants.ts @@ -1,4 +1,4 @@ -import { FC } from 'react'; +import { FC, ReactElement } from 'react'; import { IComment, INode, ValueOf } from '../types'; import { NodeImageSlideBlock } from '~/components/node/NodeImageSlideBlock'; import { NodeTextBlock } from '~/components/node/NodeTextBlock'; @@ -13,7 +13,7 @@ import { EditorImageUploadButton } from '~/components/editors/EditorImageUploadB import { EditorAudioUploadButton } from '~/components/editors/EditorAudioUploadButton'; import { EditorUploadCoverButton } from '~/components/editors/EditorUploadCoverButton'; import { modalShowPhotoswipe } from '../modal/actions'; -import { IEditorComponentProps } from '~/redux/node/types'; +import { IEditorComponentProps, NodeEditorProps } from '~/redux/node/types'; import { EditorFiller } from '~/components/editors/EditorFiller'; const prefix = 'NODE.'; @@ -29,7 +29,6 @@ export const NODE_ACTIONS = { LOCK: `${prefix}LOCK`, LOCK_COMMENT: `${prefix}LOCK_COMMENT`, EDIT_COMMENT: `${prefix}EDIT_COMMENT`, - CANCEL_COMMENT_EDIT: `${prefix}CANCEL_COMMENT_EDIT`, CREATE: `${prefix}CREATE`, LOAD_MORE_COMMENTS: `${prefix}LOAD_MORE_COMMENTS`, @@ -51,15 +50,13 @@ export const NODE_ACTIONS = { }; export const EMPTY_NODE: INode = { - id: null, - - user: null, - + id: 0, + user: undefined, title: '', files: [], - cover: null, - type: null, + cover: undefined, + type: undefined, blocks: [], tags: [], @@ -103,13 +100,16 @@ export const NODE_INLINES: INodeComponents = { }; export const EMPTY_COMMENT: IComment = { - id: null, + id: 0, text: '', files: [], - user: null, + user: undefined, }; -export const NODE_EDITORS = { +export const NODE_EDITORS: Record< + typeof NODE_TYPES[keyof typeof NODE_TYPES], + FC +> = { [NODE_TYPES.IMAGE]: ImageEditor, [NODE_TYPES.TEXT]: TextEditor, [NODE_TYPES.VIDEO]: VideoEditor, diff --git a/src/redux/node/reducer.ts b/src/redux/node/reducer.ts index eb0b3ecb..438524cd 100644 --- a/src/redux/node/reducer.ts +++ b/src/redux/node/reducer.ts @@ -8,12 +8,12 @@ export type INodeState = Readonly<{ current: INode; comments: IComment[]; related: { - albums: Record>; - similar: Partial; + albums: Record; + similar: INode[]; }; comment_data: Record; comment_count: number; - current_cover_image: IFile; + current_cover_image?: IFile; error: string; errors: Record; @@ -38,14 +38,17 @@ const INITIAL_STATE: INodeState = { }, comment_count: 0, comments: [], - related: null, - current_cover_image: null, + related: { + albums: {}, + similar: [], + }, + current_cover_image: undefined, is_loading: false, is_loading_comments: false, is_sending_comment: false, - error: null, + error: '', errors: {}, }; diff --git a/src/redux/node/sagas.ts b/src/redux/node/sagas.ts index 95f43082..fa077989 100644 --- a/src/redux/node/sagas.ts +++ b/src/redux/node/sagas.ts @@ -1,13 +1,16 @@ -import { all, call, delay, put, select, takeLatest, takeLeading } from 'redux-saga/effects'; +import { all, call, put, select, takeLatest, takeLeading } from 'redux-saga/effects'; import { push } from 'connected-react-router'; -import { omit } from 'ramda'; -import { COMMENTS_DISPLAY, EMPTY_COMMENT, EMPTY_NODE, NODE_ACTIONS, NODE_EDITOR_DATA } from './constants'; import { - nodeCancelCommentEdit, + COMMENTS_DISPLAY, + EMPTY_COMMENT, + EMPTY_NODE, + NODE_ACTIONS, + NODE_EDITOR_DATA, +} from './constants'; +import { nodeCreate, nodeEdit, - nodeEditComment, nodeGotoNode, nodeLike, nodeLoadNode, @@ -28,35 +31,34 @@ import { nodeUpdateTags, } from './actions'; import { - getNode, - getNodeComments, - getNodeRelated, - postNode, - postNodeComment, - postNodeLike, - postNodeLock, - postNodeLockComment, - postNodeStar, - updateNodeTags, + apiGetNode, + apiGetNodeComments, + apiGetNodeRelated, + apiLockComment, + apiLockNode, + apiPostComment, + apiPostNode, + apiPostNodeHeroic, + apiPostNodeLike, + apiPostNodeTags, } from './api'; -import { reqWrapper } from '../auth/sagas'; import { flowSetNodes, flowSetUpdated } from '../flow/actions'; import { ERRORS } from '~/constants/errors'; import { modalSetShown, modalShowDialog } from '../modal/actions'; import { selectFlow, selectFlowNodes } from '../flow/selectors'; import { URLS } from '~/constants/urls'; import { selectNode } from './selectors'; -import { INode, IResultWithStatus, Unwrap } from '../types'; +import { Unwrap } from '../types'; import { NODE_EDITOR_DIALOGS } from '~/constants/dialogs'; import { DIALOGS } from '~/redux/modal/constants'; -import { INodeState } from './reducer'; -import { IFlowState } from '../flow/reducer'; +import { has } from 'ramda'; export function* updateNodeEverywhere(node) { const { current: { id }, - }: INodeState = yield select(selectNode); - const flow_nodes: IFlowState['nodes'] = yield select(selectFlowNodes); + }: ReturnType = yield select(selectNode); + + const flow_nodes: ReturnType = yield select(selectFlowNodes); if (id === node.id) { yield put(nodeSetCurrent(node)); @@ -72,278 +74,282 @@ export function* updateNodeEverywhere(node) { } function* onNodeSave({ node }: ReturnType) { - yield put(nodeSetSaveErrors({})); + try { + yield put(nodeSetSaveErrors({})); - const { - error, - data: { errors, node: result }, - } = yield call(reqWrapper, postNode, { node }); + const { errors, node: result }: Unwrap = yield call(apiPostNode, { node }); - if (errors && Object.values(errors).length > 0) { - return yield put(nodeSetSaveErrors(errors)); + if (errors && Object.values(errors).length > 0) { + yield put(nodeSetSaveErrors(errors)); + return; + } + + const nodes: ReturnType = yield select(selectFlowNodes); + const updated_flow_nodes = node.id + ? nodes.map(item => (item.id === result.id ? result : item)) + : [result, ...nodes]; + + yield put(flowSetNodes(updated_flow_nodes)); + + const { current } = yield select(selectNode); + + if (node.id && current.id === result.id) { + yield put(nodeSetCurrent(result)); + } + + return yield put(modalSetShown(false)); + } catch (error) { + yield put(nodeSetSaveErrors({ error: error.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) { + 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 = yield select(selectNode); + try { + const { + current: { id }, + comments, + }: ReturnType = yield select(selectNode); - const { data, error }: Unwrap> = yield call( - reqWrapper, - getNodeComments, - { + if (!id) { + return; + } + + const data: Unwrap = yield call(apiGetNodeComments, { id, take: COMMENTS_DISPLAY, skip: comments.length, + }); + + const current: ReturnType = yield select(selectNode); + + if (!data || current.current.id != id) { + return; } - ); - const current: ReturnType = 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) { - yield put(nodeSetLoading(true)); - yield put(nodeSetLoadingComments(true)); +function* onNodeLoad({ id }: ReturnType) { + // Get node body + try { + yield put(nodeSetLoading(true)); + yield put(nodeSetLoadingComments(true)); - const { - data: { node, error }, - } = yield call(reqWrapper, getNode, { id }); + const { node }: Unwrap = yield call(apiGetNode, { id }); - if (error || !node || !node.id) { + yield put(nodeSetCurrent(node)); + yield put(nodeSetLoading(false)); + } catch (error) { yield put(push(URLS.ERRORS.NOT_FOUND)); yield put(nodeSetLoading(false)); - return; } - yield put(nodeSetCurrent(node)); - yield put(nodeSetLoading(false)); + // Comments and related + try { + const [{ comments, comment_count }, { related }]: [ + Unwrap, + Unwrap + ] = yield all([ + call(apiGetNodeComments, { id, take: COMMENTS_DISPLAY, skip: 0 }), + call(apiGetNodeRelated, { id }), + ]); - const { - comments: { - data: { comments, comment_count }, - }, - related: { - data: { related }, - }, - } = yield all({ - comments: call(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({ nodeId, comment, callback }: ReturnType) { - const { data, error }: Unwrap> = yield call( - reqWrapper, - postNodeComment, - { + try { + const data: Unwrap = yield call(apiPostComment, { data: comment, id: nodeId, + }); + + const { current }: ReturnType = yield select(selectNode); + + if (current?.id === nodeId) { + const { comments }: ReturnType = yield select(selectNode); + + if (!comment.id) { + yield put(nodeSetComments([data.comment, ...comments])); + } else { + yield put( + nodeSet({ + comments: comments.map(item => (item.id === comment.id ? data.comment : item)), + }) + ); + } + + callback(); } - ); - - if (error || !data.comment) { - return callback(error); + } catch (error) { + return callback(error.message); } - - const { current }: ReturnType = yield select(selectNode); - - if (current?.id === nodeId) { - const { comments } = yield select(selectNode); - - if (!comment.id) { - yield put(nodeSetComments([data.comment, ...comments])); - } else { - yield put( - nodeSet({ - comments: comments.map(item => (item.id === comment.id ? data.comment : item)), - }) - ); - } - - callback(); - } -} - -function* onCancelCommentEdit({ id }: ReturnType) { - const { comment_data } = yield select(selectNode); - - yield put( - nodeSet({ - comment_data: omit([id.toString()], comment_data), - }) - ); } function* onUpdateTags({ id, tags }: ReturnType) { - yield delay(100); - - const { - data: { node }, - }: IResultWithStatus<{ node: INode }> = yield call(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 = yield call(apiPostNodeTags, { id, tags }); + const { current }: ReturnType = yield select(selectNode); + if (!node || !node.id || node.id !== current.id) return; + yield put(nodeSetTags(node.tags)); + } catch {} } function* onCreateSaga({ node_type: type }: ReturnType) { - 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) { - 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 = 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) { - const { - current, - current: { is_liked, like_count }, - } = yield select(selectNode); + const { current }: ReturnType = yield select(selectNode); - yield call(updateNodeEverywhere, { - ...current, - is_liked: !is_liked, - like_count: is_liked ? Math.max(like_count - 1, 0) : like_count + 1, - }); + 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 = 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) { - 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 = yield call(apiPostNodeHeroic, { id }); - if (!error || data.is_heroic === !is_heroic) return; // ok and matches - - yield call(updateNodeEverywhere, { ...current, is_heroic }); + yield call(updateNodeEverywhere, { ...current, is_heroic: data.is_heroic }); + } catch {} } function* onLockSaga({ id, is_locked }: ReturnType) { - const { - current, - current: { deleted_at }, - } = yield select(selectNode); + const { current }: ReturnType = yield select(selectNode); - yield call(updateNodeEverywhere, { - ...current, - deleted_at: is_locked ? new Date().toISOString() : null, - }); + try { + yield call(updateNodeEverywhere, { + ...current, + deleted_at: is_locked ? new Date().toISOString() : null, + }); - const { error } = yield call(reqWrapper, postNodeLock, { id, is_locked }); + const data: Unwrap = yield call(apiLockNode, { id, is_locked }); - if (error) return yield call(updateNodeEverywhere, { ...current, deleted_at }); + yield call(updateNodeEverywhere, { + ...current, + deleted_at: data.deleted_at || undefined, + }); + } catch { + yield call(updateNodeEverywhere, { ...current, deleted_at: current.deleted_at }); + } } function* onLockCommentSaga({ id, is_locked }: ReturnType) { - const { current, comments } = yield select(selectNode); + const { current, comments }: ReturnType = yield select(selectNode); - yield put( - nodeSetComments( - comments.map(comment => - comment.id === id - ? { ...comment, deleted_at: is_locked ? new Date().toISOString() : null } - : comment + try { + yield put( + nodeSetComments( + comments.map(comment => + comment.id === id + ? { ...comment, deleted_at: is_locked ? new Date().toISOString() : undefined } + : comment + ) ) - ) - ); + ); - yield call(reqWrapper, postNodeLockComment, { current: current.id, id, is_locked }); -} + const data: Unwrap = yield call(apiLockComment, { + current: current.id, + id, + is_locked, + }); -function* onEditCommentSaga({ id }: ReturnType) { - const { comments } = yield select(selectNode); - - const comment = comments.find(item => item.id === id); - - if (!comment) return; - - yield put(nodeSetCommentData(id, { ...EMPTY_COMMENT, ...comment })); + yield put( + nodeSetComments( + comments.map(comment => + comment.id === id ? { ...comment, deleted_at: data.deleted_at || undefined } : comment + ) + ) + ); + } catch { + yield put( + nodeSetComments( + comments.map(comment => + comment.id === id ? { ...comment, deleted_at: current.deleted_at } : comment + ) + ) + ); + } } export default function* nodeSaga() { @@ -351,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); @@ -359,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); } diff --git a/src/redux/node/types.ts b/src/redux/node/types.ts index dc3ad0ed..4dba0cc3 100644 --- a/src/redux/node/types.ts +++ b/src/redux/node/types.ts @@ -1,4 +1,5 @@ -import { INode } from '~/redux/types'; +import { IComment, INode } from '~/redux/types'; +import { INodeState } from '~/redux/node/reducer'; export interface IEditorComponentProps { data: INode; @@ -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; +}; diff --git a/src/redux/player/api.ts b/src/redux/player/api.ts index 9f1c0936..9ee0eb84 100644 --- a/src/redux/player/api.ts +++ b/src/redux/player/api.ts @@ -1,11 +1,8 @@ -import { IResultWithStatus, IEmbed } from '../types'; -import { api, resultMiddleware, errorMiddleware } from '~/utils/api'; +import { api, cleanResult } from '~/utils/api'; import { API } from '~/constants/api'; +import { ApiGetEmbedYoutubeResult } from '~/redux/player/types'; -export const getEmbedYoutube = ( - ids: string[] -): Promise }>> => +export const apiGetEmbedYoutube = (ids: string[]) => api - .get(API.EMBED.YOUTUBE, { params: { ids: ids.join(',') } }) - .then(resultMiddleware) - .catch(errorMiddleware); + .get(API.EMBED.YOUTUBE, { params: { ids: ids.join(',') } }) + .then(cleanResult); diff --git a/src/redux/player/reducer.ts b/src/redux/player/reducer.ts index e8c88810..c0ebba7e 100644 --- a/src/redux/player/reducer.ts +++ b/src/redux/player/reducer.ts @@ -5,13 +5,13 @@ import { IFile, IEmbed } from '../types'; export type IPlayerState = Readonly<{ status: typeof PLAYER_STATES[keyof typeof PLAYER_STATES]; - file: IFile; + file?: IFile; youtubes: Record; }>; const INITIAL_STATE: IPlayerState = { status: PLAYER_STATES.UNSET, - file: null, + file: undefined, youtubes: {}, }; diff --git a/src/redux/player/sagas.ts b/src/redux/player/sagas.ts index 36e4b8b4..97c0a4a1 100644 --- a/src/redux/player/sagas.ts +++ b/src/redux/player/sagas.ts @@ -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) { + if (!file) { + return; + } + yield put(playerSetFile(file)); + Player.set(getURL(file)); Player.play(); } @@ -37,7 +42,7 @@ function seekSaga({ seek }: ReturnType) { function* stoppedSaga() { yield put(playerSetStatus(PLAYER_STATES.UNSET)); - yield put(playerSetFile(null)); + yield put(playerSetFile(undefined)); } function* getYoutubeInfo() { @@ -49,34 +54,38 @@ function* getYoutubeInfo() { ticker, }: { action: ReturnType; ticker: any } = yield race({ action: take(PLAYER_ACTIONS.GET_YOUTUBE_INFO), - ...(ids.length > 0 ? { ticker: delay(1000) } : {}), + ...(ids.length > 0 ? { ticker: delay(500) } : {}), }); if (action) { ids.push(action.url); } - if (ticker || ids.length > 25) { - const result: Unwrap> = yield call(getEmbedYoutube, ids); + if (!ticker && ids.length <= 25) { + // Try to collect more items in next 500ms + continue; + } - if (!result.error && result.data.items && Object.keys(result.data.items).length) { + try { + const data: Unwrap = yield call(apiGetEmbedYoutube, ids); + + if (data.items && Object.keys(data.items).length) { const { youtubes }: ReturnType = yield select(selectPlayer); - yield put(playerSet({ youtubes: { ...youtubes, ...result.data.items } })); + yield put(playerSet({ youtubes: { ...youtubes, ...data.items } })); } ids = []; - } + } catch {} } } export default function* playerSaga() { + yield fork(getYoutubeInfo); + yield takeLatest(PLAYER_ACTIONS.SET_FILE_AND_PLAY, setFileAndPlaySaga); yield takeLatest(PLAYER_ACTIONS.PAUSE, pauseSaga); yield takeLatest(PLAYER_ACTIONS.PLAY, playSaga); yield takeLatest(PLAYER_ACTIONS.SEEK, seekSaga); yield takeLatest(PLAYER_ACTIONS.STOP, stopSaga); yield takeLatest(PLAYER_ACTIONS.STOPPED, stoppedSaga); - - yield fork(getYoutubeInfo); - // yield takeEvery(PLAYER_ACTIONS.GET_YOUTUBE_INFO, getYoutubeInfo); } diff --git a/src/redux/player/types.ts b/src/redux/player/types.ts new file mode 100644 index 00000000..98e6f91d --- /dev/null +++ b/src/redux/player/types.ts @@ -0,0 +1,3 @@ +import { IEmbed } from '~/redux/types'; + +export type ApiGetEmbedYoutubeResult = { items: Record }; diff --git a/src/redux/store.ts b/src/redux/store.ts index a26248eb..eb9c60ff 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -26,7 +26,7 @@ import playerSaga from '~/redux/player/sagas'; import modal, { IModalState } from '~/redux/modal'; import { modalSaga } from './modal/sagas'; -import { authOpenProfile, gotAuthPostMessage } from './auth/actions'; +import { authLogout, authOpenProfile, gotAuthPostMessage } from './auth/actions'; import boris, { IBorisState } from './boris/reducer'; import borisSaga from './boris/sagas'; @@ -36,6 +36,9 @@ import messagesSaga from './messages/sagas'; import tag, { ITagState } from './tag'; import tagSaga from './tag/sagas'; +import { AxiosError } from 'axios'; +import { api } from '~/utils/api'; +import { assocPath } from 'ramda'; const authPersistConfig: PersistConfig = { key: 'auth', @@ -116,5 +119,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 }; } diff --git a/src/redux/tag/api.ts b/src/redux/tag/api.ts index d657b9b7..b03f9400 100644 --- a/src/redux/tag/api.ts +++ b/src/redux/tag/api.ts @@ -1,33 +1,18 @@ -import { INode, IResultWithStatus } from '~/redux/types'; -import { api, configWithToken, errorMiddleware, resultMiddleware } from '~/utils/api'; +import { api, cleanResult } from '~/utils/api'; import { API } from '~/constants/api'; +import { + ApiGetNodesOfTagRequest, + ApiGetNodesOfTagResult, + ApiGetTagSuggestionsRequest, + ApiGetTagSuggestionsResult, +} from '~/redux/tag/types'; -export const getTagNodes = ({ - access, - tag, - offset, - limit, -}: { - access: string; - tag: string; - offset: number; - limit: number; -}): Promise> => +export const apiGetNodesOfTag = ({ tag, offset, limit }: ApiGetNodesOfTagRequest) => api - .get(API.TAG.NODES, configWithToken(access, { params: { name: tag, offset, limit } })) - .then(resultMiddleware) - .catch(errorMiddleware); + .get(API.TAG.NODES, { params: { name: tag, offset, limit } }) + .then(cleanResult); -export const getTagAutocomplete = ({ - search, - exclude, - access, -}: { - access: string; - search: string; - exclude: string[]; -}): Promise> => +export const apiGetTagSuggestions = ({ search, exclude }: ApiGetTagSuggestionsRequest) => api - .get(API.TAG.AUTOCOMPLETE, configWithToken(access, { params: { search, exclude } })) - .then(resultMiddleware) - .catch(errorMiddleware); + .get(API.TAG.AUTOCOMPLETE, { params: { search, exclude } }) + .then(cleanResult); diff --git a/src/redux/tag/sagas.ts b/src/redux/tag/sagas.ts index ade289bd..eb1c3f16 100644 --- a/src/redux/tag/sagas.ts +++ b/src/redux/tag/sagas.ts @@ -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) { - yield put(tagSetNodes({ isLoading: true })); + yield put(tagSetNodes({ isLoading: true, list: [] })); try { const { list }: ReturnType = yield select(selectTagNodes); - const { data, error }: Unwrap> = yield call( - reqWrapper, - getTagNodes, - { tag, limit: 18, offset: list.length } - ); + const data: Unwrap = yield call(apiGetNodesOfTag, { + tag, + limit: 18, + offset: list.length, + }); - if (error) throw new Error(error); - - yield put(tagSetNodes({ isLoading: false, list: [...list, ...data.nodes], count: data.count })); - } catch (e) { - console.log(e); + yield put(tagSetNodes({ list: [...list, ...data.nodes], count: data.count })); + } catch { + } finally { yield put(tagSetNodes({ isLoading: false })); } } function* loadAutocomplete({ search, exclude }: ReturnType) { - if (search.length < 3) return; + if (search.length < 2) return; try { yield put(tagSetAutocomplete({ isLoading: true })); - yield delay(100); + yield delay(200); - const { data, error }: Unwrap> = yield call( - reqWrapper, - getTagAutocomplete, - { search, exclude } - ); + const data: Unwrap = yield call(apiGetTagSuggestions, { + search, + exclude, + }); - if (error) throw new Error(error); - - yield put(tagSetAutocomplete({ options: data.tags, isLoading: false })); - } catch (e) { + yield put(tagSetAutocomplete({ options: data.tags })); + } catch { + } finally { yield put(tagSetAutocomplete({ isLoading: false })); } } diff --git a/src/redux/tag/types.ts b/src/redux/tag/types.ts new file mode 100644 index 00000000..9f7f41e7 --- /dev/null +++ b/src/redux/tag/types.ts @@ -0,0 +1,16 @@ +import { INode } from '~/redux/types'; + +export type ApiGetNodesOfTagRequest = { + tag: string; + offset: number; + limit: number; +}; +export type ApiGetNodesOfTagResult = { nodes: INode[]; count: number }; + +export type ApiGetTagSuggestionsRequest = { + search: string; + exclude: string[]; +}; +export type ApiGetTagSuggestionsResult = { + tags: string[]; +}; diff --git a/src/redux/types.ts b/src/redux/types.ts index d38db98c..8dd92998 100644 --- a/src/redux/types.ts +++ b/src/redux/types.ts @@ -71,7 +71,7 @@ export interface IFile { url: string; size: number; - type: IUploadType; + type?: IUploadType; mime: string; metadata?: { id3title?: string; @@ -92,7 +92,7 @@ export interface IFileWithUUID { file: File; subject?: string; target: string; - type: string; + type?: string; onSuccess?: (file: IFile) => void; onFail?: () => void; } @@ -111,13 +111,13 @@ export type IBlock = IBlockText | IBlockEmbed; export interface INode { id?: number; - user: Partial; + user?: Partial; title: string; files: IFile[]; - cover: IFile; - type: string; + cover?: IFile; + type?: string; blocks: IBlock[]; thumbnail?: string; @@ -143,7 +143,7 @@ export interface IComment { id: number; text: string; files: IFile[]; - user: IUser; + user?: IUser; created_at?: string; update_at?: string; @@ -192,7 +192,13 @@ export type INodeNotification = { export type INotification = IMessageNotification | ICommentNotification; -export type Unwrap = T extends Promise ? U : T; +export type Unwrap = T extends (...args: any) => Promise + ? T extends (...args: any) => Promise + ? U + : T + : T extends () => Iterator + ? U + : any; export interface IEmbed { provider: string; diff --git a/src/redux/uploads/api.ts b/src/redux/uploads/api.ts index 2697c75a..756ac8ac 100644 --- a/src/redux/uploads/api.ts +++ b/src/redux/uploads/api.ts @@ -1,31 +1,20 @@ -import { - IResultWithStatus, IFile, IUploadProgressHandler, IFileWithUUID, -} from '~/redux/types'; -import { - api, configWithToken, resultMiddleware, errorMiddleware, -} from '~/utils/api'; +import { api, cleanResult } from '~/utils/api'; import { API } from '~/constants/api'; +import { ApiUploadFileRequest, ApiUploadFIleResult } from '~/redux/uploads/types'; -export const postUploadFile = ({ - access, +export const apiUploadFile = ({ file, target = 'others', type = 'image', onProgress, -}: IFileWithUUID & { - access: string; - onProgress: IUploadProgressHandler; -}): Promise> => { +}: ApiUploadFileRequest) => { const data = new FormData(); data.append('file', file); return api - .post( - API.USER.UPLOAD(target, type), - data, - configWithToken(access, { onUploadProgress: onProgress }) - ) - .then(resultMiddleware) - .catch(errorMiddleware); + .post(API.USER.UPLOAD(target, type), data, { + onUploadProgress: onProgress, + }) + .then(cleanResult); }; diff --git a/src/redux/uploads/constants.ts b/src/redux/uploads/constants.ts index 27f91abf..8d8cf134 100644 --- a/src/redux/uploads/constants.ts +++ b/src/redux/uploads/constants.ts @@ -15,9 +15,9 @@ export const UPLOAD_ACTIONS = { }; export const EMPTY_FILE: IFile = { - id: null, - user_id: null, - node_id: null, + id: undefined, + user_id: undefined, + node_id: undefined, name: '', orig_name: '', @@ -25,21 +25,21 @@ export const EMPTY_FILE: IFile = { full_path: '', url: '', size: 0, - type: null, + type: undefined, mime: '', }; export const EMPTY_UPLOAD_STATUS: IUploadStatus = { is_uploading: false, - preview: null, - error: null, - uuid: null, - url: null, + preview: '', + error: '', + uuid: 0, + url: '', progress: 0, - thumbnail_url: null, - type: null, - temp_id: null, - name: null, + thumbnail_url: '', + type: '', + temp_id: '', + name: '', }; // for targeted cancellation diff --git a/src/redux/uploads/handlers.ts b/src/redux/uploads/handlers.ts index fe3140bf..57a4b86b 100644 --- a/src/redux/uploads/handlers.ts +++ b/src/redux/uploads/handlers.ts @@ -2,38 +2,41 @@ import { assocPath } from 'ramda'; import { omit } from 'ramda'; import { UPLOAD_ACTIONS, EMPTY_UPLOAD_STATUS } from './constants'; -import { - uploadAddStatus, uploadDropStatus, uploadSetStatus, uploadAddFile -} from './actions'; +import { uploadAddStatus, uploadDropStatus, uploadSetStatus, uploadAddFile } from './actions'; import { IUploadState } from './reducer'; const addStatus = ( state: IUploadState, - { temp_id, status, }: ReturnType -): IUploadState => assocPath( - ['statuses'], - { ...state.statuses, [temp_id]: { ...EMPTY_UPLOAD_STATUS, ...status, }, }, - state -); + { temp_id, status }: ReturnType +): IUploadState => + assocPath( + ['statuses'], + { ...state.statuses, [temp_id]: { ...EMPTY_UPLOAD_STATUS, ...status } }, + state + ); const dropStatus = ( state: IUploadState, - { temp_id, }: ReturnType + { temp_id }: ReturnType ): IUploadState => assocPath(['statuses'], omit([temp_id], state.statuses), state); const setStatus = ( state: IUploadState, - { temp_id, status, }: ReturnType -): IUploadState => assocPath( - ['statuses'], - { - ...state.statuses, - [temp_id]: { ...(state.statuses[temp_id] || EMPTY_UPLOAD_STATUS), ...status, }, - }, - state -); + { temp_id, status }: ReturnType +): IUploadState => + assocPath( + ['statuses'], + { + ...state.statuses, + [temp_id]: { ...(state.statuses[temp_id] || EMPTY_UPLOAD_STATUS), ...status }, + }, + state + ); -const addFile = (state: IUploadState, { file, }: ReturnType): IUploadState => assocPath(['files'], { ...state.files, [file.id]: file, }, state); +const addFile = (state: IUploadState, { file }: ReturnType): IUploadState => { + if (!file.id) return state; + return assocPath(['files', file.id], file, state); +}; export const UPLOAD_HANDLERS = { [UPLOAD_ACTIONS.ADD_STATUS]: addStatus, diff --git a/src/redux/uploads/mocks.ts b/src/redux/uploads/mocks.ts deleted file mode 100644 index e8ccc9e9..00000000 --- a/src/redux/uploads/mocks.ts +++ /dev/null @@ -1,15 +0,0 @@ -import uuid from 'uuid4'; -import { IResultWithStatus, IFile, UUID } from '../types'; -import { HTTP_RESPONSES } from '~/utils/api'; -import { EMPTY_FILE } from './constants'; - -export const uploadMock = ({ temp_id, file }: { temp_id: UUID; file: File }): Promise> => ( - Promise.resolve({ - status: HTTP_RESPONSES.CREATED, - data: { - ...EMPTY_FILE, - id: uuid(), - temp_id, - }, - error: null, - })); diff --git a/src/redux/uploads/sagas.ts b/src/redux/uploads/sagas.ts index b24ada1c..480bcf9a 100644 --- a/src/redux/uploads/sagas.ts +++ b/src/redux/uploads/sagas.ts @@ -1,17 +1,17 @@ -import { takeEvery, all, spawn, call, put, take, fork, race } from 'redux-saga/effects'; -import { postUploadFile } from './api'; -import { UPLOAD_ACTIONS, FILE_MIMES } from '~/redux/uploads/constants'; +import { SagaIterator } from 'redux-saga'; +import { all, call, fork, put, race, spawn, take, takeEvery } from 'redux-saga/effects'; +import { apiUploadFile } from './api'; +import { FILE_MIMES, UPLOAD_ACTIONS } from '~/redux/uploads/constants'; import { - uploadUploadFiles, - uploadSetStatus, + uploadAddFile, uploadAddStatus, uploadDropStatus, - uploadAddFile, + uploadSetStatus, + uploadUploadFiles, } from './actions'; -import { 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 = yield call(apiUploadFile, { file, temp_id, type, target, onProgress, }); + + return data; } function* onUploadProgress(chan) { @@ -46,7 +48,12 @@ function* uploadCancelWorker(id) { return true; } -function* uploadWorker({ file, temp_id, target, type }: IFileWithUUID) { +function* uploadWorker({ + file, + temp_id, + target, + type, +}: IFileWithUUID): SagaIterator> { const [promise, chan] = createUploader, Partial>( uploadCall, { temp_id, target, type } @@ -63,77 +70,74 @@ function* uploadWorker({ file, temp_id, target, type }: IFileWithUUID) { } function* uploadFile({ file, temp_id, type, target, onSuccess, onFail }: IFileWithUUID) { - if (!file.type || !FILE_MIMES[type] || !FILE_MIMES[type].includes(file.type)) { - return { - error: 'File_Not_Image', - status: HTTP_RESPONSES.BAD_REQUEST, - data: {}, - }; - } + if (!temp_id) return; - const preview = yield call(uploadGetThumb, file); + try { + if (!file.type || !type || !FILE_MIMES[type] || !FILE_MIMES[type].includes(file.type)) { + return { + error: 'File_Not_Image', + status: HTTP_RESPONSES.BAD_REQUEST, + data: {}, + }; + } - yield put( - uploadAddStatus( - // replace with the one, what adds file upload status - temp_id, - { - preview, + const preview: Unwrap = yield call(uploadGetThumb, file); + + yield put( + uploadAddStatus(temp_id, { + preview: preview.toString(), is_uploading: true, temp_id, type, name: file.name, - } - ) - ); + }) + ); - const { result, cancel, cancel_editing } = yield race({ - result: call(uploadWorker, { - file, - temp_id, - target, - type, - }), - cancel: call(uploadCancelWorker, temp_id), - }); + const [result, cancel]: [ + Unwrap, + Unwrap + ] = yield race([ + call(uploadWorker, { + file, + temp_id, + target, + type, + }), + call(uploadCancelWorker, temp_id), + ]); - if (cancel || cancel_editing) { - if (onFail) onFail(); - return yield put(uploadDropStatus(temp_id)); - } + if (cancel || !result) { + if (onFail) onFail(); + return yield put(uploadDropStatus(temp_id)); + } - const { data, error }: { data: IFile & { detail: string }; error: string } = result; + yield put( + uploadSetStatus(temp_id, { + is_uploading: false, + error: '', + uuid: result.id, + url: result.full_path, + type, + thumbnail_url: result.full_path, + progress: 1, + name: file.name, + }) + ); - if (error) { + yield put(uploadAddFile(result)); + + if (onSuccess) onSuccess(result); + } catch (error) { if (onFail) onFail(); return yield put( uploadSetStatus(temp_id, { is_uploading: false, - error: data.detail || error, + error: 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) { diff --git a/src/redux/uploads/types.ts b/src/redux/uploads/types.ts new file mode 100644 index 00000000..410acb22 --- /dev/null +++ b/src/redux/uploads/types.ts @@ -0,0 +1,6 @@ +import { IFile, IFileWithUUID, IUploadProgressHandler } from '~/redux/types'; + +export type ApiUploadFileRequest = IFileWithUUID & { + onProgress: IUploadProgressHandler; +}; +export type ApiUploadFIleResult = IFile; diff --git a/src/utils/api/index.ts b/src/utils/api/index.ts index e7268343..dc68cdee 100644 --- a/src/utils/api/index.ts +++ b/src/utils/api/index.ts @@ -1,4 +1,4 @@ -import axios, { AxiosRequestConfig } from 'axios'; +import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; import { push } from 'connected-react-router'; import { API } from '~/constants/api'; import { store } from '~/redux/store'; @@ -50,3 +50,5 @@ export const configWithToken = ( ...config, headers: { ...(config.headers || {}), Authorization: `Bearer ${access}` }, }); + +export const cleanResult = (response: AxiosResponse): T => response?.data; diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 6d414348..eae9655f 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -89,7 +89,10 @@ export const getURLFromString = ( return url.replace('REMOTE_CURRENT://', process.env.REACT_APP_REMOTE_CURRENT); }; -export const getURL = (file: Partial, size?: typeof PRESETS[keyof typeof PRESETS]) => { +export const getURL = ( + file: Partial | undefined, + size?: typeof PRESETS[keyof typeof PRESETS] +) => { return file?.url ? getURLFromString(file.url, size) : ''; }; diff --git a/src/utils/fn.ts b/src/utils/fn.ts index def79fbb..8f9a9487 100644 --- a/src/utils/fn.ts +++ b/src/utils/fn.ts @@ -10,16 +10,20 @@ export const objFromArray = (array: any[], key: string) => array.reduce((obj, el) => (key && el[key] ? { ...obj, [el[key]]: el } : obj), {}); export const groupCommentsByUser = ( - result: ICommentGroup[], + grouppedComments: ICommentGroup[], comment: IComment ): ICommentGroup[] => { - const last: ICommentGroup = path([result.length - 1], result) || null; + const last: ICommentGroup | undefined = path([grouppedComments.length - 1], grouppedComments); + + if (!comment.user) { + return grouppedComments; + } return [ ...(!last || path(['user', 'id'], last) !== path(['user', 'id'], comment) ? [ // add new group - ...result, + ...grouppedComments, { user: comment.user, comments: [comment], @@ -28,7 +32,7 @@ export const groupCommentsByUser = ( ] : [ // append to last group - ...result.slice(0, result.length - 1), + ...grouppedComments.slice(0, grouppedComments.length - 1), { ...last, comments: [...last.comments, comment], @@ -37,6 +41,3 @@ export const groupCommentsByUser = ( ]), ]; }; - -// const isSameComment = (comments, index) => -// comments[index - 1] && comments[index - 1].user.id === comments[index].user.id; diff --git a/src/utils/hooks/fileUploader.tsx b/src/utils/hooks/fileUploader.tsx index 207a86f3..3e2910e6 100644 --- a/src/utils/hooks/fileUploader.tsx +++ b/src/utils/hooks/fileUploader.tsx @@ -1,4 +1,12 @@ -import React, { createContext, FC, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import React, { + createContext, + FC, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; import { IFile, IFileWithUUID } from '~/redux/types'; import { UPLOAD_SUBJECTS, UPLOAD_TARGETS } from '~/redux/uploads/constants'; import { getFileType } from '~/utils/uploader'; @@ -7,6 +15,8 @@ import { useDispatch } from 'react-redux'; import { uploadUploadFiles } from '~/redux/uploads/actions'; import { useShallowSelect } from '~/utils/hooks/useShallowSelect'; import { selectUploads } from '~/redux/uploads/selectors'; +import { has, path } from 'ramda'; +import { IUploadStatus } from '~/redux/uploads/reducer'; export const useFileUploader = ( subject: typeof UPLOAD_SUBJECTS[keyof typeof UPLOAD_SUBJECTS], @@ -31,7 +41,7 @@ export const useFileUploader = ( }) ); - const temps = items.map(file => file.temp_id); + const temps = items.filter(el => !!el.temp_id).map(file => file.temp_id!); setPendingIDs([...pendingIDs, ...temps]); dispatch(uploadUploadFiles(items)); @@ -41,9 +51,10 @@ export const useFileUploader = ( useEffect(() => { const added = pendingIDs - .map(temp_uuid => statuses[temp_uuid] && statuses[temp_uuid].uuid) - .map(el => !!el && uploadedFiles[el]) - .filter(el => !!el && !files.some(file => file && file.id === el.id)); + .map(temp_uuid => path([temp_uuid, 'uuid'], statuses) as IUploadStatus['uuid']) + .filter(el => el) + .map(el => (path([String(el)], uploadedFiles) as IFile) || undefined) + .filter(el => !!el! && !files.some(file => file && file.id === el.id)); const newPending = pendingIDs.filter( temp_id => @@ -68,7 +79,7 @@ export const useFileUploader = ( }; export type FileUploader = ReturnType; -const FileUploaderContext = createContext(null); +const FileUploaderContext = createContext(undefined); export const FileUploaderProvider: FC<{ value: FileUploader; children }> = ({ value, diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 80230f2a..563bacb0 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect } from 'react'; -export const useCloseOnEscape = (onRequestClose: () => void, ignore_inputs = false) => { +export const useCloseOnEscape = (onRequestClose?: () => void, ignore_inputs = false) => { const onEscape = useCallback( event => { - if (event.key !== 'Escape') return; + if (event.key !== 'Escape' || !onRequestClose) return; if ( ignore_inputs && (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') diff --git a/src/utils/hooks/useCommentFormFormik.ts b/src/utils/hooks/useCommentFormFormik.ts index dd3fc139..cd222db5 100644 --- a/src/utils/hooks/useCommentFormFormik.ts +++ b/src/utils/hooks/useCommentFormFormik.ts @@ -12,7 +12,7 @@ const validationSchema = object().shape({ }); const onSuccess = ({ resetForm, setStatus, setSubmitting }: FormikHelpers) => ( - e: string + e?: string ) => { setSubmitting(false); diff --git a/src/utils/hooks/useFormatWrapper.ts b/src/utils/hooks/useFormatWrapper.ts index f49e828a..3ffa2f9f 100644 --- a/src/utils/hooks/useFormatWrapper.ts +++ b/src/utils/hooks/useFormatWrapper.ts @@ -1,4 +1,4 @@ -import { MouseEventHandler, useCallback } from 'react'; +import { useCallback } from 'react'; export const useFormatWrapper = ( target: HTMLTextAreaElement, @@ -6,34 +6,42 @@ export const useFormatWrapper = ( prefix = '', suffix = '' ) => { - return useCallback( + return useCallback( event => { event.preventDefault(); - - if (!target) return; - - const start = target.selectionStart; - const end = target.selectionEnd; - const selection = target.value.substring(start, end); - - const replacement = prefix + selection + suffix; - - onChange( - target.value.substring(0, start) + - replacement + - target.value.substring(end, target.value.length) - ); - - target.focus(); - - setTimeout(() => { - if (start === end) { - target.selectionEnd = end + prefix.length; - } else { - target.selectionEnd = end + prefix.length + suffix.length; - } - }, 0); + wrapTextInsideInput(target, prefix, suffix, onChange); }, [target, onChange, prefix, suffix] ); }; + +export const wrapTextInsideInput = ( + target: HTMLTextAreaElement, + prefix: string, + suffix: string, + onChange: (val: string) => void +) => { + if (!target) return; + + const start = target.selectionStart; + const end = target.selectionEnd; + const selection = target.value.substring(start, end); + + const replacement = prefix + selection + suffix; + + onChange( + target.value.substring(0, start) + + replacement + + target.value.substring(end, target.value.length) + ); + + target.focus(); + + setTimeout(() => { + if (start === end) { + target.selectionEnd = end + prefix.length; + } else { + target.selectionEnd = end + prefix.length + suffix.length; + } + }, 0); +}; diff --git a/src/utils/hooks/useTranslatedError.ts b/src/utils/hooks/useTranslatedError.ts new file mode 100644 index 00000000..22ef367f --- /dev/null +++ b/src/utils/hooks/useTranslatedError.ts @@ -0,0 +1,17 @@ +import { ERROR_LITERAL } from '~/constants/errors'; +import { has } from 'ramda'; +import { useMemo } from 'react'; + +export const useTranslatedError = (error: string | undefined) => { + return useMemo(() => { + if (!error) { + return ''; + } + + if (!has(error, ERROR_LITERAL)) { + return error; + } + + return ERROR_LITERAL[error]; + }, [error]); +}; diff --git a/src/utils/player.ts b/src/utils/player.ts index e7d747aa..82930dc4 100644 --- a/src/utils/player.ts +++ b/src/utils/player.ts @@ -32,15 +32,12 @@ export class PlayerClass { }); } - public current: number = 0; + public current = 0; + public total = 0; + public element = new Audio(); + public duration = 0; - public total: number = 0; - - public element: HTMLAudioElement = typeof Audio !== 'undefined' ? new Audio() : null; - - public duration: number = 0; - - public set = (src: string): void => { + public set = (src: string) => { this.element.src = src; }; diff --git a/src/utils/tag.ts b/src/utils/tag.ts index 2913ba95..2eb7fa49 100644 --- a/src/utils/tag.ts +++ b/src/utils/tag.ts @@ -3,11 +3,11 @@ import { ITag } from '~/redux/types'; export const separateTags = (tags: Partial[]): Partial[][] => (tags || []).reduce( (obj, tag) => - tag.title.substr(0, 1) === '/' ? [[...obj[0], tag], obj[1]] : [obj[0], [...obj[1], tag]], - [[], []] + tag?.title?.substr(0, 1) === '/' ? [[...obj[0], tag], obj[1]] : [obj[0], [...obj[1], tag]], + [[], []] as Partial[][] ); export const separateTagOptions = (options: string[]): string[][] => separateTags(options.map((title): Partial => ({ title }))).map(item => - item.map(({ title }) => title) + item.filter(tag => tag.title).map(({ title }) => title!) ); diff --git a/src/utils/uploader.ts b/src/utils/uploader.ts index f161cd66..c1ad5941 100644 --- a/src/utils/uploader.ts +++ b/src/utils/uploader.ts @@ -1,7 +1,7 @@ import uuid from 'uuid4'; -import { eventChannel, END, EventChannel } from 'redux-saga'; +import { END, eventChannel, EventChannel } from 'redux-saga'; import { VALIDATORS } from '~/utils/validators'; -import { IResultWithStatus, IFile } from '~/redux/types'; +import { IFile, IResultWithStatus } from '~/redux/types'; import { HTTP_RESPONSES } from './api'; import { EMPTY_FILE, FILE_MIMES, UPLOAD_TYPES } from '~/redux/uploads/constants'; @@ -33,13 +33,11 @@ export function createUploader( export const uploadGetThumb = async file => { if (!file.type || !VALIDATORS.IS_IMAGE_MIME(file.type)) return ''; - const thumb = await new Promise(resolve => { + return new Promise(resolve => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result || ''); reader.readAsDataURL(file); }); - - return thumb; }; export const fakeUploader = ({ @@ -73,9 +71,6 @@ export const fakeUploader = ({ }); }; -export const getFileType = (file: File): keyof typeof UPLOAD_TYPES => { - return ( - (file.type && Object.keys(FILE_MIMES).find(mime => FILE_MIMES[mime].includes(file.type))) || - null - ); -}; +export const getFileType = (file: File): keyof typeof UPLOAD_TYPES | undefined => + (file.type && Object.keys(FILE_MIMES).find(mime => FILE_MIMES[mime].includes(file.type))) || + undefined; diff --git a/src/utils/validators.ts b/src/utils/validators.ts index 8c50b790..0691546f 100644 --- a/src/utils/validators.ts +++ b/src/utils/validators.ts @@ -3,13 +3,13 @@ import { IMAGE_MIME_TYPES } from '~/utils/uploader'; const isValidEmail = (email: string): boolean => !!email && - String(email) && + !!String(email) && !!String(email).match( /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ ); const isLikeEmail = (email: string): boolean => - !!email && String(email) && !!String(email).match(/^([^\@]+)@([^\@]+)\.([^\@]+)$$/); + !!email && !!String(email) && !!String(email).match(/^([^\@]+)@([^\@]+)\.([^\@]+)$$/); const isNonEmpty = (value: string): boolean => !!value && value.trim().length > 0; const isLikePhone = isNonEmpty; diff --git a/tsconfig.json b/tsconfig.json index d0a4b9ad..6583d3c1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, - "strict": false, + "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "module": "esnext",