Introduce Animated Counters (#2155)

This commit is contained in:
Alexander Zinchuk 2022-12-06 13:29:37 +01:00
parent 4ba32608d0
commit 378f35da9f
8 changed files with 146 additions and 13 deletions

View File

@ -342,7 +342,7 @@ export async function fetchLangPack({ sourceLangPacks, langCode }: {
}));
const collections = results
.filter<GramJs.LangPackDifference>(Boolean as any)
.filter(Boolean)
.map((result) => {
return buildCollectionByKey(result.strings.map<ApiLangString>(omitVirtualClassFields), 'key');
});

View File

@ -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;
}

View File

@ -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<OwnProps> = ({
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(
<div className={styles.characterContainer}>
<div className={styles.character}>{text[i]}</div>
<div className={styles.characterOld}>{prevText[i]}</div>
<div className={styles.characterNew}>{text[i]}</div>
</div>,
);
} else {
elements.push(<span>{text[i]}</span>);
}
}
isAnimatingRef.current = true;
return elements;
}, [prevText, shouldAnimate, text]);
useTimeout(() => {
isAnimatingRef.current = false;
forceUpdate();
}, shouldAnimate && isAnimatingRef.current ? ANIMATION_TIME : undefined);
return (
<span className={styles.root}>
{textElement}
</span>
);
};
export default AnimatedCounter;

View File

@ -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<OwnProps> = ({ chat, isPinned, isMuted }) => {
const unreadCountElement = (chat.hasUnreadMark || chat.unreadCount) ? (
<div className={className}>
{!chat.hasUnreadMark && formatIntegerCompact(chat.unreadCount!)}
{!chat.hasUnreadMark && <AnimatedCounter text={formatIntegerCompact(chat.unreadCount!)} />}
</div>
) : undefined;

View File

@ -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<OwnProps> = ({
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' ? <AnimatedCounter text={formatIntegerCompact(messagesCount)} /> : s);
})
: undefined;
return (
<div
data-cnt={formatIntegerCompact(messagesCount)}
@ -82,7 +90,7 @@ const CommentButton: FC<OwnProps> = ({
{(!recentRepliers || recentRepliers.length === 0) && <i className="icon-comments" />}
{renderRecentRepliers()}
<div className="label" dir="auto">
{messagesCount ? lang('Comments', messagesCount, 'i') : lang('LeaveAComment')}
{messagesCount ? commentsText : lang('LeaveAComment')}
</div>
<i className="icon-next" />
</div>

View File

@ -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<{
<div className="avatars">
{recentReactors.map((user) => <Avatar user={user} size="micro" />)}
</div>
) : formatIntegerCompact(reaction.count)}
) : <AnimatedCounter text={formatIntegerCompact(reaction.count)} />}
</Button>
);
};

View File

@ -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;
}

View File

@ -18,7 +18,7 @@ function formatFixedNumber(number: number) {
export function formatIntegerCompact(views: number) {
if (views < 1e3) {
return views;
return views.toString();
}
if (views < 1e6) {