feat(app): gate the expression info card behind an experiment (#2773)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-14 20:14:58 +02:00
1 parent 7c9274847f
commit 6339c3b8ad
110 files changed
+12827 -5191

No files matched your search

@@ -0,0 +1,138 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {act, createElement, type ReactNode} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const rolloutMock = {enabled: false};
vi.mock('@app/features/expressions/state/ExpressionInfoCardRollout', () => ({default: rolloutMock}));
vi.mock('@app/features/expressions/utils/CustomEmojiImageUrl', () => ({
buildCustomEmojiURL: ({id, animated}: {id: string; animated: boolean}) =>
`https://cdn.test/emojis/${id}.${animated ? 'gif' : 'png'}`,
}));
vi.mock('@app/features/expressions/utils/EmojiUtils', () => ({getEmojiURL: () => 'https://cdn.test/twemoji.svg'}));
vi.mock('@app/features/expressions/utils/UnicodeEmojis', () => ({
default: {
normalizeEmojiNameToSurrogate: (value: string) => value,
nameForSurrogate: (_surrogate: string, _colons: boolean, fallback: string) => fallback,
},
}));
vi.mock('@app/features/emoji/state/Emoji', () => ({default: {getEmojiById: () => null}}));
vi.mock('@app/features/guild/state/Guilds', () => ({default: {getGuild: () => null}}));
vi.mock('@app/features/ui/state/MobileLayout', () => ({default: {enabled: false}}));
vi.mock('@app/features/app/hooks/useShouldAnimate', () => ({useShouldAnimate: () => true}));
vi.mock('@app/features/presence/hooks/usePresenceCustomStatus', () => ({usePresenceCustomStatus: () => null}));
vi.mock('@app/features/ui/hooks/useTextOverflow', () => ({useTextOverflow: () => false}));
vi.mock('@app/features/theme/layout/RemFromPx', () => ({remFromPx: (value: number) => value}));
vi.mock('@app/features/ui/focus_ring/FocusRing', () => ({
default: ({children}: {children?: ReactNode}) => children,
}));
vi.mock('@lingui/react/macro', () => ({Trans: ({children}: {children?: ReactNode}) => children}));
vi.mock('@phosphor-icons/react', () => ({
PencilIcon: () => createElement('span'),
SmileyIcon: () => createElement('span'),
}));
vi.mock('@app/features/ui/tooltip/Tooltip', () => ({
Tooltip: ({text, children}: {text?: unknown; children?: ReactNode}) =>
createElement('div', {'data-simple-tooltip': String(text ?? '')}, children),
}));
vi.mock('@app/features/ui/tooltip/useHoverFloatingTooltip', () => ({useHoverFloatingTooltip: () => ({})}));
vi.mock('@app/features/ui/tooltip/HoverFloatingTooltipTrigger', () => ({
HoverFloatingTooltipTrigger: ({children}: {children?: ReactNode}) =>
createElement('div', {'data-hover-trigger': 'true'}, children),
}));
vi.mock('@app/features/ui/tooltip/HoverFloatingTooltipSurface', () => ({
HoverFloatingTooltipSurface: ({children}: {children?: ReactNode}) =>
createElement('div', {'data-hover-surface': 'true'}, children),
}));
vi.mock('@app/features/ui/emoji_tooltip_content/EmojiTooltipContent', () => ({
EmojiTooltipContent: ({primaryContent, subtext}: {primaryContent?: ReactNode; subtext?: ReactNode}) =>
createElement('div', {'data-emoji-tooltip-content': String(primaryContent ?? '')}, subtext),
}));
vi.mock('@app/features/emoji/components/emojis/EmojiAttributionSubtext', () => ({
getEmojiAttribution: () => ({type: 'custom_unknown'}),
EmojiAttributionSubtext: ({classes}: {classes?: Record<string, string | undefined>}) =>
createElement('div', {
'data-attribution': 'true',
'data-text-class': String(classes?.text ?? 'absent'),
}),
}));
const {CustomStatusDisplay} = await import(
'@app/features/app/components/shared/custom_status_display/CustomStatusDisplay'
);
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
const STATUS = {text: null, expiresAt: null, emojiId: '4001', emojiName: 'partyblob', emojiAnimated: false};
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
rolloutMock.enabled = false;
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
function render(): void {
act(() => {
root.render(createElement(CustomStatusDisplay, {customStatus: STATUS, showTooltip: true}));
});
}
describe('CustomStatusDisplay status emoji control arm', () => {
it('renders the hovercard with its attribution subtext', () => {
render();
expect(container.querySelector('[data-hover-trigger="true"]')).not.toBeNull();
expect(container.querySelector('[data-hover-surface="true"]')).not.toBeNull();
expect(container.querySelector('[data-emoji-tooltip-content=":partyblob:"]')).not.toBeNull();
expect(container.querySelector('[data-attribution="true"]')).not.toBeNull();
expect(container.querySelector('[data-simple-tooltip]')).toBeNull();
});
it('passes a classes map without a text entry, the way the hovercard always did', () => {
render();
expect(container.querySelector('[data-attribution="true"]')?.getAttribute('data-text-class')).toBe('absent');
});
});
describe('CustomStatusDisplay status emoji experiment arm', () => {
beforeEach(() => {
rolloutMock.enabled = true;
});
it('renders only the name tooltip', () => {
render();
expect(container.querySelector('[data-simple-tooltip=":partyblob:"]')).not.toBeNull();
expect(container.querySelector('[data-hover-trigger="true"]')).toBeNull();
expect(container.querySelector('[data-hover-surface="true"]')).toBeNull();
expect(container.querySelector('[data-attribution="true"]')).toBeNull();
});
});
describe('CustomStatusDisplay status emoji bucket flip', () => {
it('swaps arms without throwing when the assignment changes under a mounted status', () => {
render();
expect(container.querySelector('[data-hover-surface="true"]')).not.toBeNull();
rolloutMock.enabled = true;
act(() => {
root.render(createElement(CustomStatusDisplay, {customStatus: STATUS, showTooltip: true, showText: true}));
});
expect(container.querySelector('[data-hover-surface="true"]')).toBeNull();
expect(container.querySelector('[data-simple-tooltip=":partyblob:"]')).not.toBeNull();
});
});
@@ -7,6 +7,7 @@ import {
getEmojiAttribution,
} from '@app/features/emoji/components/emojis/EmojiAttributionSubtext';
import Emoji from '@app/features/emoji/state/Emoji';
import ExpressionInfoCardRollout from '@app/features/expressions/state/ExpressionInfoCardRollout';
import {buildCustomEmojiURL} from '@app/features/expressions/utils/CustomEmojiImageUrl';
import {getEmojiURL} from '@app/features/expressions/utils/EmojiUtils';
import UnicodeEmojis from '@app/features/expressions/utils/UnicodeEmojis';
@@ -79,6 +80,16 @@ const getTooltipEmojiUrl = (status: CustomStatus, animationAllowed: boolean): st
return null;
};
const getStatusEmojiDisplayName = (status: CustomStatus): string => {
if (status.emojiId) {
return `:${status.emojiName}:`;
}
if (status.emojiName) {
return UnicodeEmojis.nameForSurrogate(status.emojiName, true, status.emojiName);
}
return '';
};
interface StatusEmojiWithTooltipProps {
status: CustomStatus;
children: React.ReactNode;
@@ -86,81 +97,109 @@ interface StatusEmojiWithTooltipProps {
isButton?: boolean;
}
const StatusEmojiWithTooltip = observer(
const StatusEmojiHoverCard = observer(({status, children, onClick, isButton = false}: StatusEmojiWithTooltipProps) => {
const tooltip = useHoverFloatingTooltip(500);
const emoji = status.emojiId ? Emoji.getEmojiById(status.emojiId) : null;
const attribution = getEmojiAttribution({
emojiId: status.emojiId,
guildId: emoji?.guildId ?? null,
guild: emoji?.guildId ? Guilds.getGuild(emoji.guildId) : null,
emojiName: status.emojiName,
});
const emojiName = getStatusEmojiDisplayName(status);
const isAnimatable = getStatusEmojiAnimatable(status);
const animationAllowed = useShouldAnimate({
kind: 'custom_status_emoji',
isAnimated: isAnimatable,
isHovering: isAnimatable,
});
const tooltipEmojiUrl = getTooltipEmojiUrl(status, animationAllowed);
const TriggerComponent = isButton ? 'button' : 'span';
const triggerProps = isButton
? {type: 'button' as const, className: styles.emojiPressable, onClick}
: {className: styles.emojiTooltipTrigger};
return (
<>
<HoverFloatingTooltipTrigger
tooltip={tooltip}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.hover-floating-tooltip-trigger"
>
<TriggerComponent
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.trigger-component"
{...triggerProps}
>
{children}
</TriggerComponent>
</HoverFloatingTooltipTrigger>
<HoverFloatingTooltipSurface
tooltip={tooltip}
portalDataFlx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.floating-portal"
presenceDataFlx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.animate-presence"
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.div"
>
<EmojiTooltipContent
emojiUrl={tooltipEmojiUrl}
emojiAlt={status.emojiName ?? undefined}
primaryContent={emojiName}
subtext={
<EmojiAttributionSubtext
attribution={attribution}
classes={{
container: styles.emojiTooltipSubtext,
guildRow: styles.emojiTooltipGuildRow,
guildIcon: styles.emojiTooltipGuildIcon,
guildName: styles.emojiTooltipGuildName,
verifiedIcon: styles.emojiTooltipVerifiedIcon,
}}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.emoji-attribution-subtext"
/>
}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.emoji-tooltip-content"
/>
</HoverFloatingTooltipSurface>
</>
);
});
const StatusEmojiNameTooltip = observer(
({status, children, onClick, isButton = false}: StatusEmojiWithTooltipProps) => {
const tooltip = useHoverFloatingTooltip(500);
const emoji = status.emojiId ? Emoji.getEmojiById(status.emojiId) : null;
const attribution = getEmojiAttribution({
emojiId: status.emojiId,
guildId: emoji?.guildId ?? null,
guild: emoji?.guildId ? Guilds.getGuild(emoji.guildId) : null,
emojiName: status.emojiName,
});
const getEmojiDisplayName = (): string => {
if (status.emojiId) {
return `:${status.emojiName}:`;
}
if (status.emojiName) {
return UnicodeEmojis.nameForSurrogate(status.emojiName, true, status.emojiName);
}
return '';
};
const emojiName = getEmojiDisplayName();
const isAnimatable = getStatusEmojiAnimatable(status);
const animationAllowed = useShouldAnimate({
kind: 'custom_status_emoji',
isAnimated: isAnimatable,
isHovering: isAnimatable,
});
const tooltipEmojiUrl = getTooltipEmojiUrl(status, animationAllowed);
const TriggerComponent = isButton ? 'button' : 'span';
const triggerProps = isButton
? {type: 'button' as const, className: styles.emojiPressable, onClick}
: {className: styles.emojiTooltipTrigger};
return (
<>
<HoverFloatingTooltipTrigger
tooltip={tooltip}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.hover-floating-tooltip-trigger"
<Tooltip
text={getStatusEmojiDisplayName(status)}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.tooltip"
>
<TriggerComponent
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.trigger-component"
{...triggerProps}
>
<TriggerComponent
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.trigger-component"
{...triggerProps}
>
{children}
</TriggerComponent>
</HoverFloatingTooltipTrigger>
<HoverFloatingTooltipSurface
tooltip={tooltip}
portalDataFlx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.floating-portal"
presenceDataFlx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.animate-presence"
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.div"
>
<EmojiTooltipContent
emojiUrl={tooltipEmojiUrl}
emojiAlt={status.emojiName ?? undefined}
primaryContent={emojiName}
subtext={
<EmojiAttributionSubtext
attribution={attribution}
classes={{
container: styles.emojiTooltipSubtext,
guildRow: styles.emojiTooltipGuildRow,
guildIcon: styles.emojiTooltipGuildIcon,
guildName: styles.emojiTooltipGuildName,
verifiedIcon: styles.emojiTooltipVerifiedIcon,
}}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.emoji-attribution-subtext"
/>
}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.emoji-tooltip-content"
/>
</HoverFloatingTooltipSurface>
</>
{children}
</TriggerComponent>
</Tooltip>
);
},
);
const StatusEmojiWithTooltip = observer((props: StatusEmojiWithTooltipProps) => {
if (ExpressionInfoCardRollout.enabled) {
return (
<StatusEmojiNameTooltip
{...props}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.status-emoji-name-tooltip"
/>
);
}
return (
<StatusEmojiHoverCard
{...props}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.status-emoji-hover-card"
/>
);
});
interface EmojiRenderResult {
node: React.ReactNode;
altText: string;
@@ -139,7 +139,7 @@ export function WhatsNewModal({entry}: WhatsNewModalProps) {
>
<SafeMarkdown
content={entry.content}
options={{context: MarkdownContext.STANDARD_WITHOUT_JUMBO}}
options={{context: MarkdownContext.STANDARD_WITHOUT_JUMBO, disableEmojiInfoCard: true}}
data-flx="app.whats-new.whats-new-modal.safe-markdown"
/>
</div>