Introduce Animated Counters (#2155)
This commit is contained in:
parent
4ba32608d0
commit
378f35da9f
@ -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');
|
||||
});
|
||||
|
||||
56
src/components/common/AnimatedCounter.module.scss
Normal file
56
src/components/common/AnimatedCounter.module.scss
Normal 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;
|
||||
}
|
||||
63
src/components/common/AnimatedCounter.tsx
Normal file
63
src/components/common/AnimatedCounter.tsx
Normal 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;
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ function formatFixedNumber(number: number) {
|
||||
|
||||
export function formatIntegerCompact(views: number) {
|
||||
if (views < 1e3) {
|
||||
return views;
|
||||
return views.toString();
|
||||
}
|
||||
|
||||
if (views < 1e6) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user