fix(app): gate blocked group rendering behind an experiment (#2763)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-14 16:08:19 +02:00
1 parent 4b278a0da8
commit ed10f9d323
22 files changed
+938 -30

No files matched your search

+4
View File
@@ -21,6 +21,9 @@ import {useServiceWorkerBadge} from '@app/features/app/hooks/useServiceWorkerBad
import {useTabKeyFocusGuard} from '@app/features/app/hooks/useTabKeyFocusGuard';
import {type LayoutVariant, LayoutVariantProvider} from '@app/features/app/state/LayoutVariantContext';
import RuntimeCrash from '@app/features/app/state/RuntimeCrash';
import BlockedMessageGroupsRollout, {
BLOCKED_MESSAGE_GROUPS_EXPERIMENT_CLASS,
} from '@app/features/channel/state/BlockedMessageGroupsRollout';
import MessageHoverTrackingRollout, {
MESSAGE_HOVER_TRACKING_EXPERIMENT_CLASS,
} from '@app/features/channel/state/MessageHoverTrackingRollout';
@@ -158,6 +161,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
useDocumentClassToggle('mobile-layout', MobileLayout.platformMobileDetected || MobileLayout.enabled);
useDocumentClassToggle(UNFOCUSED_FULLY_INTERACTIVE_CLASS, stayInteractiveWhenUnfocused);
useDocumentClassToggle(MESSAGE_HOVER_TRACKING_EXPERIMENT_CLASS, MessageHoverTrackingRollout.enabled);
useDocumentClassToggle(BLOCKED_MESSAGE_GROUPS_EXPERIMENT_CLASS, BlockedMessageGroupsRollout.enabled);
useDesktopAllowTransparency(isNative);
useWindowEventListeners({preventDocumentScroll: !isNative});
useRemScaleTracking();
@@ -2,9 +2,28 @@
.container {
background-color: var(--background-secondary);
}
:global(html:not(.experiment-blocked-message-groups)) .container {
border-radius: 0.25rem;
}
:global(html.experiment-blocked-message-groups) .container {
margin-left: calc(-1 * var(--chat-mobile-horizontal-padding));
margin-right: calc(-1 * var(--chat-mobile-horizontal-padding));
padding-left: var(--chat-mobile-horizontal-padding);
padding-right: var(--chat-mobile-horizontal-padding);
}
@media (min-width: 768px) {
:global(html.experiment-blocked-message-groups) .container {
margin-left: calc(-1 * var(--chat-horizontal-padding));
margin-right: calc(-1 * var(--chat-horizontal-padding));
padding-left: var(--chat-horizontal-padding);
padding-right: var(--chat-horizontal-padding);
}
}
.toggle {
display: flex;
width: 100%;
@@ -0,0 +1,129 @@
// @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: true};
const ChannelStreamType = {
MESSAGE: 'MESSAGE',
MESSAGE_GROUP_BLOCKED: 'MESSAGE_GROUP_BLOCKED',
MESSAGE_GROUP_IGNORED: 'MESSAGE_GROUP_IGNORED',
MESSAGE_GROUP_SPAMMER: 'MESSAGE_GROUP_SPAMMER',
DIVIDER: 'DIVIDER',
} as const;
vi.mock('@app/features/messaging/utils/MessageGroupingUtils', () => ({ChannelStreamType}));
vi.mock('@app/features/channel/state/BlockedMessageGroupsRollout', () => ({default: rolloutMock}));
vi.mock('@lingui/core/macro', () => ({msg: (value: unknown) => value}));
vi.mock('@lingui/react/macro', () => ({useLingui: () => ({i18n: {_: () => 'blocked messages'}})}));
vi.mock('@app/features/channel/components/ChannelDivider', () => ({
Divider: ({children}: {children?: ReactNode}) => createElement('div', {'data-divider': true}, children),
}));
vi.mock('@app/features/channel/components/MessageGroup', () => ({
MessageGroup: () => createElement('div', {'data-message-group': true}),
}));
const {BlockedMessageGroups} = await import('@app/features/channel/components/BlockedMessageGroups');
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
const CHANNEL = {id: 'channel-1', guild_id: null} as never;
const SPACER_SELECTOR = '[data-flx="channel.blocked-message-groups.group-spacer"]';
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
function message(id: string): Record<string, unknown> {
return {id, author: {id: 'author-1'}, blocked: true};
}
beforeEach(() => {
rolloutMock.enabled = true;
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
consoleError.mockRestore();
});
function renderRevealedGroup(messageGroups: Array<unknown>): void {
act(() => {
root.render(
createElement(BlockedMessageGroups, {
channel: CHANNEL,
messageGroups: messageGroups as never,
onReveal: () => undefined,
revealed: true,
compact: false,
messageGroupSpacing: 8,
variant: 'blocked',
}),
);
});
}
function duplicateKeyWarnings(): Array<unknown> {
return consoleError.mock.calls.filter((call: Array<unknown>) => String(call[0]).includes('same key'));
}
const DIVIDER_INSIDE_GROUP = [
{type: ChannelStreamType.MESSAGE, content: message('100'), contentKey: '100', groupId: 'g1'},
{type: ChannelStreamType.DIVIDER, content: '', unreadId: '200', contentKey: 'divider-200'},
{type: ChannelStreamType.MESSAGE, content: message('200'), contentKey: '200', groupId: 'g2'},
];
describe('BlockedMessageGroups experiment arm', () => {
it('keys an unread divider apart from the message group below it', () => {
renderRevealedGroup(DIVIDER_INSIDE_GROUP);
expect(duplicateKeyWarnings()).toEqual([]);
expect(container.querySelectorAll('[data-message-group]')).toHaveLength(2);
});
it('skips a divider that leads the revealed group', () => {
renderRevealedGroup([
{type: ChannelStreamType.DIVIDER, content: '', unreadId: '100', contentKey: 'divider-100'},
{type: ChannelStreamType.MESSAGE, content: message('100'), contentKey: '100', groupId: 'g1'},
]);
expect(duplicateKeyWarnings()).toEqual([]);
expect(container.querySelectorAll('[data-message-group]')).toHaveLength(1);
expect(container.querySelectorAll('[data-divider]')).toHaveLength(0);
});
it('spaces consecutive groups apart inside the revealed block', () => {
renderRevealedGroup([
{type: ChannelStreamType.MESSAGE, content: message('100'), contentKey: '100', groupId: 'g1'},
{type: ChannelStreamType.MESSAGE, content: message('200'), contentKey: '200', groupId: 'g2'},
{type: ChannelStreamType.MESSAGE, content: message('300'), contentKey: '300', groupId: 'g3'},
]);
expect(container.querySelectorAll('[data-message-group]')).toHaveLength(3);
expect(container.querySelectorAll(SPACER_SELECTOR)).toHaveLength(2);
});
});
describe('BlockedMessageGroups control arm', () => {
beforeEach(() => {
rolloutMock.enabled = false;
});
it('renders no group spacers', () => {
renderRevealedGroup([
{type: ChannelStreamType.MESSAGE, content: message('100'), contentKey: '100', groupId: 'g1'},
{type: ChannelStreamType.MESSAGE, content: message('200'), contentKey: '200', groupId: 'g2'},
]);
expect(container.querySelectorAll('[data-message-group]')).toHaveLength(2);
expect(container.querySelectorAll(SPACER_SELECTOR)).toHaveLength(0);
});
});
@@ -2,12 +2,14 @@
import styles from '@app/features/channel/components/BlockedMessageGroups.module.css';
import {Divider} from '@app/features/channel/components/ChannelDivider';
import streamStyles from '@app/features/channel/components/ChannelMessages.module.css';
import {
MessageGroup,
type MessageGroupProps,
type MessageGroupRenderWrapperProps,
} from '@app/features/channel/components/MessageGroup';
import type {Channel} from '@app/features/channel/models/Channel';
import BlockedMessageGroupsRollout from '@app/features/channel/state/BlockedMessageGroupsRollout';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
import {type ChannelStreamItem, ChannelStreamType} from '@app/features/messaging/utils/MessageGroupingUtils';
@@ -100,6 +102,7 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
renderMessageWrapper,
suppressUnreadIndicator,
} = props;
const groupRenderingEnabled = BlockedMessageGroupsRollout.enabled;
const {i18n} = useLingui();
const containerRef = useRef<HTMLDivElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
@@ -231,8 +234,20 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
const nodes: Array<React.ReactNode> = [];
let currentGroupMessages: Array<Message> = [];
let groupId: string | undefined;
let renderedGroupCount = 0;
const flushGroup = () => {
if (currentGroupMessages.length > 0) {
if (groupRenderingEnabled && renderedGroupCount > 0 && messageGroupSpacing > 0) {
nodes.push(
<div
key={`blocked-group-spacer-${currentGroupMessages[0].id}`}
className={streamStyles.groupSpacer}
aria-hidden="true"
data-flx="channel.blocked-message-groups.group-spacer"
/>,
);
}
renderedGroupCount += 1;
nodes.push(
<MessageGroup
key={currentGroupMessages[0].id}
@@ -264,7 +279,13 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
flushGroup();
nodes.push(
<Divider
key={item.unreadId || item.contentKey || `divider-${itemIndex}`}
key={
groupRenderingEnabled
? item.unreadId
? `unread-divider-${item.unreadId}`
: item.contentKey || `divider-${itemIndex}`
: item.unreadId || item.contentKey || `divider-${itemIndex}`
}
spacing={messageGroupSpacing}
red={!!item.unreadId}
id={item.unreadId ? 'new-messages-bar' : undefined}
@@ -298,6 +319,7 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
renderMessageActions,
renderMessageWrapper,
suppressUnreadIndicator,
groupRenderingEnabled,
]);
const leadingUnreadDivider = messageGroups[0]?.type === ChannelStreamType.DIVIDER && !!messageGroups[0].unreadId;
return (
@@ -0,0 +1,75 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type BlockedMessageGroupsAssignmentResponse,
INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT,
} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
import {
type ExperimentAssignmentsResponse,
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {runInAction} from 'mobx';
import {afterEach, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/platform/utils/AppLogger', () => ({
Logger: class {
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = vi.fn();
},
}));
vi.mock('@app/features/platform/transport/RestTransport', () => ({
http: {get: vi.fn(), post: vi.fn()},
}));
const {ExperimentAssignments} = await import('@app/features/experiment/state/ExperimentAssignments');
const {BlockedMessageGroupsRollout} = await import('@app/features/channel/state/BlockedMessageGroupsRollout');
function publish(assignment: BlockedMessageGroupsAssignmentResponse): void {
const response: ExperimentAssignmentsResponse = {
poll_interval_seconds: 300,
poll_jitter_percent: 15,
assignments: {blocked_message_groups: assignment},
};
runInAction(() => {
ExperimentAssignments.response = response;
});
}
afterEach(() => {
runInAction(() => {
ExperimentAssignments.response = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE;
});
});
describe('BlockedMessageGroupsRollout', () => {
it('reads the inert assignment out of the inert envelope', () => {
expect(BlockedMessageGroupsRollout.assignment).toBe(INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT);
expect(BlockedMessageGroupsRollout.enabled).toBe(false);
});
it('stays on the control arm while the rollout is disabled', () => {
publish({enabled: false, config_version: 2, user_targeted: false, source: null});
expect(BlockedMessageGroupsRollout.enabled).toBe(false);
});
it('stays on the control arm for an account the rollout did not target', () => {
publish({enabled: true, config_version: 2, user_targeted: false, source: null});
expect(BlockedMessageGroupsRollout.enabled).toBe(false);
});
it('moves to the experiment arm for a targeted account', () => {
publish({enabled: true, config_version: 2, user_targeted: true, source: 'canary'});
expect(BlockedMessageGroupsRollout.enabled).toBe(true);
});
it('follows the envelope back to the control arm when the store is reset', () => {
publish({enabled: true, config_version: 2, user_targeted: true, source: 'user_rule'});
expect(BlockedMessageGroupsRollout.enabled).toBe(true);
ExperimentAssignments.reset();
expect(BlockedMessageGroupsRollout.enabled).toBe(false);
});
});
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import type {BlockedMessageGroupsAssignmentResponse} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
import {readBlockedMessageGroupsAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
export const BLOCKED_MESSAGE_GROUPS_EXPERIMENT_CLASS = 'experiment-blocked-message-groups';
class BlockedMessageGroupsRolloutSelector {
get assignment(): BlockedMessageGroupsAssignmentResponse {
return readBlockedMessageGroupsAssignment(ExperimentAssignments.response);
}
get enabled(): boolean {
const assignment = this.assignment;
return assignment.enabled && assignment.user_targeted;
}
}
export const BlockedMessageGroupsRollout = new BlockedMessageGroupsRolloutSelector();
export default BlockedMessageGroupsRollout;