Story: Display reaction effect (#3829)

This commit is contained in:
Alexander Zinchuk 2023-09-15 16:44:29 +02:00
parent 6e43795216
commit 31403702fe
19 changed files with 222 additions and 155 deletions

View File

@ -110,7 +110,8 @@
&.story-reaction-button { &.story-reaction-button {
--custom-emoji-size: 1.5rem; --custom-emoji-size: 1.5rem;
--custom-emoji-border-radius: 0.25rem;
overflow: visible !important;
&:hover { &:hover {
background-color: var(--color-background) !important; background-color: var(--color-background) !important;

View File

@ -26,7 +26,8 @@ import type {
ApiVideo, ApiVideo,
} from '../../api/types'; } from '../../api/types';
import type { import type {
ApiDraft, GlobalState, MessageList, MessageListType, TabState, ApiDraft, GlobalState, MessageList,
MessageListType, TabState,
} from '../../global/types'; } from '../../global/types';
import type { IAnchorPosition, InlineBotSettings, ISettings } from '../../types'; import type { IAnchorPosition, InlineBotSettings, ISettings } from '../../types';
@ -41,6 +42,7 @@ import {
import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterdom'; import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterdom';
import { import {
getAllowedAttachmentOptions, getAllowedAttachmentOptions,
getStoryKey,
isChatAdmin, isChatAdmin,
isChatChannel, isChatChannel,
isChatSuperGroup, isChatSuperGroup,
@ -92,7 +94,6 @@ import applyIosAutoCapitalizationFix from '../middle/composer/helpers/applyIosAu
import buildAttachment, { prepareAttachmentsToSend } from '../middle/composer/helpers/buildAttachment'; import buildAttachment, { prepareAttachmentsToSend } from '../middle/composer/helpers/buildAttachment';
import { buildCustomEmojiHtml } from '../middle/composer/helpers/customEmoji'; import { buildCustomEmojiHtml } from '../middle/composer/helpers/customEmoji';
import { isSelectionInsideInput } from '../middle/composer/helpers/selection'; import { isSelectionInsideInput } from '../middle/composer/helpers/selection';
import { REM } from './helpers/mediaDimensions';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';
import { getTextWithEntitiesAsHtml } from './helpers/renderTextWithEntities'; import { getTextWithEntitiesAsHtml } from './helpers/renderTextWithEntities';
@ -149,7 +150,7 @@ import ResponsiveHoverButton from '../ui/ResponsiveHoverButton';
import Spinner from '../ui/Spinner'; import Spinner from '../ui/Spinner';
import Avatar from './Avatar'; import Avatar from './Avatar';
import DeleteMessageModal from './DeleteMessageModal.async'; import DeleteMessageModal from './DeleteMessageModal.async';
import ReactionStaticEmoji from './ReactionStaticEmoji'; import ReactionAnimatedEmoji from './reactions/ReactionAnimatedEmoji';
import './Composer.scss'; import './Composer.scss';
@ -260,7 +261,6 @@ const MESSAGE_MAX_LENGTH = 4096;
const SENDING_ANIMATION_DURATION = 350; const SENDING_ANIMATION_DURATION = 350;
const MOUNT_ANIMATION_DURATION = 430; const MOUNT_ANIMATION_DURATION = 430;
const REACTION_SIZE = 1.5 * REM;
const HEART_REACTION: ApiReaction = { const HEART_REACTION: ApiReaction = {
emoticon: '❤', emoticon: '❤',
}; };
@ -1293,8 +1293,8 @@ const Composer: FC<OwnProps & StateProps> = ({
|| isMentionTooltipOpen || isInlineBotTooltipOpen || isDeleteModalOpen || isBotCommandMenuOpen || isAttachMenuOpen || isMentionTooltipOpen || isInlineBotTooltipOpen || isDeleteModalOpen || isBotCommandMenuOpen || isAttachMenuOpen
|| isStickerTooltipOpen || isBotCommandTooltipOpen || isCustomEmojiTooltipOpen || isBotMenuButtonOpen || isStickerTooltipOpen || isBotCommandTooltipOpen || isCustomEmojiTooltipOpen || isBotMenuButtonOpen
|| isCustomSendMenuOpen || Boolean(activeVoiceRecording) || attachments.length > 0 || isInputHasFocus; || isCustomSendMenuOpen || Boolean(activeVoiceRecording) || attachments.length > 0 || isInputHasFocus;
const isReactionSelectorOpen = (isComposerHasFocus || isReactionPickerOpen) const isReactionSelectorOpen = isComposerHasFocus && !isReactionPickerOpen && isInStoryViewer && !isAttachMenuOpen
&& isInStoryViewer && !isAttachMenuOpen && !isSymbolMenuOpen; && !isSymbolMenuOpen;
useEffect(() => { useEffect(() => {
if (isComposerHasFocus) { if (isComposerHasFocus) {
@ -1814,13 +1814,14 @@ const Composer: FC<OwnProps & StateProps> = ({
ariaLabel={lang('AccDescrLike')} ariaLabel={lang('AccDescrLike')}
ref={storyReactionRef} ref={storyReactionRef}
> >
{sentStoryReaction && !isSentStoryReactionHeart ? ( {sentStoryReaction && (
<ReactionStaticEmoji <ReactionAnimatedEmoji
containerId={getStoryKey(chatId, storyId!)}
reaction={sentStoryReaction} reaction={sentStoryReaction}
availableReactions={availableReactions} withEffectOnly={isSentStoryReactionHeart}
size={REACTION_SIZE}
/> />
) : ( )}
{(!sentStoryReaction || isSentStoryReactionHeart) && (
<i <i
className={buildClassName( className={buildClassName(
'icon', 'icon',

View File

@ -99,6 +99,7 @@ const ReactionEmoji: FC<OwnProps> = ({
className={transitionClassNames} className={transitionClassNames}
sharedCanvas={sharedCanvasRef!.current || undefined} sharedCanvas={sharedCanvasRef!.current || undefined}
sharedCanvasCoords={coords} sharedCanvasCoords={coords}
forceAlways={forcePlayback}
/> />
)} )}
</div> </div>

View File

@ -9,7 +9,7 @@ import { IS_OFFSET_PATH_SUPPORTED } from '../../../util/windowEnvironment';
import useMedia from '../../../hooks/useMedia'; import useMedia from '../../../hooks/useMedia';
import CustomEmoji from '../../common/CustomEmoji'; import CustomEmoji from '../CustomEmoji';
import styles from './CustomEmojiEffect.module.scss'; import styles from './CustomEmojiEffect.module.scss';

View File

@ -6,23 +6,27 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 1.75rem; width: var(--custom-emoji-size);
height: 1.75rem; height: var(--custom-emoji-size);
margin-inline-end: 0.125rem;
z-index: 1; z-index: 1;
} }
.animated-icon, .effect { .animated-icon {
position: fixed; position: absolute;
top: -0.375rem; top: 50%;
left: -0.375rem; left: 50%;
pointer-events: none; transform: translate(-50%, -50%);
}
&.effect { .effect {
top: -2.25rem; position: fixed;
left: -2.25rem; top: -2.25rem;
} left: -2.25rem;
}
.animated-icon, .effect {
pointer-events: none;
&:not(:global(.open)) { &:not(:global(.open)) {
opacity: 1 !important; opacity: 1 !important;
@ -39,3 +43,10 @@
// Fix for redundant scroll in Firefox // Fix for redundant scroll in Firefox
contain: layout; contain: layout;
} }
.withEffectOnly {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

View File

@ -1,51 +1,65 @@
import type { FC } from '../../../lib/teact/teact';
import React, { import React, {
memo, useMemo, useRef, memo, useMemo, useRef,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { ApiAvailableReaction, ApiReaction, ApiStickerSet } from '../../../api/types'; import type { ApiAvailableReaction, ApiReaction, ApiStickerSet } from '../../../api/types';
import type { ActiveReaction } from '../../../global/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { isSameReaction } from '../../../global/helpers'; import { isSameReaction } from '../../../global/helpers';
import { selectPerformanceSettingsValue, selectTabState } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { REM } from '../../common/helpers/mediaDimensions'; import { roundToNearestEven } from '../../../util/math';
import { REM } from '../helpers/mediaDimensions';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver'; import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useMedia from '../../../hooks/useMedia'; import useMedia from '../../../hooks/useMedia';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import useCustomEmoji from '../../common/hooks/useCustomEmoji'; import useCustomEmoji from '../hooks/useCustomEmoji';
import AnimatedSticker from '../../common/AnimatedSticker'; import AnimatedSticker from '../AnimatedSticker';
import CustomEmoji from '../../common/CustomEmoji'; import CustomEmoji from '../CustomEmoji';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji'; import ReactionStaticEmoji from '../ReactionStaticEmoji';
import CustomEmojiEffect from './CustomEmojiEffect'; import CustomEmojiEffect from './CustomEmojiEffect';
import styles from './ReactionAnimatedEmoji.module.scss'; import styles from './ReactionAnimatedEmoji.module.scss';
type OwnProps = { type OwnProps = {
containerId: string;
reaction: ApiReaction; reaction: ApiReaction;
activeReactions?: ActiveReaction[]; className?: string;
size?: number;
effectSize?: number;
withEffectOnly?: boolean;
observeIntersection?: ObserveFn;
};
type StateProps = {
activeReactions?: ApiReaction[];
availableReactions?: ApiAvailableReaction[]; availableReactions?: ApiAvailableReaction[];
genericEffects?: ApiStickerSet; genericEffects?: ApiStickerSet;
observeIntersection?: ObserveFn;
withEffects?: boolean; withEffects?: boolean;
}; };
const CENTER_ICON_SIZE = 2.5 * REM; const ICON_SIZE = 1.5 * REM;
const CENTER_ICON_MULTIPLIER = 1.9;
const EFFECT_SIZE = 6.5 * REM; const EFFECT_SIZE = 6.5 * REM;
const ReactionAnimatedEmoji: FC<OwnProps> = ({ const ReactionAnimatedEmoji = ({
containerId,
reaction, reaction,
genericEffects, className,
size = ICON_SIZE,
effectSize = EFFECT_SIZE,
activeReactions, activeReactions,
availableReactions, availableReactions,
observeIntersection, genericEffects,
withEffects, withEffects,
}) => { withEffectOnly,
observeIntersection,
}: OwnProps & StateProps) => {
const { stopActiveReaction } = getActions(); const { stopActiveReaction } = getActions();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
@ -93,7 +107,7 @@ const ReactionAnimatedEmoji: FC<OwnProps> = ({
const mediaDataEffect = useMedia(mediaHashEffect, !effectId); const mediaDataEffect = useMedia(mediaHashEffect, !effectId);
const activeReaction = useMemo(() => ( const activeReaction = useMemo(() => (
activeReactions?.find((active) => isSameReaction(active.reaction, reaction)) activeReactions?.find((active) => isSameReaction(active, reaction))
), [activeReactions, reaction]); ), [activeReactions, reaction]);
const shouldPlay = Boolean(withEffects && activeReaction && (isCustom || mediaDataCenterIcon) && mediaDataEffect); const shouldPlay = Boolean(withEffects && activeReaction && (isCustom || mediaDataCenterIcon) && mediaDataEffect);
@ -103,26 +117,39 @@ const ReactionAnimatedEmoji: FC<OwnProps> = ({
} = useShowTransition(shouldPlay, undefined, true, 'slow'); } = useShowTransition(shouldPlay, undefined, true, 'slow');
const handleEnded = useLastCallback(() => { const handleEnded = useLastCallback(() => {
if (!activeReaction?.messageId) return; stopActiveReaction({ containerId, reaction });
stopActiveReaction({ messageId: activeReaction.messageId, reaction });
}); });
const [isAnimationLoaded, markAnimationLoaded, unmarkAnimationLoaded] = useFlag(); const [isAnimationLoaded, markAnimationLoaded, unmarkAnimationLoaded] = useFlag();
const shouldRenderStatic = !isCustom && (!shouldPlay || !isAnimationLoaded); const shouldShowStatic = !isCustom && (!shouldPlay || !isAnimationLoaded);
const {
shouldRender: shouldRenderStatic,
transitionClassNames: staticClassNames,
} = useShowTransition(shouldShowStatic, undefined, true);
const className = buildClassName( const rootClassName = buildClassName(
styles.root, styles.root,
shouldRenderAnimation && styles.animating, shouldRenderAnimation && styles.animating,
isCustom && styles.isCustomEmoji, withEffectOnly && styles.withEffectOnly,
className,
); );
return ( return (
<div className={className} ref={ref}> <div className={rootClassName} ref={ref}>
{shouldRenderStatic && <ReactionStaticEmoji reaction={reaction} availableReactions={availableReactions} />} {!withEffectOnly && shouldRenderStatic && (
{isCustom && ( <ReactionStaticEmoji
className={staticClassNames}
reaction={reaction}
availableReactions={availableReactions}
size={size}
observeIntersection={observeIntersection}
/>
)}
{!withEffectOnly && isCustom && (
<CustomEmoji <CustomEmoji
documentId={reaction.documentId} documentId={reaction.documentId}
className={styles.customEmoji} className={styles.customEmoji}
size={size}
observeIntersectionForPlaying={observeIntersection} observeIntersectionForPlaying={observeIntersection}
/> />
)} )}
@ -131,20 +158,19 @@ const ReactionAnimatedEmoji: FC<OwnProps> = ({
<AnimatedSticker <AnimatedSticker
key={effectId} key={effectId}
className={buildClassName(styles.effect, animationClassNames)} className={buildClassName(styles.effect, animationClassNames)}
size={EFFECT_SIZE} size={effectSize}
tgsUrl={mediaDataEffect} tgsUrl={mediaDataEffect}
play={isIntersecting} play={isIntersecting}
noLoop noLoop
forceAlways forceAlways
onEnded={handleEnded} onEnded={handleEnded}
/> />
{isCustom ? ( {isCustom && !assignedEffectId && isIntersecting && <CustomEmojiEffect reaction={reaction} />}
!assignedEffectId && isIntersecting && <CustomEmojiEffect reaction={reaction} /> {!isCustom && !withEffectOnly && (
) : (
<AnimatedSticker <AnimatedSticker
key={centerIconId} key={centerIconId}
className={buildClassName(styles.animatedIcon, animationClassNames)} className={buildClassName(styles.animatedIcon, animationClassNames)}
size={CENTER_ICON_SIZE} size={roundToNearestEven(size * CENTER_ICON_MULTIPLIER)}
tgsUrl={mediaDataCenterIcon} tgsUrl={mediaDataCenterIcon}
play={isIntersecting} play={isIntersecting}
noLoop noLoop
@ -159,4 +185,18 @@ const ReactionAnimatedEmoji: FC<OwnProps> = ({
); );
}; };
export default memo(ReactionAnimatedEmoji); export default memo(withGlobal<OwnProps>(
(global, { containerId }) => {
const { availableReactions, genericEmojiEffects } = global;
const { activeReactions } = selectTabState(global);
const withEffects = selectPerformanceSettingsValue(global, 'reactionEffects');
return {
activeReactions: activeReactions?.[containerId],
availableReactions,
genericEffects: genericEmojiEffects,
withEffects,
};
},
)(ReactionAnimatedEmoji));

View File

@ -15,7 +15,7 @@ import useTimeout from '../../../hooks/useTimeout';
import CustomEmoji from '../../common/CustomEmoji'; import CustomEmoji from '../../common/CustomEmoji';
import PremiumIcon from '../../common/PremiumIcon'; import PremiumIcon from '../../common/PremiumIcon';
import CustomEmojiEffect from '../../middle/message/CustomEmojiEffect'; import CustomEmojiEffect from '../../common/reactions/CustomEmojiEffect';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import StatusPickerMenu from './StatusPickerMenu.async'; import StatusPickerMenu from './StatusPickerMenu.async';

View File

@ -11,7 +11,6 @@ import type {
ApiMessage, ApiMessage,
ApiMessageOutgoingStatus, ApiMessageOutgoingStatus,
ApiReaction, ApiReaction,
ApiStickerSet,
ApiThreadInfo, ApiThreadInfo,
ApiTopic, ApiTopic,
ApiTypeStory, ApiTypeStory,
@ -20,7 +19,6 @@ import type {
} from '../../../api/types'; } from '../../../api/types';
import type { import type {
ActiveEmojiInteraction, ActiveEmojiInteraction,
ActiveReaction,
ChatTranslatedMessages, ChatTranslatedMessages,
MessageListType, MessageListType,
} from '../../../global/types'; } from '../../../global/types';
@ -37,6 +35,7 @@ import {
getMessageContent, getMessageContent,
getMessageCustomShape, getMessageCustomShape,
getMessageHtmlId, getMessageHtmlId,
getMessageKey,
getMessageLocation, getMessageLocation,
getMessageSingleCustomEmoji, getMessageSingleCustomEmoji,
getMessageSingleRegularEmoji, getMessageSingleRegularEmoji,
@ -230,7 +229,7 @@ type StateProps = {
highlight?: string; highlight?: string;
animatedEmoji?: string; animatedEmoji?: string;
animatedCustomEmoji?: string; animatedCustomEmoji?: string;
genericEffects?: ApiStickerSet; hasActiveReactions?: boolean;
isInSelectMode?: boolean; isInSelectMode?: boolean;
isSelected?: boolean; isSelected?: boolean;
isGroupSelected?: boolean; isGroupSelected?: boolean;
@ -247,7 +246,6 @@ type StateProps = {
reactionMessage?: ApiMessage; reactionMessage?: ApiMessage;
availableReactions?: ApiAvailableReaction[]; availableReactions?: ApiAvailableReaction[];
defaultReaction?: ApiReaction; defaultReaction?: ApiReaction;
activeReactions?: ActiveReaction[];
activeEmojiInteractions?: ActiveEmojiInteraction[]; activeEmojiInteractions?: ActiveEmojiInteraction[];
hasUnreadReaction?: boolean; hasUnreadReaction?: boolean;
isTranscribing?: boolean; isTranscribing?: boolean;
@ -262,7 +260,6 @@ type StateProps = {
shouldDetectChatLanguage?: boolean; shouldDetectChatLanguage?: boolean;
requestedTranslationLanguage?: string; requestedTranslationLanguage?: string;
requestedChatTranslationLanguage?: string; requestedChatTranslationLanguage?: string;
withReactionEffects?: boolean;
withStickerEffects?: boolean; withStickerEffects?: boolean;
webPageStory?: ApiTypeStory; webPageStory?: ApiTypeStory;
isConnected: boolean; isConnected: boolean;
@ -341,7 +338,7 @@ const Message: FC<OwnProps & StateProps> = ({
highlight, highlight,
animatedEmoji, animatedEmoji,
animatedCustomEmoji, animatedCustomEmoji,
genericEffects, hasActiveReactions,
hasLinkedChat, hasLinkedChat,
isInSelectMode, isInSelectMode,
isSelected, isSelected,
@ -350,7 +347,6 @@ const Message: FC<OwnProps & StateProps> = ({
reactionMessage, reactionMessage,
availableReactions, availableReactions,
defaultReaction, defaultReaction,
activeReactions,
activeEmojiInteractions, activeEmojiInteractions,
messageListType, messageListType,
isPinnedList, isPinnedList,
@ -371,7 +367,6 @@ const Message: FC<OwnProps & StateProps> = ({
shouldDetectChatLanguage, shouldDetectChatLanguage,
requestedTranslationLanguage, requestedTranslationLanguage,
requestedChatTranslationLanguage, requestedChatTranslationLanguage,
withReactionEffects,
withStickerEffects, withStickerEffects,
webPageStory, webPageStory,
isConnected, isConnected,
@ -611,7 +606,7 @@ const Message: FC<OwnProps & StateProps> = ({
isSwiped && 'is-swiped', isSwiped && 'is-swiped',
transitionClassNames, transitionClassNames,
isJustAdded && 'is-just-added', isJustAdded && 'is-just-added',
(Boolean(activeReactions) || hasActiveStickerEffect) && 'has-active-reaction', (hasActiveReactions || hasActiveStickerEffect) && 'has-active-reaction',
isStoryMention && 'is-story-mention', isStoryMention && 'is-story-mention',
); );
@ -864,7 +859,7 @@ const Message: FC<OwnProps & StateProps> = ({
return ( return (
<div <div
className={buildClassName('quick-reaction', isQuickReactionVisible && !activeReactions && 'visible')} className={buildClassName('quick-reaction', isQuickReactionVisible && !hasActiveReactions && 'visible')}
onClick={handleSendQuickReaction} onClick={handleSendQuickReaction}
ref={quickReactionRef} ref={quickReactionRef}
> >
@ -877,7 +872,7 @@ const Message: FC<OwnProps & StateProps> = ({
</div> </div>
); );
}, [ }, [
activeReactions, availableReactions, defaultReaction, handleSendQuickReaction, isQuickReactionVisible, hasActiveReactions, availableReactions, defaultReaction, handleSendQuickReaction, isQuickReactionVisible,
observeIntersectionForPlaying, observeIntersectionForPlaying,
]); ]);
@ -908,14 +903,10 @@ const Message: FC<OwnProps & StateProps> = ({
return ( return (
<Reactions <Reactions
activeReactions={activeReactions}
message={reactionMessage!} message={reactionMessage!}
metaChildren={meta} metaChildren={meta}
availableReactions={availableReactions}
genericEffects={genericEffects}
observeIntersection={observeIntersectionForPlaying} observeIntersection={observeIntersectionForPlaying}
noRecentReactors={isChannel} noRecentReactors={isChannel}
withEffects={withReactionEffects}
/> />
); );
} }
@ -1381,12 +1372,8 @@ const Message: FC<OwnProps & StateProps> = ({
message={reactionMessage!} message={reactionMessage!}
isOutside isOutside
maxWidth={reactionsMaxWidth} maxWidth={reactionsMaxWidth}
activeReactions={activeReactions}
availableReactions={availableReactions}
genericEffects={genericEffects}
observeIntersection={observeIntersectionForPlaying} observeIntersection={observeIntersectionForPlaying}
noRecentReactors={isChannel} noRecentReactors={isChannel}
withEffects={withReactionEffects}
/> />
)} )}
</div> </div>
@ -1437,7 +1424,7 @@ function MessageAppendix({ isOwn } : { isOwn: boolean }) {
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global, ownProps): StateProps => { (global, ownProps): StateProps => {
const { const {
focusedMessage, forwardMessages, activeReactions, activeEmojiInteractions, focusedMessage, forwardMessages, activeEmojiInteractions, activeReactions,
} = selectTabState(global); } = selectTabState(global);
const { const {
message, album, withSenderName, withAvatar, threadId, messageListType, isLastInDocumentGroup, isFirstInGroup, message, album, withSenderName, withAvatar, threadId, messageListType, isLastInDocumentGroup, isFirstInGroup,
@ -1538,6 +1525,8 @@ export default memo(withGlobal<OwnProps>(
const isConnected = global.connectionState === 'connectionStateReady'; const isConnected = global.connectionState === 'connectionStateReady';
const hasActiveReactions = Boolean(reactionMessage && activeReactions[getMessageKey(reactionMessage)]?.length);
return { return {
theme: selectTheme(global), theme: selectTheme(global),
chatUsernames, chatUsernames,
@ -1584,7 +1573,7 @@ export default memo(withGlobal<OwnProps>(
availableReactions: global.availableReactions, availableReactions: global.availableReactions,
defaultReaction: isMessageLocal(message) || messageListType === 'scheduled' defaultReaction: isMessageLocal(message) || messageListType === 'scheduled'
? undefined : selectDefaultReaction(global, chatId), ? undefined : selectDefaultReaction(global, chatId),
activeReactions: reactionMessage && activeReactions[reactionMessage.id], hasActiveReactions,
activeEmojiInteractions, activeEmojiInteractions,
hasUnreadReaction, hasUnreadReaction,
isTranscribing: transcriptionId !== undefined && global.transcriptions[transcriptionId]?.isPending, isTranscribing: transcriptionId !== undefined && global.transcriptions[transcriptionId]?.isPending,
@ -1592,7 +1581,6 @@ export default memo(withGlobal<OwnProps>(
isPremium: selectIsCurrentUserPremium(global), isPremium: selectIsCurrentUserPremium(global),
senderAdminMember, senderAdminMember,
messageTopic, messageTopic,
genericEffects: global.genericEmojiEffects,
hasTopicChip, hasTopicChip,
chatTranslations, chatTranslations,
areTranslationsEnabled, areTranslationsEnabled,
@ -1600,7 +1588,6 @@ export default memo(withGlobal<OwnProps>(
requestedTranslationLanguage, requestedTranslationLanguage,
requestedChatTranslationLanguage, requestedChatTranslationLanguage,
hasLinkedChat: Boolean(chatFullInfo?.linkedChatId), hasLinkedChat: Boolean(chatFullInfo?.linkedChatId),
withReactionEffects: selectPerformanceSettingsValue(global, 'reactionEffects'),
withStickerEffects: selectPerformanceSettingsValue(global, 'stickerEffects'), withStickerEffects: selectPerformanceSettingsValue(global, 'stickerEffects'),
webPageStory, webPageStory,
isConnected, isConnected,

View File

@ -3,41 +3,35 @@ import React, { memo, useMemo } from '../../../lib/teact/teact';
import { getActions, getGlobal } from '../../../global'; import { getActions, getGlobal } from '../../../global';
import type { import type {
ApiAvailableReaction, ApiChat, ApiMessage, ApiReactionCount, ApiStickerSet, ApiUser, ApiChat, ApiMessage, ApiReactionCount, ApiUser,
} from '../../../api/types'; } from '../../../api/types';
import type { ActiveReaction } from '../../../global/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { isReactionChosen, isSameReaction } from '../../../global/helpers'; import { getMessageKey, isReactionChosen, isSameReaction } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { formatIntegerCompact } from '../../../util/textFormat'; import { formatIntegerCompact } from '../../../util/textFormat';
import { REM } from '../../common/helpers/mediaDimensions';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import AnimatedCounter from '../../common/AnimatedCounter'; import AnimatedCounter from '../../common/AnimatedCounter';
import AvatarList from '../../common/AvatarList'; import AvatarList from '../../common/AvatarList';
import ReactionAnimatedEmoji from '../../common/reactions/ReactionAnimatedEmoji';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import ReactionAnimatedEmoji from './ReactionAnimatedEmoji';
import './Reactions.scss'; import './Reactions.scss';
const REACTION_SIZE = 1.25 * REM;
const ReactionButton: FC<{ const ReactionButton: FC<{
reaction: ApiReactionCount; reaction: ApiReactionCount;
message: ApiMessage; message: ApiMessage;
activeReactions?: ActiveReaction[];
availableReactions?: ApiAvailableReaction[];
withRecentReactors?: boolean; withRecentReactors?: boolean;
withEffects?: boolean;
genericEffects?: ApiStickerSet;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
}> = ({ }> = ({
reaction, reaction,
message, message,
activeReactions,
availableReactions,
withRecentReactors, withRecentReactors,
withEffects,
genericEffects,
observeIntersection, observeIntersection,
}) => { }) => {
const { toggleReaction } = getActions(); const { toggleReaction } = getActions();
@ -68,21 +62,22 @@ const ReactionButton: FC<{
return ( return (
<Button <Button
className={buildClassName(isReactionChosen(reaction) && 'chosen')} className={buildClassName(isReactionChosen(reaction) && 'chosen', 'message-reaction')}
size="tiny" size="tiny"
onClick={handleClick} onClick={handleClick}
> >
<ReactionAnimatedEmoji <ReactionAnimatedEmoji
activeReactions={activeReactions} className="reaction-animated-emoji"
containerId={getMessageKey(message)}
reaction={reaction.reaction} reaction={reaction.reaction}
availableReactions={availableReactions} size={REACTION_SIZE}
genericEffects={genericEffects}
observeIntersection={observeIntersection} observeIntersection={observeIntersection}
withEffects={withEffects}
/> />
{recentReactors?.length ? ( {recentReactors?.length ? (
<AvatarList size="mini" peers={recentReactors} /> <AvatarList size="mini" peers={recentReactors} />
) : <AnimatedCounter text={formatIntegerCompact(reaction.count)} className="counter" />} ) : (
<AnimatedCounter text={formatIntegerCompact(reaction.count)} className="counter" />
)}
</Button> </Button>
); );
}; };

View File

@ -8,6 +8,7 @@
max-width: calc(var(--max-width) + 2.25rem); max-width: calc(var(--max-width) + 2.25rem);
.Button { .Button {
--custom-emoji-size: 1.25rem;
--reaction-background: var(--color-reaction); --reaction-background: var(--color-reaction);
--reaction-background-hover: var(--hover-color-reaction); --reaction-background-hover: var(--hover-color-reaction);
--reaction-text-color: var(--text-color-reaction); --reaction-text-color: var(--text-color-reaction);
@ -36,10 +37,6 @@
transition: background-color 150ms, color 150ms, backdrop-filter 150ms, filter 150ms; transition: background-color 150ms, color 150ms, backdrop-filter 150ms, filter 150ms;
.ReactionStaticEmoji {
width: 1.25rem;
}
.avatars { .avatars {
display: flex; display: flex;
@ -56,6 +53,14 @@
} }
} }
&.message-reaction {
gap: 0.125rem;
}
.reaction-animated-emoji {
margin: 0.25rem;
}
&.chosen { &.chosen {
--reaction-background: var(--color-reaction-chosen); --reaction-background: var(--color-reaction-chosen);
--reaction-background-hover: var(--hover-color-reaction-chosen); --reaction-background-hover: var(--hover-color-reaction-chosen);

View File

@ -1,8 +1,7 @@
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import React, { memo, useMemo } from '../../../lib/teact/teact'; import React, { memo, useMemo } from '../../../lib/teact/teact';
import type { ApiAvailableReaction, ApiMessage, ApiStickerSet } from '../../../api/types'; import type { ApiMessage } from '../../../api/types';
import type { ActiveReaction } from '../../../global/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { getReactionUniqueKey } from '../../../global/helpers'; import { getReactionUniqueKey } from '../../../global/helpers';
@ -18,13 +17,9 @@ type OwnProps = {
message: ApiMessage; message: ApiMessage;
isOutside?: boolean; isOutside?: boolean;
maxWidth?: number; maxWidth?: number;
activeReactions?: ActiveReaction[];
availableReactions?: ApiAvailableReaction[];
metaChildren?: React.ReactNode; metaChildren?: React.ReactNode;
genericEffects?: ApiStickerSet;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
noRecentReactors?: boolean; noRecentReactors?: boolean;
withEffects?: boolean;
}; };
const MAX_RECENT_AVATARS = 3; const MAX_RECENT_AVATARS = 3;
@ -33,13 +28,9 @@ const Reactions: FC<OwnProps> = ({
message, message,
isOutside, isOutside,
maxWidth, maxWidth,
activeReactions,
availableReactions,
metaChildren, metaChildren,
genericEffects,
observeIntersection, observeIntersection,
noRecentReactors, noRecentReactors,
withEffects,
}) => { }) => {
const lang = useLang(); const lang = useLang();
@ -58,12 +49,8 @@ const Reactions: FC<OwnProps> = ({
key={getReactionUniqueKey(reaction.reaction)} key={getReactionUniqueKey(reaction.reaction)}
reaction={reaction} reaction={reaction}
message={message} message={message}
activeReactions={activeReactions}
availableReactions={availableReactions}
withRecentReactors={totalCount <= MAX_RECENT_AVATARS && !noRecentReactors} withRecentReactors={totalCount <= MAX_RECENT_AVATARS && !noRecentReactors}
genericEffects={genericEffects}
observeIntersection={observeIntersection} observeIntersection={observeIntersection}
withEffects={withEffects}
/> />
))} ))}
{metaChildren} {metaChildren}

View File

@ -1,11 +1,12 @@
import React, { import React, {
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, memo, useEffect, useLayoutEffect, useMemo, useRef, useState,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getGlobal, withGlobal } from '../../global'; import { getActions, getGlobal, withGlobal } from '../../global';
import type { ApiUserStories } from '../../api/types'; import type { ApiUserStories } from '../../api/types';
import { ANIMATION_END_DELAY } from '../../config'; import { ANIMATION_END_DELAY } from '../../config';
import { getStoryKey } from '../../global/helpers';
import { selectIsStoryViewerOpen, selectTabState, selectUser } from '../../global/selectors'; import { selectIsStoryViewerOpen, selectTabState, selectUser } from '../../global/selectors';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import buildStyle from '../../util/buildStyle'; import buildStyle from '../../util/buildStyle';
@ -66,6 +67,7 @@ function StorySlides({
onClose, onClose,
onReport, onReport,
}: OwnProps & StateProps) { }: OwnProps & StateProps) {
const { stopActiveReaction } = getActions();
const [renderingUserId, setRenderingUserId] = useState(currentUserId); const [renderingUserId, setRenderingUserId] = useState(currentUserId);
const [renderingStoryId, setRenderingStoryId] = useState(currentStoryId); const [renderingStoryId, setRenderingStoryId] = useState(currentStoryId);
const prevUserId = usePrevious(currentUserId); const prevUserId = usePrevious(currentUserId);
@ -168,6 +170,15 @@ function StorySlides({
}; };
}, [prevUserId, currentUserId, setIsAnimating]); }, [prevUserId, currentUserId, setIsAnimating]);
useEffect(() => {
return () => {
if (!currentStoryId || !currentUserId) return;
stopActiveReaction({
containerId: getStoryKey(currentUserId, currentStoryId),
});
};
}, [currentStoryId, currentUserId]);
const slideAmount = currentUserPosition - renderingUserPosition; const slideAmount = currentUserPosition - renderingUserPosition;
const isBackward = renderingUserPosition > currentUserPosition; const isBackward = renderingUserPosition > currentUserPosition;

View File

@ -1,5 +1,7 @@
import type { IDimensions } from '../../../global/types'; import type { IDimensions } from '../../../global/types';
import { roundToNearestEven } from '../../../util/math';
const BASE_SCREEN_WIDTH = 1200; const BASE_SCREEN_WIDTH = 1200;
const BASE_SCREEN_HEIGHT = 800; const BASE_SCREEN_HEIGHT = 800;
const BASE_ACTIVE_SLIDE_WIDTH = 405; const BASE_ACTIVE_SLIDE_WIDTH = 405;
@ -56,8 +58,3 @@ function calculateScale(baseWidth: number, baseHeight: number, newWidth: number,
return Math.min(widthScale, heightScale); return Math.min(widthScale, heightScale);
} }
// Fractional values cause blurry text. Round to even to keep whole numbers while centering
function roundToNearestEven(value: number) {
return Math.round(value / 2) * 2;
}

View File

@ -7,7 +7,10 @@ import * as mediaLoader from '../../../util/mediaLoader';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { import {
getDocumentMediaHash, getDocumentMediaHash,
getUserReactions, isMessageLocal, isSameReaction, getMessageKey,
getUserReactions,
isMessageLocal,
isSameReaction,
} from '../../helpers'; } from '../../helpers';
import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { import {
@ -150,22 +153,14 @@ addActionHandler('toggleReaction', async (global, actions, payload): Promise<voi
const limit = selectMaxUserReactions(global); const limit = selectMaxUserReactions(global);
const reactions = newUserReactions.slice(-limit); const reactions = newUserReactions.slice(-limit);
const tabState = selectTabState(global, tabId); const messageKey = getMessageKey(message);
if (selectPerformanceSettingsValue(global, 'reactionEffects')) { if (selectPerformanceSettingsValue(global, 'reactionEffects')) {
const newActiveReactions = hasReaction ? omit(tabState.activeReactions, [messageId]) : { if (hasReaction) {
...tabState.activeReactions, actions.stopActiveReaction({ containerId: messageKey, reaction, tabId });
[messageId]: [ } else {
...(tabState.activeReactions[messageId] || []), actions.startActiveReaction({ containerId: messageKey, reaction, tabId });
{ }
messageId,
reaction,
},
],
};
global = updateTabState(global, {
activeReactions: newActiveReactions,
}, tabId);
} }
global = addMessageReaction(global, message, reactions); global = addMessageReaction(global, message, reactions);
@ -185,21 +180,41 @@ addActionHandler('toggleReaction', async (global, actions, payload): Promise<voi
} }
}); });
addActionHandler('stopActiveReaction', (global, actions, payload): ActionReturnType => { addActionHandler('startActiveReaction', (global, actions, payload): ActionReturnType => {
const { messageId, reaction, tabId = getCurrentTabId() } = payload; const { containerId, reaction, tabId = getCurrentTabId() } = payload;
const tabState = selectTabState(global, tabId); const tabState = selectTabState(global, tabId);
if (!tabState.activeReactions[messageId]?.some((active) => isSameReaction(active.reaction, reaction))) {
return global; if (!selectPerformanceSettingsValue(global, 'reactionEffects')) return undefined;
const currentActiveReactions = tabState.activeReactions[containerId] || [];
if (currentActiveReactions.some((active) => isSameReaction(active, reaction))) {
return undefined;
} }
const newMessageActiveReactions = tabState.activeReactions[messageId] const newActiveReactions = currentActiveReactions.concat(reaction);
.filter((active) => !isSameReaction(active.reaction, reaction));
return updateTabState(global, {
activeReactions: {
...tabState.activeReactions,
[containerId]: newActiveReactions,
},
}, tabId);
});
addActionHandler('stopActiveReaction', (global, actions, payload): ActionReturnType => {
const { containerId, reaction, tabId = getCurrentTabId() } = payload;
const tabState = selectTabState(global, tabId);
const currentActiveReactions = tabState.activeReactions[containerId] || [];
// Remove all reactions if reaction is not specified
const newMessageActiveReactions = reaction
? currentActiveReactions.filter((active) => !isSameReaction(active, reaction)) : [];
const newActiveReactions = newMessageActiveReactions.length ? { const newActiveReactions = newMessageActiveReactions.length ? {
...tabState.activeReactions, ...tabState.activeReactions,
[messageId]: newMessageActiveReactions, [containerId]: newMessageActiveReactions,
} : omit(tabState.activeReactions, [messageId]); } : omit(tabState.activeReactions, [containerId]);
return updateTabState(global, { return updateTabState(global, {
activeReactions: newActiveReactions, activeReactions: newActiveReactions,

View File

@ -6,6 +6,7 @@ import { buildCollectionByKey } from '../../../util/iteratees';
import { translate } from '../../../util/langProvider'; import { translate } from '../../../util/langProvider';
import { getServerTime } from '../../../util/serverTime'; import { getServerTime } from '../../../util/serverTime';
import { callApi } from '../../../api/gramjs'; import { callApi } from '../../../api/gramjs';
import { getStoryKey } from '../../helpers';
import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { import {
addStories, addStories,
@ -428,7 +429,7 @@ addActionHandler('loadStoriesMaxIds', async (global, actions, payload): Promise<
addActionHandler('sendStoryReaction', async (global, actions, payload): Promise<void> => { addActionHandler('sendStoryReaction', async (global, actions, payload): Promise<void> => {
const { const {
userId, storyId, reaction, shouldAddToRecent, userId, storyId, reaction, shouldAddToRecent, tabId = getCurrentTabId(),
} = payload; } = payload;
const user = selectUser(global, userId); const user = selectUser(global, userId);
if (!user) return; if (!user) return;
@ -442,6 +443,13 @@ addActionHandler('sendStoryReaction', async (global, actions, payload): Promise<
}); });
setGlobal(global); setGlobal(global);
const containerId = getStoryKey(userId, storyId);
if (reaction) {
actions.startActiveReaction({ containerId, reaction, tabId });
} else {
actions.stopActiveReaction({ containerId, tabId });
}
const result = await callApi('sendStoryReaction', { const result = await callApi('sendStoryReaction', {
user, storyId, reaction, shouldAddToRecent, user, storyId, reaction, shouldAddToRecent,
}); });

View File

@ -54,3 +54,7 @@ function getSizeParameter(size: StorySize) {
return ''; return '';
} }
} }
export function getStoryKey(chatId: string, storyId: number) {
return `story${chatId}-${storyId}`;
}

View File

@ -132,11 +132,6 @@ export type IDimensions = {
export type ApiPaymentStatus = 'paid' | 'failed' | 'pending' | 'cancelled'; export type ApiPaymentStatus = 'paid' | 'failed' | 'pending' | 'cancelled';
export interface ActiveReaction {
messageId?: number;
reaction?: ApiReaction;
}
export interface TabThread { export interface TabThread {
scrollOffset?: number; scrollOffset?: number;
replyStack?: number[]; replyStack?: number[];
@ -321,7 +316,7 @@ export type TabState = {
}; };
activeEmojiInteractions?: ActiveEmojiInteraction[]; activeEmojiInteractions?: ActiveEmojiInteraction[];
activeReactions: Record<number, ActiveReaction[]>; activeReactions: Record<string, ApiReaction[]>;
localTextSearch: { localTextSearch: {
byChatThreadKey: Record<string, { byChatThreadKey: Record<string, {
@ -1927,10 +1922,14 @@ export interface ActionPayloads {
enabledReactions?: ApiChatReactions; enabledReactions?: ApiChatReactions;
} & WithTabId; } & WithTabId;
stopActiveReaction: { startActiveReaction: {
messageId: number; containerId: string;
reaction: ApiReaction; reaction: ApiReaction;
} & WithTabId; } & WithTabId;
stopActiveReaction: {
containerId: string;
reaction?: ApiReaction;
} & WithTabId;
openMessageReactionPicker: { openMessageReactionPicker: {
chatId: string; chatId: string;
@ -2051,7 +2050,7 @@ export interface ActionPayloads {
storyId: number; storyId: number;
reaction?: ApiReaction; reaction?: ApiReaction;
shouldAddToRecent?: boolean; shouldAddToRecent?: boolean;
}; } & WithTabId;
toggleStealthModal: { toggleStealthModal: {
isOpen: boolean; isOpen: boolean;
} & WithTabId; } & WithTabId;

View File

@ -4,3 +4,8 @@ export const round = (num: number, decimals: number = 0) => Math.round(num * 10
export const lerp = (start: number, end: number, interpolationRatio: number) => { export const lerp = (start: number, end: number, interpolationRatio: number) => {
return (1 - interpolationRatio) * start + interpolationRatio * end; return (1 - interpolationRatio) * start + interpolationRatio * end;
}; };
// Fractional values cause blurry text & canvas. Round to even to keep whole numbers while centering
export function roundToNearestEven(value: number) {
return Math.round(value / 2) * 2;
}