From 75dc20ca0b5f6737074f11d59a3a591aa6d89d24 Mon Sep 17 00:00:00 2001 From: Fedor Katurov <fedor.katurov@playrix.com> Date: Tue, 22 Nov 2022 10:46:41 +0600 Subject: [PATCH] fixed user hydration --- .../comment/CommentContent/index.tsx | 75 ++++++++++++++----- .../containers/Authorized/index.tsx | 15 ++-- src/components/flow/FlowGrid/index.tsx | 60 +++++++++------ src/components/menu/SeparatedMenu/index.tsx | 4 +- src/components/node/NodeTitle/index.tsx | 61 ++++++++------- src/containers/main/Header/index.tsx | 12 ++- src/containers/main/Header/styles.module.scss | 10 ++- src/hooks/auth/useAuth.ts | 1 + src/hooks/auth/useUser.ts | 20 +++-- src/hooks/node/useNodePermissions.ts | 18 ++++- src/layouts/NodeLayout/index.tsx | 6 +- src/store/auth/AuthStore.ts | 5 ++ src/utils/providers/AuthProvider.tsx | 2 + 13 files changed, 194 insertions(+), 95 deletions(-) diff --git a/src/components/comment/CommentContent/index.tsx b/src/components/comment/CommentContent/index.tsx index 51c9f96e..17f1eac1 100644 --- a/src/components/comment/CommentContent/index.tsx +++ b/src/components/comment/CommentContent/index.tsx @@ -1,9 +1,19 @@ -import React, { createElement, FC, Fragment, memo, ReactNode, useCallback, useMemo, useState } from 'react'; +import React, { + createElement, + FC, + Fragment, + memo, + ReactNode, + useCallback, + useMemo, + useState, +} from 'react'; import classnames from 'classnames'; import classNames from 'classnames'; import { CommentForm } from '~/components/comment/CommentForm'; +import { Authorized } from '~/components/containers/Authorized'; import { Group } from '~/components/containers/Group'; import { AudioPlayer } from '~/components/media/AudioPlayer'; import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment'; @@ -28,7 +38,15 @@ interface IProps { } const CommentContent: FC<IProps> = memo( - ({ comment, canEdit, nodeId, saveComment, onDelete, onShowImageModal, prefix }) => { + ({ + comment, + canEdit, + nodeId, + saveComment, + onDelete, + onShowImageModal, + prefix, + }) => { const [isEditing, setIsEditing] = useState(false); const startEditing = useCallback(() => setIsEditing(true), [setIsEditing]); @@ -38,11 +56,13 @@ const CommentContent: FC<IProps> = memo( () => reduce( (group, file) => - file.type ? assocPath([file.type], append(file, group[file.type]), group) : group, + file.type + ? assocPath([file.type], append(file, group[file.type]), group) + : group, {} as Record<UploadType, IFile[]>, - comment.files + comment.files, ), - [comment] + [comment], ); const onLockClick = useCallback(() => { @@ -50,8 +70,9 @@ const CommentContent: FC<IProps> = memo( }, [comment, onDelete]); const menu = useMemo( - () => canEdit && <CommentMenu onDelete={onLockClick} onEdit={startEditing} />, - [canEdit, startEditing, onLockClick] + () => + canEdit && <CommentMenu onDelete={onLockClick} onEdit={startEditing} />, + [canEdit, startEditing, onLockClick], ); const blocks = useMemo( @@ -59,7 +80,7 @@ const CommentContent: FC<IProps> = memo( !!comment.text.trim() ? formatCommentText(path(['user', 'username'], comment), comment.text) : [], - [comment] + [comment], ); if (isEditing) { @@ -78,17 +99,22 @@ const CommentContent: FC<IProps> = memo( {!!prefix && <div className={styles.prefix}>{prefix}</div>} {comment.text.trim() && ( <Group className={classnames(styles.block, styles.block_text)}> - {menu} + <Authorized>{menu}</Authorized> <Group className={styles.renderers}> {blocks.map( (block, key) => COMMENT_BLOCK_RENDERERS[block.type] && - createElement(COMMENT_BLOCK_RENDERERS[block.type], { block, key }) + createElement(COMMENT_BLOCK_RENDERERS[block.type], { + block, + key, + }), )} </Group> - <div className={styles.date}>{getPrettyDate(comment.created_at)}</div> + <div className={styles.date}> + {getPrettyDate(comment.created_at)} + </div> </Group> )} @@ -102,32 +128,45 @@ const CommentContent: FC<IProps> = memo( })} > {groupped.image.map((file, index) => ( - <div key={file.id} onClick={() => onShowImageModal(groupped.image, index)}> - <img src={getURL(file, ImagePresets['600'])} alt={file.name} /> + <div + key={file.id} + onClick={() => onShowImageModal(groupped.image, index)} + > + <img + src={getURL(file, ImagePresets['600'])} + alt={file.name} + /> </div> ))} </div> - <div className={styles.date}>{getPrettyDate(comment.created_at)}</div> + <div className={styles.date}> + {getPrettyDate(comment.created_at)} + </div> </div> )} {groupped.audio && groupped.audio.length > 0 && ( <Fragment> - {groupped.audio.map(file => ( - <div className={classnames(styles.block, styles.block_audio)} key={file.id}> + {groupped.audio.map((file) => ( + <div + className={classnames(styles.block, styles.block_audio)} + key={file.id} + > {menu} <AudioPlayer file={file} /> - <div className={styles.date}>{getPrettyDate(comment.created_at)}</div> + <div className={styles.date}> + {getPrettyDate(comment.created_at)} + </div> </div> ))} </Fragment> )} </div> ); - } + }, ); export { CommentContent }; diff --git a/src/components/containers/Authorized/index.tsx b/src/components/containers/Authorized/index.tsx index 739e1ec8..d38a9ea6 100644 --- a/src/components/containers/Authorized/index.tsx +++ b/src/components/containers/Authorized/index.tsx @@ -1,15 +1,20 @@ import React, { FC } from 'react'; +import { observer } from 'mobx-react-lite'; + import { useAuth } from '~/hooks/auth/useAuth'; -interface IProps {} +interface IProps { + // don't wait for user refetch, trust hydration + hydratedOnly?: boolean; +} -const Authorized: FC<IProps> = ({ children }) => { - const { isUser } = useAuth(); +const Authorized: FC<IProps> = observer(({ children, hydratedOnly }) => { + const { isUser, fetched } = useAuth(); - if (!isUser) return null; + if (!isUser || (!hydratedOnly && !fetched)) return null; return <>{children}</>; -}; +}); export { Authorized }; diff --git a/src/components/flow/FlowGrid/index.tsx b/src/components/flow/FlowGrid/index.tsx index 3d451b54..5aac50b5 100644 --- a/src/components/flow/FlowGrid/index.tsx +++ b/src/components/flow/FlowGrid/index.tsx @@ -1,9 +1,11 @@ import React, { FC, Fragment } from 'react'; import classNames from 'classnames'; +import { observer } from 'mobx-react-lite'; import { FlowCell } from '~/components/flow/FlowCell'; import { flowDisplayToPreset, URLS } from '~/constants/urls'; +import { useAuth } from '~/hooks/auth/useAuth'; import { FlowDisplay, IFlowNode, INode } from '~/types'; import { IUser } from '~/types/auth'; import { getURLFromString } from '~/utils/dom'; @@ -17,28 +19,38 @@ interface Props { onChangeCellView: (id: INode['id'], flow: FlowDisplay) => void; } -export const FlowGrid: FC<Props> = ({ user, nodes, onChangeCellView }) => { - if (!nodes) { - return null; - } +export const FlowGrid: FC<Props> = observer( + ({ user, nodes, onChangeCellView }) => { + const { fetched, isUser } = useAuth(); - return ( - <Fragment> - {nodes.map(node => ( - <div className={classNames(styles.cell, styles[node.flow.display])} key={node.id}> - <FlowCell - id={node.id} - color={node.flow.dominant_color} - to={URLS.NODE_URL(node.id)} - image={getURLFromString(node.thumbnail, flowDisplayToPreset[node.flow.display])} - flow={node.flow} - text={node.description} - title={node.title} - canEdit={canEditNode(node, user)} - onChangeCellView={onChangeCellView} - /> - </div> - ))} - </Fragment> - ); -}; + if (!nodes) { + return null; + } + + return ( + <Fragment> + {nodes.map((node) => ( + <div + className={classNames(styles.cell, styles[node.flow.display])} + key={node.id} + > + <FlowCell + id={node.id} + color={node.flow.dominant_color} + to={URLS.NODE_URL(node.id)} + image={getURLFromString( + node.thumbnail, + flowDisplayToPreset[node.flow.display], + )} + flow={node.flow} + text={node.description} + title={node.title} + canEdit={fetched && isUser && canEditNode(node, user)} + onChangeCellView={onChangeCellView} + /> + </div> + ))} + </Fragment> + ); + }, +); diff --git a/src/components/menu/SeparatedMenu/index.tsx b/src/components/menu/SeparatedMenu/index.tsx index 19a4c9e3..c030220b 100644 --- a/src/components/menu/SeparatedMenu/index.tsx +++ b/src/components/menu/SeparatedMenu/index.tsx @@ -4,7 +4,7 @@ import classNames from 'classnames'; import styles from './styles.module.scss'; -interface SeparatedMenuProps { +export interface SeparatedMenuProps { className?: string; } @@ -14,7 +14,7 @@ const SeparatedMenu: FC<SeparatedMenuProps> = ({ children, className }) => { return []; } - return (Array.isArray(children) ? children : [children]).filter(it => it); + return (Array.isArray(children) ? children : [children]).filter((it) => it); }, [children]); return ( diff --git a/src/components/node/NodeTitle/index.tsx b/src/components/node/NodeTitle/index.tsx index d33a4bab..99ae85ae 100644 --- a/src/components/node/NodeTitle/index.tsx +++ b/src/components/node/NodeTitle/index.tsx @@ -2,6 +2,7 @@ import React, { memo, VFC } from 'react'; import classNames from 'classnames'; +import { Authorized } from '~/components/containers/Authorized'; import { Icon } from '~/components/input/Icon'; import { SeparatedMenu } from '~/components/menu/SeparatedMenu'; import { NodeEditMenu } from '~/components/node/NodeEditMenu'; @@ -76,37 +77,39 @@ const NodeTitle: VFC<IProps> = memo( )} </div> - <SeparatedMenu className={styles.buttons}> - {canEdit && ( - <NodeEditMenu - className={styles.button} - canStar={canStar} - isHeroic={isHeroic} - isLocked={isLocked} - onStar={onStar} - onLock={onLock} - onEdit={onEdit} - /> - )} + <Authorized> + <SeparatedMenu className={styles.buttons}> + {canEdit && ( + <NodeEditMenu + className={styles.button} + canStar={canStar} + isHeroic={isHeroic} + isLocked={isLocked} + onStar={onStar} + onLock={onLock} + onEdit={onEdit} + /> + )} - {canLike && ( - <div - className={classNames(styles.button, styles.like, { - [styles.is_liked]: isLiked, - })} - > - {isLiked ? ( - <Icon icon="heart_full" size={24} onClick={onLike} /> - ) : ( - <Icon icon="heart" size={24} onClick={onLike} /> - )} + {canLike && ( + <div + className={classNames(styles.button, styles.like, { + [styles.is_liked]: isLiked, + })} + > + {isLiked ? ( + <Icon icon="heart_full" size={24} onClick={onLike} /> + ) : ( + <Icon icon="heart" size={24} onClick={onLike} /> + )} - {!!likeCount && likeCount > 0 && ( - <div className={styles.like_count}>{likeCount}</div> - )} - </div> - )} - </SeparatedMenu> + {!!likeCount && likeCount > 0 && ( + <div className={styles.like_count}>{likeCount}</div> + )} + </div> + )} + </SeparatedMenu> + </Authorized> </div> </div> ); diff --git a/src/containers/main/Header/index.tsx b/src/containers/main/Header/index.tsx index 893737d3..113409f3 100644 --- a/src/containers/main/Header/index.tsx +++ b/src/containers/main/Header/index.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; import isBefore from 'date-fns/isBefore'; @@ -16,7 +16,6 @@ import { URLS } from '~/constants/urls'; import { useAuth } from '~/hooks/auth/useAuth'; import { useScrollTop } from '~/hooks/dom/useScrollTop'; import { useFlow } from '~/hooks/flow/useFlow'; -import { useGetLabStats } from '~/hooks/lab/useGetLabStats'; import { useModal } from '~/hooks/modal/useModal'; import { useUpdates } from '~/hooks/updates/useUpdates'; import { useSidebar } from '~/utils/providers/SidebarProvider'; @@ -66,8 +65,13 @@ const Header: FC<HeaderProps> = observer(() => { <Filler className={styles.filler} /> - <nav className={styles.plugs}> - <Authorized> + <nav + className={classNames(styles.plugs, { + // [styles.active]: isHydrated && fetched, + [styles.active]: true, + })} + > + <Authorized hydratedOnly> <Anchor className={classNames(styles.item, { [styles.has_dot]: hasFlowUpdates, diff --git a/src/containers/main/Header/styles.module.scss b/src/containers/main/Header/styles.module.scss index fd01a468..3d2b0401 100644 --- a/src/containers/main/Header/styles.module.scss +++ b/src/containers/main/Header/styles.module.scss @@ -2,10 +2,10 @@ @keyframes appear { from { - transform: translate(0, -$header_height); + opacity: 0; } to { - transform: translate(0, 0); + opacity: 1; } } @@ -55,6 +55,12 @@ user-select: none; text-transform: uppercase; align-items: center; + opacity: 0; + transition: all 250ms; + + &.active { + opacity: 1; + } @include tablet { flex: 1; diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts index c15a2ab7..91649d00 100644 --- a/src/hooks/auth/useAuth.ts +++ b/src/hooks/auth/useAuth.ts @@ -16,5 +16,6 @@ export const useAuth = () => { setToken: auth.setToken, isTester: auth.isTester, setIsTester: auth.setIsTester, + fetched: auth.fetched, }; }; diff --git a/src/hooks/auth/useUser.ts b/src/hooks/auth/useUser.ts index 23fa3efc..e362ede7 100644 --- a/src/hooks/auth/useUser.ts +++ b/src/hooks/auth/useUser.ts @@ -10,11 +10,19 @@ import { IUser } from '~/types/auth'; import { showErrorToast } from '~/utils/errors/showToast'; export const useUser = () => { - const { token, setUser } = useAuthStore(); - const { data, mutate } = useSWR(token ? API.USER.ME : null, () => apiAuthGetUser(), { - onSuccess: data => setUser(data?.user || EMPTY_USER), - onError: error => showErrorToast(error), - }); + const { token, setUser, setFetched, user } = useAuthStore(); + const { data, mutate } = useSWR( + token ? API.USER.ME : null, + () => apiAuthGetUser(), + { + onSuccess: (data) => { + setUser(data?.user || EMPTY_USER); + setFetched(true); + }, + onError: (error) => showErrorToast(error), + fallbackData: { user }, + }, + ); const update = useCallback( async (user: Partial<IUser>, revalidate?: boolean) => { @@ -25,7 +33,7 @@ export const useUser = () => { await mutate({ ...data, user: { ...data.user, ...user } }, revalidate); }, - [data, mutate] + [data, mutate], ); return { user: data?.user || EMPTY_USER, update }; diff --git a/src/hooks/node/useNodePermissions.ts b/src/hooks/node/useNodePermissions.ts index a0825141..b1b9c7be 100644 --- a/src/hooks/node/useNodePermissions.ts +++ b/src/hooks/node/useNodePermissions.ts @@ -4,12 +4,24 @@ import { useUser } from '~/hooks/auth/useUser'; import { INode } from '~/types'; import { canEditNode, canLikeNode, canStarNode } from '~/utils/node'; +import { useAuth } from '../auth/useAuth'; + export const useNodePermissions = (node?: INode) => { const { user } = useUser(); + const { fetched, isUser } = useAuth(); - const edit = useMemo(() => canEditNode(node, user), [node, user]); - const like = useMemo(() => canLikeNode(node, user), [node, user]); - const star = useMemo(() => canStarNode(node, user), [node, user]); + const edit = useMemo( + () => fetched && isUser && canEditNode(node, user), + [node, user, fetched, isUser], + ); + const like = useMemo( + () => fetched && isUser && canLikeNode(node, user), + [node, user, fetched, isUser], + ); + const star = useMemo( + () => fetched && isUser && canStarNode(node, user), + [node, user, fetched, isUser], + ); return [edit, like, star]; }; diff --git a/src/layouts/NodeLayout/index.tsx b/src/layouts/NodeLayout/index.tsx index 0e600c8b..c4c7611f 100644 --- a/src/layouts/NodeLayout/index.tsx +++ b/src/layouts/NodeLayout/index.tsx @@ -1,5 +1,7 @@ import React, { FC } from 'react'; +import { observer } from 'mobx-react-lite'; + import { Superpower } from '~/components/boris/Superpower'; import { ScrollHelperBottom } from '~/components/common/ScrollHelperBottom'; import { Card } from '~/components/containers/Card'; @@ -18,7 +20,7 @@ import styles from './styles.module.scss'; type IProps = {}; -const NodeLayout: FC<IProps> = () => { +const NodeLayout: FC<IProps> = observer(() => { const { node, isLoading, update } = useNodeContext(); const { head, block } = useNodeBlocks(node, isLoading); const [canEdit, canLike, canStar] = useNodePermissions(node); @@ -70,6 +72,6 @@ const NodeLayout: FC<IProps> = () => { </Superpower> </div> ); -}; +}); export { NodeLayout }; diff --git a/src/store/auth/AuthStore.ts b/src/store/auth/AuthStore.ts index 52cb63c3..e80806bc 100644 --- a/src/store/auth/AuthStore.ts +++ b/src/store/auth/AuthStore.ts @@ -9,6 +9,7 @@ export class AuthStore { token: string = ''; user: IUser = EMPTY_USER; isTesterInternal: boolean = false; + fetched = false; constructor() { makeAutoObservable(this); @@ -46,4 +47,8 @@ export class AuthStore { this.token = ''; this.setUser(EMPTY_USER); }; + + setFetched = (fetched: boolean) => { + this.fetched = fetched; + }; } diff --git a/src/utils/providers/AuthProvider.tsx b/src/utils/providers/AuthProvider.tsx index 34aa4200..89d569e3 100644 --- a/src/utils/providers/AuthProvider.tsx +++ b/src/utils/providers/AuthProvider.tsx @@ -1,6 +1,7 @@ import { createContext, FC, useContext } from 'react'; import { observer } from 'mobx-react-lite'; +import { boolean } from 'yup'; import { EMPTY_USER } from '~/constants/auth'; import { useAuth } from '~/hooks/auth/useAuth'; @@ -18,6 +19,7 @@ const AuthContext = createContext<AuthProviderContextType>({ logout: () => {}, login: async () => EMPTY_USER, setToken: () => {}, + fetched: false, }); export const AuthProvider: FC = observer(({ children }) => {