mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +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
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user