From 378f35da9f497ed72dac4233180efb2a878b684c Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Tue, 6 Dec 2022 13:29:37 +0100 Subject: [PATCH] Introduce Animated Counters (#2155) --- src/api/gramjs/methods/settings.ts | 2 +- .../common/AnimatedCounter.module.scss | 56 +++++++++++++++++ src/components/common/AnimatedCounter.tsx | 63 +++++++++++++++++++ src/components/left/main/Badge.tsx | 3 +- .../middle/message/CommentButton.tsx | 10 ++- .../middle/message/ReactionButton.tsx | 3 +- src/util/langProvider.ts | 20 +++--- src/util/textFormat.ts | 2 +- 8 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 src/components/common/AnimatedCounter.module.scss create mode 100644 src/components/common/AnimatedCounter.tsx diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts index 8bf964efa..53981c192 100644 --- a/src/api/gramjs/methods/settings.ts +++ b/src/api/gramjs/methods/settings.ts @@ -342,7 +342,7 @@ export async function fetchLangPack({ sourceLangPacks, langCode }: { })); const collections = results - .filter(Boolean as any) + .filter(Boolean) .map((result) => { return buildCollectionByKey(result.strings.map(omitVirtualClassFields), 'key'); }); diff --git a/src/components/common/AnimatedCounter.module.scss b/src/components/common/AnimatedCounter.module.scss new file mode 100644 index 000000000..06e9a1419 --- /dev/null +++ b/src/components/common/AnimatedCounter.module.scss @@ -0,0 +1,56 @@ +$perspective: 10px; +$translate: 10px; +$rotate: 30deg; +$animation-time: 0.15s; + +.root { + display: inline-flex; + white-space: pre; +} + +.character-container { + position: relative; +} + +@keyframes character-disappear { + from { + transform: none; + opacity: 1; + } + + to { + transform: perspective($perspective) translateY($translate) rotateX(-$rotate); + opacity: 0; + } +} + +@keyframes character-appear { + from { + transform: perspective($perspective) translateY(-$translate) rotateX($rotate); + opacity: 0; + } + + to { + transform: none; + opacity: 1; + } +} + +.character { + white-space: pre; + visibility: hidden; +} + +.character-old { + position: absolute; + top: 0; + left: 0; + animation: $animation-time ease-out character-disappear forwards; +} + +.character-new { + position: absolute; + top: 0; + left: 0; + animation: $animation-time ease-out character-appear forwards; +} diff --git a/src/components/common/AnimatedCounter.tsx b/src/components/common/AnimatedCounter.tsx new file mode 100644 index 000000000..e4bfedc7d --- /dev/null +++ b/src/components/common/AnimatedCounter.tsx @@ -0,0 +1,63 @@ +import type { FC } from '../../lib/teact/teact'; +import React, { useMemo, useRef } from '../../lib/teact/teact'; +import { getGlobal } from '../../global'; + +import { ANIMATION_LEVEL_MAX } from '../../config'; +import usePrevious from '../../hooks/usePrevious'; +import useForceUpdate from '../../hooks/useForceUpdate'; +import useTimeout from '../../hooks/useTimeout'; + +import styles from './AnimatedCounter.module.scss'; + +type OwnProps = { + text: string; +}; + +const ANIMATION_TIME = 150; + +const AnimatedCounter: FC = ({ + text, +}) => { + const prevText = usePrevious(text); + const forceUpdate = useForceUpdate(); + + const isAnimatingRef = useRef(false); + + const shouldAnimate = getGlobal().settings.byKey.animationLevel === ANIMATION_LEVEL_MAX; + + const textElement = useMemo(() => { + if (!shouldAnimate) return text; + + const elements = []; + for (let i = 0; i < text.length; i++) { + if (prevText && text[i] !== prevText[i]) { + elements.push( +
+
{text[i]}
+
{prevText[i]}
+
{text[i]}
+
, + ); + } else { + elements.push({text[i]}); + } + } + + isAnimatingRef.current = true; + + return elements; + }, [prevText, shouldAnimate, text]); + + useTimeout(() => { + isAnimatingRef.current = false; + forceUpdate(); + }, shouldAnimate && isAnimatingRef.current ? ANIMATION_TIME : undefined); + + return ( + + {textElement} + + ); +}; + +export default AnimatedCounter; diff --git a/src/components/left/main/Badge.tsx b/src/components/left/main/Badge.tsx index 68bad93aa..575c648b1 100644 --- a/src/components/left/main/Badge.tsx +++ b/src/components/left/main/Badge.tsx @@ -7,6 +7,7 @@ import { formatIntegerCompact } from '../../../util/textFormat'; import buildClassName from '../../../util/buildClassName'; import ShowTransition from '../../ui/ShowTransition'; +import AnimatedCounter from '../../common/AnimatedCounter'; import './Badge.scss'; @@ -43,7 +44,7 @@ const Badge: FC = ({ chat, isPinned, isMuted }) => { const unreadCountElement = (chat.hasUnreadMark || chat.unreadCount) ? (
- {!chat.hasUnreadMark && formatIntegerCompact(chat.unreadCount!)} + {!chat.hasUnreadMark && }
) : undefined; diff --git a/src/components/middle/message/CommentButton.tsx b/src/components/middle/message/CommentButton.tsx index 9ddc152ce..5b395557c 100644 --- a/src/components/middle/message/CommentButton.tsx +++ b/src/components/middle/message/CommentButton.tsx @@ -12,6 +12,7 @@ import buildClassName from '../../../util/buildClassName'; import useLang from '../../../hooks/useLang'; import Avatar from '../../common/Avatar'; +import AnimatedCounter from '../../common/AnimatedCounter'; import './CommentButton.scss'; @@ -71,6 +72,13 @@ const CommentButton: FC = ({ const hasUnread = Boolean(lastReadInboxMessageId && lastMessageId && lastReadInboxMessageId < lastMessageId); + const commentsText = messagesCount ? (lang('Comments', '%COMMENTS_COUNT%', undefined, messagesCount) as string) + .split('%') + .map((s) => { + return (s === 'COMMENTS_COUNT' ? : s); + }) + : undefined; + return (
= ({ {(!recentRepliers || recentRepliers.length === 0) && } {renderRecentRepliers()}
- {messagesCount ? lang('Comments', messagesCount, 'i') : lang('LeaveAComment')} + {messagesCount ? commentsText : lang('LeaveAComment')}
diff --git a/src/components/middle/message/ReactionButton.tsx b/src/components/middle/message/ReactionButton.tsx index 977fc838a..21d2f5392 100644 --- a/src/components/middle/message/ReactionButton.tsx +++ b/src/components/middle/message/ReactionButton.tsx @@ -13,6 +13,7 @@ import { formatIntegerCompact } from '../../../util/textFormat'; import Button from '../../ui/Button'; import Avatar from '../../common/Avatar'; import ReactionAnimatedEmoji from './ReactionAnimatedEmoji'; +import AnimatedCounter from '../../common/AnimatedCounter'; import './Reactions.scss'; @@ -70,7 +71,7 @@ const ReactionButton: FC<{
{recentReactors.map((user) => )}
- ) : formatIntegerCompact(reaction.count)} + ) : } ); }; diff --git a/src/util/langProvider.ts b/src/util/langProvider.ts index 4bfceb38b..df581e58b 100644 --- a/src/util/langProvider.ts +++ b/src/util/langProvider.ts @@ -12,7 +12,7 @@ import { createCallbackManager } from './callbacks'; import { formatInteger } from './textFormat'; interface LangFn { - (key: string, value?: any, format?: 'i'): any; + (key: string, value?: any, format?: 'i', pluralValue?: number): any; isRtl?: boolean; code?: LangCode; @@ -94,10 +94,10 @@ export { addCallback, removeCallback }; let currentLangCode: string | undefined; let currentTimeFormat: TimeFormat | undefined; -export const getTranslation: LangFn = (key: string, value?: any, format?: 'i') => { +export const getTranslation: LangFn = (key: string, value?: any, format?: 'i', pluralValue?: number) => { if (value !== undefined) { const cacheValue = Array.isArray(value) ? JSON.stringify(value) : value; - const cached = cache.get(`${key}_${cacheValue}_${format}`); + const cached = cache.get(`${key}_${cacheValue}_${format}${pluralValue ? `_${pluralValue}` : ''}`); if (cached) { return cached; } @@ -116,7 +116,7 @@ export const getTranslation: LangFn = (key: string, value?: any, format?: 'i') = return key; } - return processTranslation(langString, key, value, format); + return processTranslation(langString, key, value, format, pluralValue); }; export async function getTranslationForLangString(langCode: string, key: string) { @@ -163,7 +163,7 @@ export async function setLanguage(langCode: LangCode, callback?: NoneToVoidFunct const { languages, timeFormat } = getGlobal().settings.byKey; const langInfo = languages?.find((l) => l.langCode === langCode); getTranslation.isRtl = Boolean(langInfo?.rtl); - getTranslation.code = langCode; + getTranslation.code = langCode.replace('-raw', '') as LangCode; getTranslation.langName = langInfo?.nativeName; getTranslation.timeFormat = timeFormat; @@ -241,8 +241,12 @@ function processTemplate(template: string, value: any) { }, initialValue || ''); } -function processTranslation(langString: ApiLangString | undefined, key: string, value?: any, format?: 'i') { - const preferredPluralOption = typeof value === 'number' ? getPluralOption(value) : 'value'; +function processTranslation( + langString: ApiLangString | undefined, key: string, value?: any, format?: 'i', pluralValue?: number, +) { + const preferredPluralOption = typeof value === 'number' || pluralValue !== undefined + ? getPluralOption(pluralValue ?? value) + : 'value'; const template = langString ? ( langString[preferredPluralOption] || langString.otherValue || langString.value ) : undefined; @@ -256,7 +260,7 @@ function processTranslation(langString: ApiLangString | undefined, key: string, const formattedValue = format === 'i' ? formatInteger(value) : value; const result = processTemplate(template, formattedValue); const cacheValue = Array.isArray(value) ? JSON.stringify(value) : value; - cache.set(`${key}_${cacheValue}_${format}`, result); + cache.set(`${key}_${cacheValue}_${format}${pluralValue ? `_${pluralValue}` : ''}`, result); return result; } diff --git a/src/util/textFormat.ts b/src/util/textFormat.ts index f0cd0ab3a..6021cadb5 100644 --- a/src/util/textFormat.ts +++ b/src/util/textFormat.ts @@ -18,7 +18,7 @@ function formatFixedNumber(number: number) { export function formatIntegerCompact(views: number) { if (views < 1e3) { - return views; + return views.toString(); } if (views < 1e6) {