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

@@ -27,6 +27,10 @@ import {
ExperimentDeliveryConfigResponse,
ExperimentDeliveryConfigUpdateRequest,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {
ExpressionInfoCardConfigResponse,
ExpressionInfoCardConfigUpdateRequest,
} from '@fluxer/schema/src/domains/experiment/ExpressionInfoCardSchemas';
import {
GuildActivityLogPresentationConfigResponse,
GuildActivityLogPresentationConfigUpdateRequest,
@@ -650,6 +654,7 @@ export const InstanceConfigResponse = z.object({
message_hover_tracking: MessageHoverTrackingConfigResponse,
message_keyboard_focus: MessageKeyboardFocusConfigResponse,
blocked_message_groups: BlockedMessageGroupsConfigResponse,
expression_info_card: ExpressionInfoCardConfigResponse,
registration: InstanceRegistrationResponse,
self_hosted: z.boolean(),
app_public: AppPublicConfigResponse,
@@ -690,6 +695,7 @@ export const InstanceConfigUpdateRequest = z.object({
message_hover_tracking: MessageHoverTrackingConfigUpdateRequest.nullish(),
message_keyboard_focus: MessageKeyboardFocusConfigUpdateRequest.nullish(),
blocked_message_groups: BlockedMessageGroupsConfigUpdateRequest.nullish(),
expression_info_card: ExpressionInfoCardConfigUpdateRequest.nullish(),
registration: z
.object({
mode: InstanceRegistrationModeSchema.optional(),
@@ -8,6 +8,10 @@ import {
BlockedMessageGroupsAssignmentResponse,
INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT,
} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
import {
ExpressionInfoCardAssignmentResponse,
INERT_EXPRESSION_INFO_CARD_ASSIGNMENT,
} from '@fluxer/schema/src/domains/experiment/ExpressionInfoCardSchemas';
import {
GuildActivityLogPresentationAssignmentResponse,
INERT_GUILD_ACTIVITY_LOG_PRESENTATION_ASSIGNMENT,
@@ -62,6 +66,7 @@ const ExperimentAssignmentsSchema = z.object({
message_keyboard_focus: MessageKeyboardFocusAssignmentResponse.optional(),
blocked_message_groups: BlockedMessageGroupsAssignmentResponse.optional(),
guild_activity_log_presentation: GuildActivityLogPresentationAssignmentResponse.optional(),
expression_info_card: ExpressionInfoCardAssignmentResponse.optional(),
});
export const ExperimentAssignmentsResponse = z.object({
@@ -107,3 +112,9 @@ export function readGuildActivityLogPresentationAssignment(
): GuildActivityLogPresentationAssignmentResponse {
return response.assignments.guild_activity_log_presentation ?? INERT_GUILD_ACTIVITY_LOG_PRESENTATION_ASSIGNMENT;
}
export function readExpressionInfoCardAssignment(
response: ExperimentAssignmentsResponse,
): ExpressionInfoCardAssignmentResponse {
return response.assignments.expression_info_card ?? INERT_EXPRESSION_INFO_CARD_ASSIGNMENT;
}
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createClientBehaviorExperiment} from '@fluxer/schema/src/domains/experiment/ClientBehaviorExperiment';
import type {z} from 'zod';
const expressionInfoCard = createClientBehaviorExperiment({defaultRolloutSalt: 'expression-info-card-v1'});
export const ExpressionInfoCardConfigSchema = expressionInfoCard.ConfigSchema;
export type ExpressionInfoCardConfig = z.infer<typeof ExpressionInfoCardConfigSchema>;
export const DEFAULT_EXPRESSION_INFO_CARD_CONFIG: ExpressionInfoCardConfig = expressionInfoCard.DEFAULT_CONFIG;
export const ExpressionInfoCardConfigUpdateRequest = expressionInfoCard.ConfigUpdateRequest;
export type ExpressionInfoCardConfigUpdateRequest = z.infer<typeof ExpressionInfoCardConfigUpdateRequest>;
export const ExpressionInfoCardConfigResponse = expressionInfoCard.ConfigSchema;
export type ExpressionInfoCardConfigResponse = ExpressionInfoCardConfig;
export const ExpressionInfoCardAssignmentResponse = expressionInfoCard.AssignmentResponse;
export type ExpressionInfoCardAssignmentResponse = z.infer<typeof ExpressionInfoCardAssignmentResponse>;
export const INERT_EXPRESSION_INFO_CARD_ASSIGNMENT: ExpressionInfoCardAssignmentResponse =
expressionInfoCard.INERT_ASSIGNMENT;
export function resolveExpressionInfoCardAssignment(
config: ExpressionInfoCardConfig,
userId: string,
): ExpressionInfoCardAssignmentResponse {
return expressionInfoCard.resolveAssignment(config, userId);
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {MAX_GUILD_STICKER_TAGS} from '@fluxer/constants/src/LimitConstants';
import {type UserPartial, UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
@@ -70,12 +71,33 @@ export const GuildEmojiWithUserListResponse = z.array(GuildEmojiWithUserResponse
export type GuildEmojiWithUserListResponse = z.infer<typeof GuildEmojiWithUserListResponse>;
export const GuildStickerWithUserListResponse = z.array(GuildStickerWithUserResponse);
export const GUILD_EXPRESSION_SOURCE_BADGE_FEATURES = [
GuildFeatures.VERIFIED,
GuildFeatures.PARTNERED,
GuildFeatures.DISCOVERABLE,
] as const;
export const GuildExpressionSourceGuildResponse = z
.object({
id: SnowflakeStringType.describe('The ID of the source guild'),
name: z.string().describe('The name of the source guild'),
icon: z.string().nullable().describe('The hash of the source guild icon'),
features: z
.array(z.enum(GUILD_EXPRESSION_SOURCE_BADGE_FEATURES))
.describe('The badge feature flags of the source guild, limited to VERIFIED, PARTNERED, and DISCOVERABLE'),
})
.describe('Public presentation of the source guild of an expression');
export type GuildExpressionSourceGuildResponse = z.infer<typeof GuildExpressionSourceGuildResponse>;
export const GuildEmojiMetadataResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for this emoji'),
guild_id: SnowflakeStringType.describe('The guild this emoji belongs to'),
name: z.string().describe('The name of the emoji'),
animated: z.boolean().describe('Whether this emoji is animated'),
allow_cloning: z.boolean().describe('Whether the source guild allows non-members to use the in-app clone shortcut'),
guild: GuildExpressionSourceGuildResponse,
});
export type GuildEmojiMetadataResponse = z.infer<typeof GuildEmojiMetadataResponse>;
@@ -86,6 +108,7 @@ export const GuildStickerMetadataResponse = z.object({
name: z.string().describe('The name of the sticker'),
animated: z.boolean().describe('Whether this sticker is animated'),
allow_cloning: z.boolean().describe('Whether the source guild allows non-members to use the in-app clone shortcut'),
guild: GuildExpressionSourceGuildResponse,
});
export type GuildStickerMetadataResponse = z.infer<typeof GuildStickerMetadataResponse>;
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {
DEFAULT_EXPRESSION_INFO_CARD_CONFIG,
ExpressionInfoCardConfigSchema,
resolveExpressionInfoCardAssignment,
} from '@fluxer/schema/src/domains/experiment/ExpressionInfoCardSchemas';
import {describe, expect, it} from 'vitest';
const USER_ID = '1485064866382176262';
const OTHER_USER_ID = '1485064866382176263';
function config(overrides: Partial<ReturnType<typeof ExpressionInfoCardConfigSchema.parse>> = {}) {
return ExpressionInfoCardConfigSchema.parse({...overrides});
}
describe('expression info card configuration', () => {
it('defaults to a disabled rollout that targets nobody', () => {
expect(DEFAULT_EXPRESSION_INFO_CARD_CONFIG).toEqual({
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: 'expression-info-card-v1',
included_user_ids: [],
excluded_user_ids: [],
});
});
it('refuses a rollout wider than the bucket resolution', () => {
expect(() => config({rollout_basis_points: EXPERIMENT_BUCKET_RESOLUTION + 1})).toThrow();
});
it('refuses a target that is not a snowflake', () => {
expect(() => config({included_user_ids: ['not-a-snowflake']})).toThrow();
});
});
describe('expression info card resolution', () => {
it('reports the stored revision while the rollout is disabled', () => {
const assignment = resolveExpressionInfoCardAssignment(config({config_version: 3}), USER_ID);
expect(assignment).toEqual({enabled: false, config_version: 3, user_targeted: false, source: null});
});
it('targets an allowlisted account regardless of its bucket', () => {
const assignment = resolveExpressionInfoCardAssignment(
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('keeps a blocklisted account out of a full rollout', () => {
const assignment = resolveExpressionInfoCardAssignment(
config({
enabled: true,
rollout_basis_points: EXPERIMENT_BUCKET_RESOLUTION,
excluded_user_ids: [USER_ID],
}),
USER_ID,
);
expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: false, source: null});
});
it('prefers the blocklist over the allowlist', () => {
const assignment = resolveExpressionInfoCardAssignment(
config({enabled: true, included_user_ids: [USER_ID], excluded_user_ids: [USER_ID]}),
USER_ID,
);
expect(assignment.user_targeted).toBe(false);
});
it('targets an account whose bucket falls inside the rollout', () => {
const salt = DEFAULT_EXPRESSION_INFO_CARD_CONFIG.rollout_salt;
const bucket = experimentBucket(USER_ID, salt);
const inside = resolveExpressionInfoCardAssignment(
config({enabled: true, rollout_basis_points: bucket + 1}),
USER_ID,
);
const outside = resolveExpressionInfoCardAssignment(config({enabled: true, rollout_basis_points: bucket}), USER_ID);
expect(inside).toEqual({enabled: true, config_version: 0, user_targeted: true, source: 'canary'});
expect(outside).toEqual({enabled: true, config_version: 0, user_targeted: false, source: null});
});
it('separates two accounts under the same salt', () => {
const salt = DEFAULT_EXPRESSION_INFO_CARD_CONFIG.rollout_salt;
expect(experimentBucket(USER_ID, salt)).not.toBe(experimentBucket(OTHER_USER_ID, salt));
});
it('moves an account when the salt changes', () => {
expect(experimentBucket(USER_ID, 'expression-info-card-v1')).not.toBe(
experimentBucket(USER_ID, 'expression-info-card-v2'),
);
});
});