Last active
April 18, 2020 10:50
-
-
Save IPRIT/9c53340e5eaa445514c271d6d30524e0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import Like from 'assets/mg/reactions/reactions__icon_reaction_Like.svg'; | |
| import LikeBig from 'assets/mg/reactions/reactions__icon_reaction_Like-big.svg'; | |
| import Haha from 'assets/mg/reactions/reactions__icon_reaction_Haha.svg'; | |
| import HahaBig from 'assets/mg/reactions/reactions__icon_reaction_Haha-big.svg'; | |
| import Wow from 'assets/mg/reactions/reactions__icon_reaction_Wow.svg'; | |
| import WowBig from 'assets/mg/reactions/reactions__icon_reaction_Wow-big.svg'; | |
| // todo: вернуть обратно на Bored, когда закончится пандемия коронавирусом | |
| import Bored from 'assets/mg/reactions/reactions__icon_reaction_Virus.svg'; | |
| import BoredBig from 'assets/mg/reactions/reactions__icon_reaction_Virus-big.svg'; | |
| import Sad from 'assets/mg/reactions/reactions__icon_reaction_Sad.svg'; | |
| import SadBig from 'assets/mg/reactions/reactions__icon_reaction_Sad-big.svg'; | |
| import Angry from 'assets/mg/reactions/reactions__icon_reaction_Angry.svg'; | |
| import AngryBig from 'assets/mg/reactions/reactions__icon_reaction_Angry-big.svg'; | |
| import Dislike from 'assets/mg/reactions/reactions__icon_reaction_Dislike.svg'; | |
| import DislikeBig from 'assets/mg/reactions/reactions__icon_reaction_Dislike-big.svg'; | |
| import Empty from 'assets/mg/reactions/reactions__icon_empty.svg'; | |
| export { | |
| Like, | |
| LikeBig, | |
| Haha, | |
| HahaBig, | |
| Wow, | |
| WowBig, | |
| Bored, | |
| BoredBig, | |
| Sad, | |
| SadBig, | |
| Angry, | |
| AngryBig, | |
| Dislike, | |
| DislikeBig, | |
| Empty, | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { TReactionType } from 'types/neo/reactions'; | |
| export const availableReactions: TReactionType[] = ['Like', 'Haha', 'Wow', 'Bored', 'Sad', 'Angry', 'Dislike']; | |
| export const REACTIONS_PREVIEW_NUMBER = 3; | |
| export const REACTIONS_HOVER_TIMEOUT = 200; // ms |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { TReactionType } from 'types/neo/reactions'; | |
| import { EPopupDirection, EPopupState, IState } from './Reactions.types'; | |
| export enum EAction { | |
| ADD_REACTION, | |
| REMOVE_REACTION, | |
| INCREMENT, | |
| DECREMENT, | |
| CHANGE, | |
| HIDE_POPUP, | |
| SHOW_POPUP, | |
| SET_POPUP_STATE, | |
| SET_POPUP_DIRECTION, | |
| START_PROGRESS, | |
| STOP_PROGRESS, | |
| SET_ERROR, | |
| RESET_ERROR, | |
| } | |
| export interface IAction { | |
| type: EAction; | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| payload?: any; | |
| } | |
| export function reducer(state: IState, action: IAction): IState { | |
| const { type, payload } = action; | |
| switch (type) { | |
| case EAction.SET_POPUP_STATE: | |
| return { | |
| ...state, | |
| popupState: payload, | |
| }; | |
| case EAction.SET_POPUP_DIRECTION: | |
| return { | |
| ...state, | |
| popupDirection: payload, | |
| }; | |
| case EAction.HIDE_POPUP: | |
| return { | |
| ...state, | |
| popupState: EPopupState.INACTIVE, | |
| popupDirection: EPopupDirection.UP, | |
| }; | |
| case EAction.SHOW_POPUP: | |
| return { | |
| ...state, | |
| popupState: EPopupState.ACTIVE, | |
| }; | |
| case EAction.START_PROGRESS: | |
| return { | |
| ...state, | |
| progress: true, | |
| }; | |
| case EAction.STOP_PROGRESS: | |
| return { | |
| ...state, | |
| progress: false, | |
| }; | |
| case EAction.SET_ERROR: | |
| return { | |
| ...state, | |
| error: payload, | |
| }; | |
| case EAction.RESET_ERROR: | |
| return { | |
| ...state, | |
| error: null, | |
| }; | |
| case EAction.ADD_REACTION: { | |
| return { | |
| ...reducer(state, { | |
| type: EAction.INCREMENT, | |
| payload, | |
| }), | |
| reaction: payload, | |
| }; | |
| } | |
| case EAction.REMOVE_REACTION: { | |
| const { reaction, ...newState } = reducer(state, { | |
| type: EAction.DECREMENT, | |
| payload: state.reaction, | |
| }); | |
| return newState; | |
| } | |
| case EAction.INCREMENT: | |
| return reducer(state, { | |
| type: EAction.CHANGE, | |
| payload: { | |
| reaction: payload, | |
| change: 1, | |
| }, | |
| }); | |
| case EAction.DECREMENT: | |
| return reducer(state, { | |
| type: EAction.CHANGE, | |
| payload: { | |
| reaction: payload, | |
| change: -1, | |
| }, | |
| }); | |
| case EAction.CHANGE: { | |
| const { counts } = state; | |
| const { reaction, change } = payload as { | |
| reaction: TReactionType; | |
| change: number; | |
| }; | |
| const count = counts[reaction] || 0; | |
| return { | |
| ...state, | |
| counts: { | |
| ...counts, | |
| [reaction]: Math.max(0, count + change), | |
| }, | |
| }; | |
| } | |
| default: | |
| throw new Error(`Unknown action ${type}`); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @import 'components/mg/font-sizes.scss'; | |
| @import 'components/mg/layout-sizes.scss'; | |
| @import 'components/mg/colors.scss'; | |
| $reactions-paranja-z-index: 90; | |
| $block: mg-reactions; | |
| .mg-reactions { | |
| position: relative; | |
| display: inline-block; | |
| user-select: none; | |
| &__control { | |
| position: relative; | |
| z-index: $reactions-paranja-z-index + 1; | |
| font-size: $font-size-text-s; | |
| line-height: $line-height-text-s; | |
| white-space: nowrap; | |
| } | |
| &__reactions { | |
| position: relative; | |
| bottom: 3px; | |
| display: inline-block; | |
| height: 20px; | |
| margin-right: 4px; | |
| vertical-align: bottom; | |
| } | |
| &__label, | |
| &__counter { | |
| display: inline; | |
| margin-right: 4px; | |
| white-space: nowrap; | |
| } | |
| &__counter { | |
| font-weight: $font-weight-normal; | |
| opacity: 1; | |
| color: #939cb0; | |
| } | |
| &__icon { | |
| display: inline-block; | |
| width: 16px; | |
| height: 16px; | |
| margin-top: 4px; | |
| // https://st.yandex-team.ru/WEATHERFRONT-3335 | |
| margin-left: -1px; | |
| will-change: transform, opacity; | |
| animation: .5s reactions__pop-out; | |
| // используем 120%, чтобы пофиксить это: https://st.yandex-team.ru/WEATHERFRONT-3335 | |
| clip-path: polygon(0 120%, 120% 100%, 120% 0, 0 0, 0 10%, 7% 20%, 12% 30%, 15% 50%, 12% 70%, 7% 80%, 0 90%); | |
| &_big, | |
| &_empty, | |
| &_user, | |
| &:first-child { | |
| margin-left: 0; | |
| clip-path: none; | |
| } | |
| &_empty { | |
| animation: none; | |
| } | |
| &_big { | |
| width: 44px; | |
| height: 44px; | |
| margin-top: 0; | |
| margin-left: 9px; | |
| vertical-align: middle; | |
| opacity: 0; | |
| transition: .2s ease-in-out; | |
| animation: none; | |
| @media (max-width: 413px) { | |
| width: 36px; | |
| height: 36px; | |
| } | |
| @media (max-width: 360px) { | |
| width: 30px; | |
| height: 30px; | |
| } | |
| &:first-child { | |
| margin-left: 0; | |
| } | |
| .#{$block}__popup_direction_up.#{$block}__popup_state_shown & { | |
| animation: .5s reactions__pop-out_up; | |
| } | |
| .#{$block}__popup_direction_down.#{$block}__popup_state_shown & { | |
| animation: .5s reactions__pop-out_down; | |
| } | |
| .#{$block}__popup_state_shown & { | |
| opacity: 1; | |
| @for $i from 1 through 7 { | |
| &:nth-of-type(#{$i}) { | |
| $delay: .1s + $i * .035s; | |
| transition-delay: $delay; | |
| animation-delay: $delay; | |
| } | |
| } | |
| } | |
| &:hover { | |
| transition-delay: 0s !important; | |
| transform: scale(1.2); | |
| } | |
| } | |
| &_user { | |
| width: 20px; | |
| height: 20px; | |
| margin-top: 0; | |
| margin-left: 4px; | |
| animation: .5s reactions__pop-out; | |
| } | |
| } | |
| &__paranja { | |
| position: fixed; | |
| z-index: $reactions-paranja-z-index; | |
| top: 0; | |
| right: 0; | |
| bottom: 0; | |
| left: 0; | |
| -webkit-tap-highlight-color: transparent; | |
| } | |
| &__popup { | |
| position: absolute; | |
| z-index: $reactions-paranja-z-index + 2; | |
| left: -8px; | |
| overflow: hidden; | |
| padding: 12px; | |
| white-space: nowrap; | |
| opacity: .1; | |
| border-radius: 60px; | |
| background: $color-white-normal; | |
| box-shadow: 0 1px 20px rgba(0, 0, 0, .4); | |
| transition: transform .2s ease-in-out, opacity .15s ease-in-out; | |
| transform: scale(.3) translateY(30px); | |
| &_state_active { | |
| visibility: hidden; | |
| } | |
| &_state_shown { | |
| visibility: visible; | |
| opacity: 1; | |
| transform: scale(1) translateY(0); | |
| } | |
| &_direction_up { | |
| bottom: calc(100% + 8px); | |
| transform-origin: 0 100%; | |
| } | |
| &_direction_down { | |
| top: calc(100% + 8px); | |
| transform-origin: 0 0; | |
| } | |
| } | |
| &__error { | |
| position: fixed; | |
| z-index: 91; | |
| bottom: 25px; | |
| left: 50%; | |
| padding: 8px 16px; | |
| text-align: center; | |
| white-space: nowrap; | |
| pointer-events: none; | |
| opacity: 0; | |
| color: $color-white-normal; | |
| border-radius: 20px; | |
| background: #201d1d; | |
| box-shadow: rgba(0, 0, 0, .4); | |
| transform: translateX(-50%); | |
| animation: 2s reactions-control__error; | |
| } | |
| } | |
| @keyframes reactions__pop-out_up { | |
| 0% { | |
| transform: scale(.7, .7); | |
| transform-origin: 0 100%; | |
| } | |
| 25% { | |
| transform: scale(1.15, 1.15); | |
| transform-origin: 0 100%; | |
| } | |
| 50% { | |
| transform: scale(.95, .95); | |
| transform-origin: 0 100%; | |
| } | |
| 75% { | |
| transform: scale(1.02, 1.02); | |
| transform-origin: 0 100%; | |
| } | |
| 100% { | |
| transform: scale(1, 1); | |
| transform-origin: 50% 50%; | |
| } | |
| } | |
| @keyframes reactions__pop-out_down { | |
| 0% { | |
| transform: scale(.7, .7); | |
| transform-origin: 0 0; | |
| } | |
| 25% { | |
| transform: scale(1.15, 1.15); | |
| transform-origin: 0 0; | |
| } | |
| 50% { | |
| transform: scale(.95, .95); | |
| transform-origin: 0 0; | |
| } | |
| 75% { | |
| transform: scale(1.02, 1.02); | |
| transform-origin: 0 0; | |
| } | |
| 100% { | |
| transform: scale(1, 1); | |
| transform-origin: 50% 50%; | |
| } | |
| } | |
| @keyframes reactions__pop-out { | |
| 0% { | |
| transform: scale(.7, .7); | |
| } | |
| 25% { | |
| transform: scale(1.15, 1.15); | |
| } | |
| 50% { | |
| transform: scale(.95, .95); | |
| } | |
| 75% { | |
| transform: scale(1.02, 1.02); | |
| } | |
| 100% { | |
| transform: scale(1, 1); | |
| } | |
| } | |
| @keyframes reactions-control__error { | |
| 0% { | |
| opacity: 0; | |
| } | |
| 5% { | |
| opacity: .9; | |
| } | |
| 80% { | |
| opacity: .9; | |
| } | |
| 100% { | |
| opacity: 0; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { | |
| MutableRefObject, | |
| ReactText, | |
| Reducer, | |
| Ref, | |
| RefObject, | |
| useCallback, | |
| useEffect, | |
| useMemo, | |
| useReducer, | |
| useRef, | |
| } from 'react'; | |
| import { TReactions, TReactionType } from 'types/neo/reactions'; | |
| import { useBaobab } from 'hooks/neo/useBaobab'; | |
| import { formatCounter } from 'functions/neo/helpers/formatCounter'; | |
| import { | |
| EPopupDirection, | |
| EPopupState, | |
| IMouseTimeouts, | |
| IPopupProps, | |
| IPreviewProps, | |
| IProps, | |
| IReaction, | |
| IState, | |
| } from './Reactions.types'; | |
| import { ReactionsApi } from './Reactions.api'; | |
| import { EAction, IAction, reducer } from './Reactions.reducer'; | |
| import { availableReactions, REACTIONS_HOVER_TIMEOUT, REACTIONS_PREVIEW_NUMBER } from './Reactions.const'; | |
| import { cls } from './Reactions.cn'; | |
| import * as Icons from './Reactions.assets'; | |
| import './Reactions.scss'; | |
| const noop = () => true; | |
| export function Reactions(props: IProps): JSX.Element { | |
| const { | |
| counts, | |
| reaction, | |
| endpoint, | |
| className = '', | |
| mouseEventsEnabled = false, | |
| baobabAttrs, | |
| } = props; | |
| const api = useReactionsApi(endpoint); | |
| const { BaobabNode, counterProps } = useBaobab('Reactions', baobabAttrs); | |
| const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, { | |
| counts, | |
| reaction, | |
| progress: false, | |
| popupState: EPopupState.INACTIVE, | |
| popupDirection: EPopupDirection.UP, | |
| error: null, | |
| }); | |
| // explicitly cast MutableRef to Ref for future forwarding | |
| const popupRef = useRef(null) as RefObject<HTMLDivElement>; | |
| const controlRef = useRef(null) as RefObject<HTMLDivElement>; | |
| // we need to store hover state for mouse events, | |
| // if user has reaction already - disable hover | |
| const hoverRef = useRef<boolean>(); | |
| if (hoverRef.current === undefined) { | |
| hoverRef.current = !state.reaction; | |
| } | |
| const hidePopup = useCallback(() => { | |
| dispatch({ type: EAction.HIDE_POPUP }); | |
| }, []); | |
| const onScroll = useCallback(() => { | |
| hidePopup(); | |
| document.removeEventListener('scroll', onScroll); | |
| }, [hidePopup]); | |
| const showPopup = useCallback(() => { | |
| dispatch({ type: EAction.SHOW_POPUP }); | |
| document.addEventListener('scroll', onScroll); | |
| }, [onScroll]); | |
| const deleteReaction = useCallback(() => { | |
| const oldReaction = state.reaction; | |
| dispatch({ type: EAction.RESET_ERROR }); | |
| dispatch({ type: EAction.START_PROGRESS }); | |
| dispatch({ type: EAction.REMOVE_REACTION }); | |
| const onReactionDeleted = () => { | |
| dispatch({ type: EAction.STOP_PROGRESS }); | |
| }; | |
| const onError = (error: Error) => { | |
| dispatch({ type: EAction.STOP_PROGRESS }); | |
| dispatch({ type: EAction.SET_ERROR, payload: error }); | |
| // set previous reaction when error occurred | |
| dispatch({ type: EAction.ADD_REACTION, payload: oldReaction }); | |
| }; | |
| api.delete() | |
| .then(onReactionDeleted) | |
| .catch(onError); | |
| }, [api, state.reaction]); | |
| const setReaction = useCallback((reactionType: TReactionType) => { | |
| hoverRef.current = false; | |
| dispatch({ type: EAction.HIDE_POPUP }); | |
| dispatch({ type: EAction.RESET_ERROR }); | |
| dispatch({ type: EAction.START_PROGRESS }); | |
| dispatch({ type: EAction.ADD_REACTION, payload: reactionType }); | |
| const onReactionSet = () => { | |
| dispatch({ type: EAction.STOP_PROGRESS }); | |
| }; | |
| const onError = (error: Error) => { | |
| dispatch({ type: EAction.STOP_PROGRESS }); | |
| dispatch({ type: EAction.SET_ERROR, payload: error }); | |
| // remove previous reaction when error occurred | |
| dispatch({ type: EAction.REMOVE_REACTION }); | |
| }; | |
| api.set(reactionType) | |
| .then(onReactionSet) | |
| .catch(onError); | |
| }, [api]); | |
| const onPreviewClick = useCallback(() => { | |
| const { reaction, progress, popupState } = state; | |
| if (progress || popupState === EPopupState.ACTIVE) { | |
| return; | |
| } | |
| if (popupState === EPopupState.SHOWN) { | |
| hidePopup(); | |
| } else if (reaction) { | |
| deleteReaction(); | |
| } else { | |
| showPopup(); | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [ | |
| deleteReaction, hidePopup, showPopup, | |
| state.reaction, state.progress, state.popupState, | |
| ]); | |
| useMouseEvents({ | |
| controlRef, | |
| popupRef, | |
| hoverRef, | |
| popupState: state.popupState, | |
| mouseEventsEnabled, | |
| showPopup, | |
| hidePopup, | |
| }); | |
| useEffect(() => { | |
| if (state.popupState === EPopupState.ACTIVE && popupRef.current) { | |
| const direction = isElementInViewport(popupRef.current) | |
| ? EPopupDirection.UP | |
| : EPopupDirection.DOWN; | |
| dispatch({ type: EAction.SET_POPUP_STATE, payload: EPopupState.SHOWN }); | |
| dispatch({ type: EAction.SET_POPUP_DIRECTION, payload: direction }); | |
| } | |
| }, [popupRef, state.popupState]); | |
| const shouldRenderPopup = state.popupState !== EPopupState.INACTIVE; | |
| return ( | |
| <BaobabNode> | |
| <div className={cls({ progress: state.progress, 'popup-state': state.popupState }, [className])} {...counterProps}> | |
| <ReactionsPreview | |
| counts={state.counts} | |
| reaction={state.reaction} | |
| onClick={onPreviewClick} | |
| ref={controlRef} | |
| /> | |
| { | |
| shouldRenderPopup && ( | |
| <ReactionsPopup | |
| popupDirection={state.popupDirection} | |
| popupState={state.popupState} | |
| onOutsideClick={hidePopup} | |
| onReaction={setReaction} | |
| ref={popupRef} | |
| /> | |
| ) | |
| } | |
| { | |
| state.error && ( | |
| <div className={cls('error')}>Произошла неизвестная ошибка</div> | |
| ) | |
| } | |
| </div> | |
| </BaobabNode> | |
| ); | |
| } | |
| const ReactionsPreview = React.memo(React.forwardRef(function ReactionsPreview( | |
| props: IPreviewProps, | |
| ref: Ref<HTMLDivElement>, | |
| ): JSX.Element { | |
| const { | |
| counts, | |
| reaction, | |
| onClick, | |
| } = props; | |
| const { counterProps } = useBaobab('ReactionsPreview'); | |
| const { | |
| topReactions, | |
| showUserReaction, | |
| reactionText, | |
| } = useMemo(() => { | |
| const sortedReactions = getSortedReactions({ counts, reaction }); | |
| const counter = getCounter({ counts, reaction }); | |
| // eslint-disable-next-line no-nested-ternary | |
| const reactionText = reaction | |
| ? counter | |
| ? `Вы +${counter}` | |
| : 'Вы оценили' | |
| : counter; | |
| // не показываем реакцию пользователя, | |
| // если существует только 1 тип реакции (включая реакцию пользователя) | |
| const showUserReaction = reaction && !( | |
| sortedReactions.length === 1 | |
| && sortedReactions[0].reaction === reaction | |
| ); | |
| return { | |
| topReactions: sortedReactions.slice(0, REACTIONS_PREVIEW_NUMBER), | |
| showUserReaction, | |
| reactionText, | |
| }; | |
| }, [counts, reaction]); | |
| return ( | |
| <div className={cls('control')} onClick={onClick} ref={ref} {...counterProps}> | |
| <div className={cls('reactions')} key="reactions"> | |
| { | |
| topReactions.length | |
| ? topReactions.map(({ reaction }) => ( | |
| <div className={cls('icon')} key={reaction}> | |
| <ReactionIcon reaction={reaction} width={16} height={16} /> | |
| </div> | |
| )) | |
| : ( | |
| <div className={cls('icon', { empty: true })} key="empty"> | |
| <Icons.Empty width={16} height={16} /> | |
| </div> | |
| ) | |
| } | |
| { | |
| showUserReaction && ( | |
| <div className={cls('icon', { user: true })} key="user"> | |
| <ReactionIcon reaction={reaction!} width={20} height={20} /> | |
| </div> | |
| ) | |
| } | |
| </div> | |
| { | |
| !reaction && ( | |
| <div className={cls('label')} key="label">Оценить</div> | |
| ) | |
| } | |
| <div className={cls('counter')} key="counter"> | |
| {reactionText} | |
| </div> | |
| </div> | |
| ); | |
| })); | |
| const ReactionsPopup = React.forwardRef(function ReactionsPopup( | |
| props: IPopupProps, | |
| ref: Ref<HTMLDivElement>, | |
| ) { | |
| const { | |
| popupDirection, | |
| popupState, | |
| onReaction = noop, | |
| onOutsideClick = noop, | |
| } = props; | |
| const onReactionClick = useCallback((reaction: TReactionType) => { | |
| return () => onReaction(reaction); | |
| }, [onReaction]); | |
| return ( | |
| <> | |
| <div className={cls('paranja')} onClick={onOutsideClick} /> | |
| <div className={cls('popup', { direction: popupDirection, state: popupState })} ref={ref}> | |
| { | |
| availableReactions.map((reaction) => ( | |
| <div | |
| key={reaction} | |
| className={cls('icon', { big: true })} | |
| onClick={onReactionClick(reaction)} | |
| > | |
| <ReactionIcon reaction={reaction} big width="100%" height="100%" /> | |
| </div> | |
| )) | |
| } | |
| </div> | |
| </> | |
| ); | |
| }); | |
| const ReactionIcon = React.memo(function ReactionIcon(props: { | |
| reaction: string; | |
| big?: boolean; | |
| width?: ReactText; | |
| height?: ReactText; | |
| }) { | |
| const { | |
| reaction, | |
| big, | |
| ...otherProps | |
| } = props; | |
| const name = big ? `${reaction}Big` : reaction; | |
| // @ts-ignore | |
| // eslint-disable-next-line import/namespace | |
| return React.createElement(Icons[name], otherProps); | |
| }); | |
| function getCounter(params: { | |
| counts: TReactions<number>; | |
| reaction?: TReactionType; | |
| }): string { | |
| const total = getTotalCount({ | |
| ...params, | |
| ignoreUserReaction: true, | |
| }); | |
| if (!total) { | |
| return ''; | |
| } | |
| return formatCounter(total, { | |
| withPrecise: true, | |
| }); | |
| } | |
| function getTotalCount(params: { | |
| counts: TReactions<number>; | |
| reaction?: TReactionType; | |
| ignoreUserReaction?: boolean; | |
| }): number { | |
| const { | |
| reaction, | |
| ignoreUserReaction = false, | |
| } = params; | |
| const totalLikes = getReactions(params).reduce((total, { count }) => total + count, 0); | |
| const userLikes = reaction && ignoreUserReaction ? 1 : 0; | |
| return totalLikes - userLikes; | |
| } | |
| function getReactions(params: { | |
| counts: TReactions<number>; | |
| reaction?: TReactionType; | |
| }): IReaction[] { | |
| const { | |
| counts, | |
| reaction: userReaction, | |
| } = params; | |
| return availableReactions.map((reaction) => { | |
| let count = Number(counts[reaction]) || 0; | |
| // случай, когда агрегат не пересчитался | |
| // (пользовательская реакция есть, но количество лайков этого типа равно нулю) | |
| if (userReaction && reaction === userReaction && count === 0) { | |
| count = 1; | |
| } | |
| return count > 0 | |
| ? { reaction, count } | |
| : null; | |
| }).filter(Boolean) as IReaction[]; | |
| } | |
| function getSortedReactions(params: { | |
| counts: TReactions<number>; | |
| reaction?: TReactionType; | |
| }): IReaction[] { | |
| return getReactions(params) | |
| .sort((a: IReaction, b: IReaction) => b.count - a.count); | |
| } | |
| function isElementInViewport(element: HTMLDivElement) { | |
| const { | |
| top, left, | |
| bottom, right, | |
| } = element.getBoundingClientRect(); | |
| const upperLimit = 84; // subrubrics height + reserved space | |
| return top >= upperLimit | |
| && left >= 0 | |
| && bottom <= (window.innerHeight || document.documentElement.clientHeight) | |
| && right <= (window.innerWidth || document.documentElement.clientWidth); | |
| } | |
| function useMouseEvents(params: { | |
| controlRef: RefObject<HTMLDivElement>; | |
| popupRef: RefObject<HTMLDivElement>; | |
| hoverRef: MutableRefObject<boolean | undefined>; | |
| popupState: EPopupState; | |
| mouseEventsEnabled: boolean; | |
| showPopup: () => void; | |
| hidePopup: () => void; | |
| }) { | |
| const { | |
| controlRef, | |
| popupRef, | |
| hoverRef, | |
| popupState, | |
| mouseEventsEnabled, | |
| showPopup, | |
| hidePopup, | |
| } = params; | |
| const timeoutsRef = useRef<IMouseTimeouts>({ | |
| enter: null, | |
| leave: null, | |
| }); | |
| const onPointerEnter = useCallback(() => { | |
| if (timeoutsRef.current.leave) { | |
| clearTimeout(timeoutsRef.current.leave); | |
| } | |
| if (popupState === EPopupState.INACTIVE) { | |
| timeoutsRef.current.enter = setTimeout(showPopup, REACTIONS_HOVER_TIMEOUT); | |
| } | |
| }, [showPopup, popupState]); | |
| const onPointerLeave = useCallback(() => { | |
| if (timeoutsRef.current.enter) { | |
| clearTimeout(timeoutsRef.current.enter); | |
| } | |
| if (popupState !== EPopupState.INACTIVE) { | |
| timeoutsRef.current.leave = setTimeout(hidePopup, REACTIONS_HOVER_TIMEOUT); | |
| } | |
| }, [hidePopup, popupState]); | |
| useEffect(() => { | |
| if (!mouseEventsEnabled) { | |
| return; | |
| } | |
| const { current: control } = controlRef; | |
| const { current: popup } = popupRef; | |
| const { current: hoverEnabled } = hoverRef; | |
| if (hoverEnabled) { | |
| control?.addEventListener('mouseleave', onPointerLeave); | |
| control?.addEventListener('mouseenter', onPointerEnter); | |
| if (popupState !== EPopupState.INACTIVE) { | |
| popup?.addEventListener('mouseleave', onPointerLeave); | |
| popup?.addEventListener('mouseenter', onPointerEnter); | |
| } | |
| } | |
| return () => { | |
| control?.removeEventListener('mouseleave', onPointerLeave); | |
| control?.removeEventListener('mouseenter', onPointerEnter); | |
| popup?.removeEventListener('mouseleave', onPointerLeave); | |
| popup?.removeEventListener('mouseenter', onPointerEnter); | |
| }; | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [popupState, hoverRef]); | |
| } | |
| export function useReactionsApi(endpoint: string) { | |
| return useMemo<ReactionsApi>(() => { | |
| return new ReactionsApi(endpoint); | |
| }, [endpoint]); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React from 'react'; | |
| import { IBaobabProps } from 'types/neo/baobab'; | |
| import { TReactions, TReactionType } from 'types/neo/reactions'; | |
| export interface IProps extends IBaobabProps { | |
| endpoint: string; | |
| counts: TReactions<number>; | |
| reaction?: TReactionType; | |
| className?: string; | |
| mouseEventsEnabled?: boolean; | |
| } | |
| export interface IPreviewProps { | |
| counts: TReactions<number>; | |
| reaction?: TReactionType; | |
| onClick?: (event: React.MouseEvent<HTMLDivElement>) => void; | |
| } | |
| export interface IPopupProps { | |
| popupDirection: EPopupDirection; | |
| popupState: EPopupState; | |
| onReaction?: (reaction: TReactionType) => void; | |
| onOutsideClick?: () => void; | |
| } | |
| /** | |
| * статусы попапа: | |
| * - active - создан в DOM, но скрыт (нужен для определения попадания его во вьюпорт и отображения под/над контролом | |
| * - shown - направление определено, отображаем попап | |
| * - inactive - удален из DOM | |
| */ | |
| export enum EPopupState { | |
| ACTIVE = 'active', | |
| SHOWN = 'shown', | |
| INACTIVE = 'inactive', | |
| } | |
| export enum EPopupDirection { | |
| UP = 'up', | |
| DOWN = 'down', | |
| } | |
| export interface IState { | |
| counts: TReactions<number>; | |
| reaction?: TReactionType; | |
| progress: boolean; | |
| popupState: EPopupState; | |
| popupDirection: EPopupDirection; | |
| error: Error | null; | |
| } | |
| export interface IReaction { | |
| reaction: TReactionType; | |
| count: number; | |
| } | |
| export interface IMouseTimeouts { | |
| enter: NodeJS.Timeout | null; | |
| leave: NodeJS.Timeout | null; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment