mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
Animate reaction counts (#904)
This commit is contained in:
@@ -6,6 +6,7 @@ import type { TimelineReaction } from "@/features/messages/types";
|
|||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
import { emojiDisplayName } from "@/shared/lib/emojiName";
|
import { emojiDisplayName } from "@/shared/lib/emojiName";
|
||||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||||
|
import { AnimatedCount } from "@/shared/ui/AnimatedCount";
|
||||||
import {
|
import {
|
||||||
isPositiveEmojiParticle,
|
isPositiveEmojiParticle,
|
||||||
useEmojiBurst,
|
useEmojiBurst,
|
||||||
@@ -433,7 +434,10 @@ function ReactionPill({
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<EmojiGlyph reaction={reaction} className={REACTION_GLYPH_CLASSES} />
|
<EmojiGlyph reaction={reaction} className={REACTION_GLYPH_CLASSES} />
|
||||||
<span className="text-muted-foreground">{reaction.count}</span>
|
<AnimatedCount
|
||||||
|
className="text-muted-foreground"
|
||||||
|
value={reaction.count}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -463,7 +467,10 @@ function ReactionPill({
|
|||||||
reaction={reaction}
|
reaction={reaction}
|
||||||
className={REACTION_GLYPH_CLASSES}
|
className={REACTION_GLYPH_CLASSES}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground">{reaction.count}</span>
|
<AnimatedCount
|
||||||
|
className="text-muted-foreground"
|
||||||
|
value={reaction.count}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useNoteByIdQuery } from "@/features/pulse/hooks";
|
|||||||
import { getReplyParent, noteSnippet } from "@/features/pulse/lib/replies";
|
import { getReplyParent, noteSnippet } from "@/features/pulse/lib/replies";
|
||||||
import type { UserNote } from "@/shared/api/socialTypes";
|
import type { UserNote } from "@/shared/api/socialTypes";
|
||||||
import type { ChannelMember, UserProfileSummary } from "@/shared/api/types";
|
import type { ChannelMember, UserProfileSummary } from "@/shared/api/types";
|
||||||
|
import { AnimatedCount } from "@/shared/ui/AnimatedCount";
|
||||||
import { Markdown } from "@/shared/ui/markdown";
|
import { Markdown } from "@/shared/ui/markdown";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||||
@@ -149,9 +150,7 @@ export function NoteCard({
|
|||||||
const activeActionClass = "text-primary";
|
const activeActionClass = "text-primary";
|
||||||
const countPlaceholder = <span aria-hidden className="w-2.5" />;
|
const countPlaceholder = <span aria-hidden className="w-2.5" />;
|
||||||
const reactionCountLabel =
|
const reactionCountLabel =
|
||||||
reactionCount > 0 ? (
|
reactionCount > 0 ? <AnimatedCount value={reactionCount} /> : null;
|
||||||
<span className="tabular-nums">{reactionCount}</span>
|
|
||||||
) : null;
|
|
||||||
const currentUserAvatarUrl = currentUserProfile?.avatarUrl ?? null;
|
const currentUserAvatarUrl = currentUserProfile?.avatarUrl ?? null;
|
||||||
const replyParentId = getReplyParent(note);
|
const replyParentId = getReplyParent(note);
|
||||||
|
|
||||||
@@ -224,7 +223,7 @@ export function NoteCard({
|
|||||||
<button
|
<button
|
||||||
aria-label={isUpvoted ? "Unlike" : "Like"}
|
aria-label={isUpvoted ? "Unlike" : "Like"}
|
||||||
aria-pressed={isUpvoted}
|
aria-pressed={isUpvoted}
|
||||||
className={`${actionButtonClass} ${isUpvoted ? activeActionClass : ""} disabled:cursor-not-allowed disabled:opacity-45`}
|
className={`${actionButtonClass} ${isUpvoted ? activeActionClass : ""} disabled:opacity-45`}
|
||||||
disabled={isUpvotePending}
|
disabled={isUpvotePending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isUpvotePending) {
|
if (!isUpvotePending) {
|
||||||
|
|||||||
@@ -97,6 +97,129 @@
|
|||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sprout-animated-count {
|
||||||
|
display: inline-block;
|
||||||
|
font-kerning: none;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprout-animated-count__motion {
|
||||||
|
display: inline-flex;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprout-animated-count__static {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprout-animated-count__slot {
|
||||||
|
display: inline-block;
|
||||||
|
height: 1em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprout-animated-count__slot--digit {
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
width: 1ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprout-animated-count__reel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 2em;
|
||||||
|
line-height: 1;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprout-animated-count__reel > span {
|
||||||
|
display: block;
|
||||||
|
height: 1em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprout-animated-count__slot[data-direction="up"] .sprout-animated-count__reel {
|
||||||
|
animation: sprout-animated-count-roll-up 260ms cubic-bezier(0.2, 0.8, 0.2, 1)
|
||||||
|
both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprout-animated-count__slot[data-direction="down"]
|
||||||
|
.sprout-animated-count__reel {
|
||||||
|
animation: sprout-animated-count-roll-down 260ms
|
||||||
|
cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprout-animated-count__symbol {
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprout-animated-count__symbol[data-direction="up"] {
|
||||||
|
animation: sprout-animated-count-symbol-up 220ms
|
||||||
|
cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprout-animated-count__symbol[data-direction="down"] {
|
||||||
|
animation: sprout-animated-count-symbol-down 220ms
|
||||||
|
cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sprout-animated-count-roll-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateY(-1em);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sprout-animated-count-roll-down {
|
||||||
|
from {
|
||||||
|
transform: translateY(-1em);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sprout-animated-count-symbol-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0.25em);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sprout-animated-count-symbol-down {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-0.25em);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sprout-animated-count__motion {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprout-animated-count__static {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Tiptap rich-text composer ──────────────────────────────────────── */
|
/* ── Tiptap rich-text composer ──────────────────────────────────────── */
|
||||||
.rich-text-composer .tiptap {
|
.rich-text-composer .tiptap {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
183
desktop/src/shared/ui/AnimatedCount.tsx
Normal file
183
desktop/src/shared/ui/AnimatedCount.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
import {
|
||||||
|
formatAnimatedCount,
|
||||||
|
getAnimatedCountSlots,
|
||||||
|
normalizeAnimatedCount,
|
||||||
|
} from "@/shared/ui/animatedCountParts";
|
||||||
|
|
||||||
|
type CountDirection = -1 | 0 | 1;
|
||||||
|
|
||||||
|
type AnimatedCountTransition = {
|
||||||
|
current: string;
|
||||||
|
direction: CountDirection;
|
||||||
|
previous: string;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnimatedCountProps = {
|
||||||
|
className?: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANIMATED_COUNT_SETTLE_DELAY_MS = 260;
|
||||||
|
|
||||||
|
function directionFor(previous: number, current: number): CountDirection {
|
||||||
|
if (current > previous) return 1;
|
||||||
|
if (current < previous) return -1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSlotText(value: string) {
|
||||||
|
return value || "\u00A0";
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnimatedCountSlot({
|
||||||
|
current,
|
||||||
|
direction,
|
||||||
|
isDigit,
|
||||||
|
previous,
|
||||||
|
version,
|
||||||
|
}: {
|
||||||
|
current: string;
|
||||||
|
direction: CountDirection;
|
||||||
|
isDigit: boolean;
|
||||||
|
previous: string;
|
||||||
|
version: number;
|
||||||
|
}) {
|
||||||
|
if (direction === 0 || previous === current) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"sprout-animated-count__slot",
|
||||||
|
isDigit && "sprout-animated-count__slot--digit",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderSlotText(current)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDigit) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sprout-animated-count__symbol"
|
||||||
|
data-direction={direction > 0 ? "up" : "down"}
|
||||||
|
key={`${version}-${previous}-${current}`}
|
||||||
|
>
|
||||||
|
{renderSlotText(current)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sprout-animated-count__slot sprout-animated-count__slot--digit"
|
||||||
|
data-direction={direction > 0 ? "up" : "down"}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="sprout-animated-count__reel"
|
||||||
|
key={`${version}-${previous}-${current}`}
|
||||||
|
>
|
||||||
|
{direction > 0 ? (
|
||||||
|
<>
|
||||||
|
<span>{renderSlotText(previous)}</span>
|
||||||
|
<span>{renderSlotText(current)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{renderSlotText(current)}</span>
|
||||||
|
<span>{renderSlotText(previous)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnimatedCount({ className, value }: AnimatedCountProps) {
|
||||||
|
const normalizedValue = normalizeAnimatedCount(value);
|
||||||
|
const formattedValue = formatAnimatedCount(normalizedValue);
|
||||||
|
const previousValueRef = React.useRef(normalizedValue);
|
||||||
|
const [transition, setTransition] = React.useState<AnimatedCountTransition>(
|
||||||
|
() => ({
|
||||||
|
current: formattedValue,
|
||||||
|
direction: 0,
|
||||||
|
previous: formattedValue,
|
||||||
|
version: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
setTransition((currentTransition) => {
|
||||||
|
if (currentTransition.current === formattedValue) {
|
||||||
|
previousValueRef.current = normalizedValue;
|
||||||
|
return currentTransition;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = directionFor(previousValueRef.current, normalizedValue);
|
||||||
|
previousValueRef.current = normalizedValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: formattedValue,
|
||||||
|
direction,
|
||||||
|
previous: currentTransition.current,
|
||||||
|
version: currentTransition.version + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [formattedValue, normalizedValue]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (
|
||||||
|
transition.direction === 0 ||
|
||||||
|
transition.previous === transition.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setTransition((currentTransition) => {
|
||||||
|
if (currentTransition.version !== transition.version) {
|
||||||
|
return currentTransition;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentTransition,
|
||||||
|
direction: 0,
|
||||||
|
previous: currentTransition.current,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, ANIMATED_COUNT_SETTLE_DELAY_MS);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [
|
||||||
|
transition.current,
|
||||||
|
transition.direction,
|
||||||
|
transition.previous,
|
||||||
|
transition.version,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const slots = getAnimatedCountSlots(transition.previous, transition.current);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn("sprout-animated-count", className)}>
|
||||||
|
<span className="sr-only">{transition.current}</span>
|
||||||
|
<span aria-hidden className="sprout-animated-count__motion">
|
||||||
|
{slots.map((slot) => (
|
||||||
|
<AnimatedCountSlot
|
||||||
|
current={slot.current}
|
||||||
|
direction={transition.direction}
|
||||||
|
isDigit={slot.isDigit}
|
||||||
|
key={`${transition.version}-${slot.place}`}
|
||||||
|
previous={slot.previous}
|
||||||
|
version={transition.version}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden className="sprout-animated-count__static">
|
||||||
|
{transition.current}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
desktop/src/shared/ui/animatedCountParts.test.mjs
Normal file
34
desktop/src/shared/ui/animatedCountParts.test.mjs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatAnimatedCount,
|
||||||
|
getAnimatedCountSlots,
|
||||||
|
normalizeAnimatedCount,
|
||||||
|
} from "./animatedCountParts.ts";
|
||||||
|
|
||||||
|
test("normalizeAnimatedCount clamps to displayable whole counts", () => {
|
||||||
|
assert.equal(normalizeAnimatedCount(4.9), 4);
|
||||||
|
assert.equal(normalizeAnimatedCount(-1), 0);
|
||||||
|
assert.equal(normalizeAnimatedCount(Number.NaN), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatAnimatedCount uses stable grouped ASCII digits", () => {
|
||||||
|
assert.equal(formatAnimatedCount(1234), "1,234");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getAnimatedCountSlots right-aligns digits for rollover", () => {
|
||||||
|
assert.deepEqual(getAnimatedCountSlots("9", "10"), [
|
||||||
|
{ current: "1", isDigit: true, place: 1, previous: "" },
|
||||||
|
{ current: "0", isDigit: true, place: 0, previous: "9" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getAnimatedCountSlots shrinks to current width after settling", () => {
|
||||||
|
assert.equal(getAnimatedCountSlots("1,000", "999").length, 5);
|
||||||
|
assert.deepEqual(getAnimatedCountSlots("999", "999"), [
|
||||||
|
{ current: "9", isDigit: true, place: 2, previous: "9" },
|
||||||
|
{ current: "9", isDigit: true, place: 1, previous: "9" },
|
||||||
|
{ current: "9", isDigit: true, place: 0, previous: "9" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
47
desktop/src/shared/ui/animatedCountParts.ts
Normal file
47
desktop/src/shared/ui/animatedCountParts.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export type AnimatedCountSlot = {
|
||||||
|
current: string;
|
||||||
|
isDigit: boolean;
|
||||||
|
place: number;
|
||||||
|
previous: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const COUNT_FORMATTER = new Intl.NumberFormat("en-US", {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function normalizeAnimatedCount(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return 0;
|
||||||
|
return Math.max(0, Math.trunc(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAnimatedCount(value: number): string {
|
||||||
|
return COUNT_FORMATTER.format(normalizeAnimatedCount(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAsciiDigit(value: string): boolean {
|
||||||
|
return value.length === 1 && value >= "0" && value <= "9";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAnimatedCountSlots(
|
||||||
|
previous: string,
|
||||||
|
current: string,
|
||||||
|
): AnimatedCountSlot[] {
|
||||||
|
const width = Math.max(previous.length, current.length);
|
||||||
|
const previousOffset = width - previous.length;
|
||||||
|
const currentOffset = width - current.length;
|
||||||
|
|
||||||
|
return Array.from({ length: width }, (_, index) => {
|
||||||
|
const previousCharacter =
|
||||||
|
index >= previousOffset ? previous[index - previousOffset] : "";
|
||||||
|
const currentCharacter =
|
||||||
|
index >= currentOffset ? current[index - currentOffset] : "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: currentCharacter ?? "",
|
||||||
|
isDigit:
|
||||||
|
isAsciiDigit(previousCharacter) || isAsciiDigit(currentCharacter),
|
||||||
|
place: width - index - 1,
|
||||||
|
previous: previousCharacter ?? "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user