mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(app): gate blocked group rendering behind an experiment (#2763)
This commit is contained in:
1 parent
4b278a0da8
commit
ed10f9d323
22 files changed
+938
-30
No files matched your search
@@ -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;
|
||||
Reference in new issue
Block a user