fix(app): gate reworked typing indicators behind an experiment (#2782)

This commit is contained in:
Hampus
2026-09-14 21:54:52 +02:00
committed by GitHub
parent 1861432a53
commit c212d315f4
91 changed files with 6137 additions and 1756 deletions
@@ -47,6 +47,10 @@ import {
MessageKeyboardFocusConfigResponse,
MessageKeyboardFocusConfigUpdateRequest,
} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
import {
TypingIndicatorReworkConfigResponse,
TypingIndicatorReworkConfigUpdateRequest,
} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas';
import {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import {
InstanceCaptchaProviderSchema,
@@ -660,6 +664,7 @@ export const InstanceConfigResponse = z.object({
blocked_message_groups: BlockedMessageGroupsConfigResponse,
expression_info_card: ExpressionInfoCardConfigResponse,
guild_header_collapse: GuildHeaderCollapseConfigResponse,
typing_indicator_rework: TypingIndicatorReworkConfigResponse,
registration: InstanceRegistrationResponse,
self_hosted: z.boolean(),
app_public: AppPublicConfigResponse,
@@ -702,6 +707,7 @@ export const InstanceConfigUpdateRequest = z.object({
blocked_message_groups: BlockedMessageGroupsConfigUpdateRequest.nullish(),
expression_info_card: ExpressionInfoCardConfigUpdateRequest.nullish(),
guild_header_collapse: GuildHeaderCollapseConfigUpdateRequest.nullish(),
typing_indicator_rework: TypingIndicatorReworkConfigUpdateRequest.nullish(),
registration: z
.object({
mode: InstanceRegistrationModeSchema.optional(),
@@ -28,6 +28,10 @@ import {
INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT,
MessageKeyboardFocusAssignmentResponse,
} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
import {
INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT,
TypingIndicatorReworkAssignmentResponse,
} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas';
import {z} from 'zod';
export const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS = 60;
@@ -72,6 +76,7 @@ const ExperimentAssignmentsSchema = z.object({
guild_activity_log_presentation: GuildActivityLogPresentationAssignmentResponse.optional(),
expression_info_card: ExpressionInfoCardAssignmentResponse.optional(),
guild_header_collapse: GuildHeaderCollapseAssignmentResponse.optional(),
typing_indicator_rework: TypingIndicatorReworkAssignmentResponse.optional(),
});
export const ExperimentAssignmentsResponse = z.object({
@@ -129,3 +134,9 @@ export function readGuildHeaderCollapseAssignment(
): GuildHeaderCollapseAssignmentResponse {
return response.assignments.guild_header_collapse ?? INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT;
}
export function readTypingIndicatorReworkAssignment(
response: ExperimentAssignmentsResponse,
): TypingIndicatorReworkAssignmentResponse {
return response.assignments.typing_indicator_rework ?? INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT;
}
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createClientBehaviorExperiment} from '@fluxer/schema/src/domains/experiment/ClientBehaviorExperiment';
import type {z} from 'zod';
const typingIndicatorRework = createClientBehaviorExperiment({defaultRolloutSalt: 'typing-indicator-rework-v1'});
export const TypingIndicatorReworkConfigSchema = typingIndicatorRework.ConfigSchema;
export type TypingIndicatorReworkConfig = z.infer<typeof TypingIndicatorReworkConfigSchema>;
export const DEFAULT_TYPING_INDICATOR_REWORK_CONFIG: TypingIndicatorReworkConfig = typingIndicatorRework.DEFAULT_CONFIG;
export const TypingIndicatorReworkConfigUpdateRequest = typingIndicatorRework.ConfigUpdateRequest;
export type TypingIndicatorReworkConfigUpdateRequest = z.infer<typeof TypingIndicatorReworkConfigUpdateRequest>;
export const TypingIndicatorReworkConfigResponse = typingIndicatorRework.ConfigSchema;
export type TypingIndicatorReworkConfigResponse = TypingIndicatorReworkConfig;
export const TypingIndicatorReworkAssignmentResponse = typingIndicatorRework.AssignmentResponse;
export type TypingIndicatorReworkAssignmentResponse = z.infer<typeof TypingIndicatorReworkAssignmentResponse>;
export const INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT: TypingIndicatorReworkAssignmentResponse =
typingIndicatorRework.INERT_ASSIGNMENT;
export function resolveTypingIndicatorReworkAssignment(
config: TypingIndicatorReworkConfig,
userId: string,
): TypingIndicatorReworkAssignmentResponse {
return typingIndicatorRework.resolveAssignment(config, userId);
}
export function isTypingIndicatorReworkTargeted(assignment: TypingIndicatorReworkAssignmentResponse): boolean {
return assignment.enabled && assignment.user_targeted;
}
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
DEFAULT_TYPING_INDICATOR_REWORK_CONFIG,
INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT,
isTypingIndicatorReworkTargeted,
resolveTypingIndicatorReworkAssignment,
type TypingIndicatorReworkConfig,
TypingIndicatorReworkConfigSchema,
} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas';
import {describe, expect, it} from 'vitest';
const USER_ID = '1485064866382176262';
const OTHER_USER_ID = '1485064866382176263';
function config(overrides: Partial<TypingIndicatorReworkConfig> = {}): TypingIndicatorReworkConfig {
return {...DEFAULT_TYPING_INDICATOR_REWORK_CONFIG, ...overrides};
}
describe('TypingIndicatorReworkConfigSchema', () => {
it('defaults to a disabled rollout that targets nobody', () => {
expect(DEFAULT_TYPING_INDICATOR_REWORK_CONFIG).toEqual({
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: 'typing-indicator-rework-v1',
included_user_ids: [],
excluded_user_ids: [],
});
});
it('rejects a rollout share above the bucket resolution', () => {
expect(() => TypingIndicatorReworkConfigSchema.parse({rollout_basis_points: 10001})).toThrow();
});
it('rejects a target that is not a snowflake', () => {
expect(() => TypingIndicatorReworkConfigSchema.parse({included_user_ids: ['not-a-snowflake']})).toThrow();
});
});
describe('resolveTypingIndicatorReworkAssignment', () => {
it('reports the inert assignment with the stored revision while the rollout is off', () => {
const assignment = resolveTypingIndicatorReworkAssignment(
config({enabled: false, config_version: 4, rollout_basis_points: 10000}),
USER_ID,
);
expect(assignment).toEqual({...INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT, config_version: 4});
expect(isTypingIndicatorReworkTargeted(assignment)).toBe(false);
});
it('targets every account at the full rollout share', () => {
const assignment = resolveTypingIndicatorReworkAssignment(
config({enabled: true, config_version: 2, rollout_basis_points: 10000}),
USER_ID,
);
expect(assignment).toEqual({enabled: true, config_version: 2, user_targeted: true, source: 'canary'});
expect(isTypingIndicatorReworkTargeted(assignment)).toBe(true);
});
it('targets no account at a zero rollout share', () => {
const assignment = resolveTypingIndicatorReworkAssignment(config({enabled: true}), USER_ID);
expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: false, source: null});
expect(isTypingIndicatorReworkTargeted(assignment)).toBe(false);
});
it('targets an allowlisted account outside the sampled share', () => {
const assignment = resolveTypingIndicatorReworkAssignment(
config({enabled: true, included_user_ids: [USER_ID]}),
USER_ID,
);
expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: true, source: 'user_rule'});
});
it('excludes an account even when it is allowlisted and inside the share', () => {
const assignment = resolveTypingIndicatorReworkAssignment(
config({
enabled: true,
rollout_basis_points: 10000,
included_user_ids: [USER_ID],
excluded_user_ids: [USER_ID],
}),
USER_ID,
);
expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: false, source: null});
expect(
resolveTypingIndicatorReworkAssignment(config({enabled: true, rollout_basis_points: 10000}), OTHER_USER_ID),
).toEqual({enabled: true, config_version: 0, user_targeted: true, source: 'canary'});
});
it('resolves the same account the same way on every call', () => {
const rollout = config({enabled: true, rollout_basis_points: 5000});
const first = resolveTypingIndicatorReworkAssignment(rollout, USER_ID);
const second = resolveTypingIndicatorReworkAssignment(rollout, USER_ID);
expect(first).toEqual(second);
});
});