mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(app): gate the expression info card behind an experiment (#2773)
This commit is contained in:
1 parent
7c9274847f
commit
6339c3b8ad
110 files changed
+12827
-5191
No files matched your search
+138
@@ -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();
|
||||
});
|
||||
});
|
||||
+103
-64
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user