Skip to content

Instantly share code, notes, and snippets.

@IPRIT
Last active April 18, 2020 10:50
Show Gist options
  • Select an option

  • Save IPRIT/9c53340e5eaa445514c271d6d30524e0 to your computer and use it in GitHub Desktop.

Select an option

Save IPRIT/9c53340e5eaa445514c271d6d30524e0 to your computer and use it in GitHub Desktop.
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,
};
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
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}`);
}
}
@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;
}
}
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]);
}
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