From 4b278a0da8f39949f2ce9cfda4817e920743f2cc Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 14 Sep 2026 15:55:00 +0200 Subject: [PATCH] fix(app): gate one-Tab message focus behind an experiment (#2762) --- fluxer_admin/openapi-admin.json | 54 ++++ fluxer_admin/src/api/types/instance_config.rs | 78 +++++ fluxer_admin/src/routes/system_actions.rs | 41 ++- .../src/templates/pages/instance_config.rs | 112 ++++++- .../InstanceConfigAdminController.ts | 16 + .../api/experiment/ExperimentController.ts | 5 +- .../tests/ExperimentController.test.ts | 60 +++- .../instance/InstanceConfigRepository.test.ts | 50 +++ .../api/instance/InstanceConfigRepository.ts | 21 ++ fluxer_api/src/api/openapi/openapi.json | 14 +- .../components/layout/KeyboardModeListener.ts | 13 +- .../components/BlockedMessageGroups.tsx | 121 ++++++-- .../channel/components/ChannelMessage.tsx | 20 +- .../ChannelMessageFocusTarget.test.ts | 89 ++++++ .../components/ChannelMessageStream.tsx | 3 +- .../channel/components/ChannelMessages.tsx | 6 +- .../LexicalChannelTextareaContent.tsx | 7 +- .../MessageFocusRingContract.test.ts | 51 +++ ...eChannelComposerDraftFocusRestore.test.tsx | 107 +++++++ .../lexical/composer/LexicalComposerInput.tsx | 7 +- .../lexical/composer/LexicalRichInput.tsx | 4 +- .../useMessageListKeyboardNavigation.test.tsx | 266 ++++++++++++++++ .../hooks/useMessageListKeyboardNavigation.ts | 32 +- .../state/MessageKeyboardFocusRollout.test.ts | 75 +++++ .../state/MessageKeyboardFocusRollout.ts | 20 ++ .../utils/MessageNodeSelectors.test.ts | 120 ++++++++ .../messaging/utils/MessageNodeSelectors.ts | 25 ++ .../features/platform/utils/ScrollManager.ts | 10 +- .../ui/focus_ring/FocusRingScope.test.tsx | 290 ++++++++++++++++++ .../src/content/docs/admin-api/instance.mdx | 26 +- .../src/content/docs/http-api/experiments.mdx | 32 +- .../schema/src/domains/admin/AdminSchemas.ts | 6 + .../domains/experiment/ExperimentSchemas.ts | 11 + .../experiment/MessageKeyboardFocusSchemas.ts | 36 +++ .../tests/MessageKeyboardFocusSchemas.test.ts | 98 ++++++ 35 files changed, 1864 insertions(+), 62 deletions(-) create mode 100644 fluxer_app/src/features/channel/components/ChannelMessageFocusTarget.test.ts create mode 100644 fluxer_app/src/features/channel/components/MessageFocusRingContract.test.ts create mode 100644 fluxer_app/src/features/channel/components/useChannelComposerDraftFocusRestore.test.tsx create mode 100644 fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.test.tsx create mode 100644 fluxer_app/src/features/messaging/state/MessageKeyboardFocusRollout.test.ts create mode 100644 fluxer_app/src/features/messaging/state/MessageKeyboardFocusRollout.ts create mode 100644 fluxer_app/src/features/messaging/utils/MessageNodeSelectors.test.ts create mode 100644 fluxer_app/src/features/messaging/utils/MessageNodeSelectors.ts create mode 100644 fluxer_app/src/features/ui/focus_ring/FocusRingScope.test.tsx create mode 100644 packages/schema/src/domains/experiment/MessageKeyboardFocusSchemas.ts create mode 100644 packages/schema/src/domains/tests/MessageKeyboardFocusSchemas.test.ts diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 429cdb04d..3326d2ece 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -10564,6 +10564,7 @@ "voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"}, "experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"}, "message_hover_tracking": {"$ref": "#/components/schemas/MessageHoverTrackingConfigResponse"}, + "message_keyboard_focus": {"$ref": "#/components/schemas/MessageKeyboardFocusConfigResponse"}, "registration": { "type": "object", "properties": { @@ -10968,6 +10969,7 @@ "voice_noise_suppression", "experiment_delivery", "message_hover_tracking", + "message_keyboard_focus", "registration", "self_hosted", "app_public", @@ -11109,6 +11111,10 @@ "nullable": true, "allOf": [{"$ref": "#/components/schemas/MessageHoverTrackingConfigUpdateRequest"}] }, + "message_keyboard_focus": { + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/MessageKeyboardFocusConfigUpdateRequest"}] + }, "registration": { "nullable": true, "type": "object", @@ -15165,6 +15171,24 @@ "enum": ["open", "approval", "closed"], "type": "string" }, + "MessageKeyboardFocusConfigUpdateRequest": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000}, + "rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64}, + "included_user_ids": { + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + }, + "excluded_user_ids": { + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + } + } + }, "MessageHoverTrackingConfigUpdateRequest": { "type": "object", "properties": { @@ -15247,6 +15271,36 @@ "type": "string", "enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"] }, + "MessageKeyboardFocusConfigResponse": { + "type": "object", + "properties": { + "enabled": {"default": false, "type": "boolean"}, + "config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000}, + "rollout_salt": {"default": "message-keyboard-focus-v1", "type": "string", "minLength": 1, "maxLength": 64}, + "included_user_ids": { + "default": [], + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + }, + "excluded_user_ids": { + "default": [], + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + } + }, + "required": [ + "enabled", + "config_version", + "rollout_basis_points", + "rollout_salt", + "included_user_ids", + "excluded_user_ids" + ], + "additionalProperties": false + }, "MessageHoverTrackingConfigResponse": { "type": "object", "properties": { diff --git a/fluxer_admin/src/api/types/instance_config.rs b/fluxer_admin/src/api/types/instance_config.rs index cc0958f44..a9e121733 100644 --- a/fluxer_admin/src/api/types/instance_config.rs +++ b/fluxer_admin/src/api/types/instance_config.rs @@ -26,6 +26,8 @@ pub struct InstanceConfigResponse { pub experiment_delivery: ExperimentDeliveryConfigResponse, #[serde(default)] pub message_hover_tracking: MessageHoverTrackingConfigResponse, + #[serde(default)] + pub message_keyboard_focus: MessageKeyboardFocusConfigResponse, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -577,6 +579,44 @@ pub struct MessageHoverTrackingConfigUpdateRequest { pub excluded_user_ids: Option>, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub struct MessageKeyboardFocusConfigResponse { + pub enabled: bool, + pub config_version: u64, + pub rollout_basis_points: u32, + pub rollout_salt: String, + pub included_user_ids: Vec, + pub excluded_user_ids: Vec, +} + +impl Default for MessageKeyboardFocusConfigResponse { + fn default() -> Self { + Self { + enabled: false, + config_version: 0, + rollout_basis_points: 0, + rollout_salt: "message-keyboard-focus-v1".to_owned(), + included_user_ids: Vec::new(), + excluded_user_ids: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct MessageKeyboardFocusConfigUpdateRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rollout_basis_points: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rollout_salt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub included_user_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub excluded_user_ids: Option>, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(default)] pub struct ExperimentDeliveryConfigResponse { @@ -696,6 +736,8 @@ pub struct InstanceConfigUpdateRequest { pub experiment_delivery: Option, #[serde(skip_serializing_if = "Option::is_none")] pub message_hover_tracking: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message_keyboard_focus: Option, } #[derive(Clone, Debug, Default, Serialize)] @@ -1032,10 +1074,14 @@ mod tests { .expect("default delivery config"); let hover = serde_json::from_value::(json!({})) .expect("default message hover tracking config"); + let keyboard = serde_json::from_value::(json!({})) + .expect("default message keyboard focus config"); let noise = serde_json::to_value(noise).expect("serializable noise config"); let delivery = serde_json::to_value(delivery).expect("serializable delivery config"); let hover = serde_json::to_value(hover).expect("serializable message hover tracking config"); + let keyboard = + serde_json::to_value(keyboard).expect("serializable message keyboard focus config"); let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse = serde_json::from_value(noise.clone()).expect("generated noise config contract"); let generated_delivery: generated_types::ExperimentDeliveryConfigResponse = @@ -1043,6 +1089,9 @@ mod tests { let generated_hover: generated_types::MessageHoverTrackingConfigResponse = serde_json::from_value(hover.clone()) .expect("generated message hover tracking config contract"); + let generated_keyboard: generated_types::MessageKeyboardFocusConfigResponse = + serde_json::from_value(keyboard.clone()) + .expect("generated message keyboard focus config contract"); assert_eq!( serde_json::to_value(generated_noise).expect("serializable generated noise config"), noise @@ -1057,10 +1106,16 @@ mod tests { .expect("serializable generated message hover tracking config"), hover ); + assert_eq!( + serde_json::to_value(generated_keyboard) + .expect("serializable generated message keyboard focus config"), + keyboard + ); for (name, value) in [ ("VoiceNoiseSuppressionConfigResponse", noise), ("ExperimentDeliveryConfigResponse", delivery), ("MessageHoverTrackingConfigResponse", hover), + ("MessageKeyboardFocusConfigResponse", keyboard), ] { for (field, value) in value.as_object().expect("config object") { assert_eq!( @@ -1094,6 +1149,29 @@ mod tests { ); } + #[test] + fn message_keyboard_focus_update_preserves_empty_lists_and_omitted_fields() { + let update = MessageKeyboardFocusConfigUpdateRequest { + included_user_ids: Some(Vec::new()), + excluded_user_ids: Some(Vec::new()), + ..Default::default() + }; + let value = serde_json::to_value(update).expect("serializable update"); + serde_json::from_value::( + value.clone(), + ) + .expect("generated update contract"); + assert_eq!( + value, + json!({"included_user_ids": [], "excluded_user_ids": []}) + ); + assert_eq!( + serde_json::to_value(MessageKeyboardFocusConfigUpdateRequest::default()) + .expect("serializable update"), + json!({}) + ); + } + #[test] fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() { let update = VoiceNoiseSuppressionConfigUpdateRequest { diff --git a/fluxer_admin/src/routes/system_actions.rs b/fluxer_admin/src/routes/system_actions.rs index d86824c8a..d75d2f208 100644 --- a/fluxer_admin/src/routes/system_actions.rs +++ b/fluxer_admin/src/routes/system_actions.rs @@ -17,8 +17,9 @@ use crate::{ InstanceMediaUpdateRequest, InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule, - LimitRuleFilters, MessageHoverTrackingConfigUpdateRequest, NoiseSuppressionBackend, - PremiumMode, RegistrationMode, SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, + LimitRuleFilters, MessageHoverTrackingConfigUpdateRequest, + MessageKeyboardFocusConfigUpdateRequest, NoiseSuppressionBackend, PremiumMode, + RegistrationMode, SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride, }, @@ -211,6 +212,10 @@ pub async fn instance_config_post( Ok(update) => instance_config_result(client.update_instance_config(&update).await), Err(message) => FlashData::error(message), }, + "update_message_keyboard_focus" => match build_message_keyboard_focus_update(&form) { + Ok(update) => instance_config_result(client.update_instance_config(&update).await), + Err(message) => FlashData::error(message), + }, "update_experiment_delivery" => match build_experiment_delivery_update(&form) { Ok(update) => instance_config_result(client.update_instance_config(&update).await), Err(message) => FlashData::error(message), @@ -663,6 +668,38 @@ fn build_message_hover_tracking_update( }) } +fn build_message_keyboard_focus_update( + form: &MultiValueForm, +) -> Result { + Ok(InstanceConfigUpdateRequest { + message_keyboard_focus: Some(MessageKeyboardFocusConfigUpdateRequest { + enabled: Some(form.bool_value("message_keyboard_focus_enabled")), + rollout_basis_points: parse_form_number( + form, + "message_keyboard_focus_rollout_basis_points", + "Rollout basis points", + 0, + EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX, + )?, + rollout_salt: parse_experiment_rollout_salt( + form, + "message_keyboard_focus_rollout_salt", + )?, + included_user_ids: Some(parse_experiment_user_ids( + form.first("message_keyboard_focus_included_user_ids") + .unwrap_or_default(), + "Included user IDs", + )?), + excluded_user_ids: Some(parse_experiment_user_ids( + form.first("message_keyboard_focus_excluded_user_ids") + .unwrap_or_default(), + "Excluded user IDs", + )?), + }), + ..Default::default() + }) +} + fn build_experiment_delivery_update( form: &MultiValueForm, ) -> Result { diff --git a/fluxer_admin/src/templates/pages/instance_config.rs b/fluxer_admin/src/templates/pages/instance_config.rs index d03aca97c..353f1349b 100644 --- a/fluxer_admin/src/templates/pages/instance_config.rs +++ b/fluxer_admin/src/templates/pages/instance_config.rs @@ -5,9 +5,10 @@ use crate::{ AppPublicConfigResponse, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse, - MessageHoverTrackingConfigResponse, NoiseSuppressionBackend, PendingRegistrationResponse, - RegistrationUrlResponse, SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, - VOICE_NS_MAX_TARGETED_USERS, VoiceNoiseSuppressionConfigResponse, + MessageHoverTrackingConfigResponse, MessageKeyboardFocusConfigResponse, + NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse, + SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS, + VoiceNoiseSuppressionConfigResponse, }, config::AdminConfig, middleware::auth::AuthContext, @@ -149,6 +150,7 @@ pub fn instance_config_page( (gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout)) (voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression)) (message_hover_tracking_section(base, csrf_token, &instance_config.message_hover_tracking)) + (message_keyboard_focus_section(base, csrf_token, &instance_config.message_keyboard_focus)) (experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery)) @if let Some(limit_config) = limit_config { (limit_config_section(base, limit_config)) @@ -1280,6 +1282,110 @@ fn message_hover_tracking_section( ) } +fn message_keyboard_focus_section( + base: &str, + csrf_token: &str, + message_keyboard_focus: &MessageKeyboardFocusConfigResponse, +) -> Markup { + let status = if message_keyboard_focus.enabled { + ("Live", BadgeVariant::Success) + } else { + ("Inert", BadgeVariant::Default) + }; + let included_user_ids = message_keyboard_focus.included_user_ids.join("\n"); + let excluded_user_ids = message_keyboard_focus.excluded_user_ids.join("\n"); + section_card_with_description( + "Message Keyboard Focus", + "Picks whether targeted clients run the keyboard navigation rework in the message list. \ + A targeted client reaches the message list from the composer with one Tab, walks \ + messages with the arrow keys through revealed blocked groups, and draws the focus ring \ + inside each row. While the master switch below is off every client keeps the keyboard \ + navigation it ships with, whatever the rest of these fields say.", + html! { + form method="post" action={(base) "/instance-config?action=update_message_keyboard_focus"} { + (csrf_input(csrf_token)) + div class="space-y-6" { + div class="flex flex-wrap items-center gap-2" { + h3 class="text-sm font-semibold text-neutral-900" { "Master switch" } + (badge(status.0, status.1)) + span class="text-xs text-neutral-500" { + "Config version " (message_keyboard_focus.config_version) + } + } + (checkbox( + "message_keyboard_focus_enabled", + "true", + "Serve message keyboard focus assignments to clients", + message_keyboard_focus.enabled, + true, + )) + p class="text-xs text-neutral-500" { + "Off is the safe state. With this unchecked every client is told the \ + rollout is inert and keeps its current keyboard navigation, so the rollout \ + and targeting fields below have no effect at all." + } + + h3 class="text-sm font-semibold text-neutral-900" { "Rollout" } + (number_field( + "message_keyboard_focus_rollout_basis_points", + "Rollout (basis points)", + &message_keyboard_focus.rollout_basis_points.to_string(), + Some(0), Some(10000), "1", + Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."), + )) + div class="flex flex-col gap-2" { + (text_input( + "message_keyboard_focus_rollout_salt", + "Rollout Salt", + &message_keyboard_focus.rollout_salt, + "message-keyboard-focus-v1", + )) + p class="text-xs text-neutral-500" { + "Seeds the bucketing hash. Changing it reshuffles which users fall \ + inside the percentage above. Leave it alone to keep the current \ + cohort stable." + } + } + div class="flex flex-col gap-2" { + (textarea_input( + "message_keyboard_focus_included_user_ids", + "Always-on User IDs", + "1500000000000000001\n1500000000000000002", + &included_user_ids, + 4, + false, + )) + p class="text-xs text-neutral-500" { + "One snowflake per line, or comma separated. These users are targeted \ + regardless of the percentage above. IDs must contain 1 to 20 decimal \ + digits. Invalid entries prevent the save; blank entries and duplicate \ + IDs are ignored." + } + } + div class="flex flex-col gap-2" { + (textarea_input( + "message_keyboard_focus_excluded_user_ids", + "Never-on User IDs", + "1500000000000000003\n1500000000000000004", + &excluded_user_ids, + 4, + false, + )) + p class="text-xs text-neutral-500" { + "Same format. Exclusion wins over both the always-on list and the \ + percentage, so this is the per-user kill switch." + } + } + + (form_actions(html! { + (submit_button("Save Message Keyboard Focus Configuration")) + })) + } + } + }, + ) +} + fn experiment_delivery_section( base: &str, csrf_token: &str, diff --git a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts index 94320bbfd..acac8f7a2 100644 --- a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts @@ -33,6 +33,7 @@ import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admi import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas'; import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas'; import {MessageHoverTrackingConfigSchema} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas'; +import {MessageKeyboardFocusConfigSchema} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas'; import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider'; import type {Context} from 'hono'; @@ -60,6 +61,7 @@ async function buildInstanceConfigResponse(): Promise { voiceNoiseSuppression, experimentDelivery, messageHoverTracking, + messageKeyboardFocus, registrationConfig, registrationUrls, pendingRegistrations, @@ -69,6 +71,7 @@ async function buildInstanceConfigResponse(): Promise { instanceConfigRepository.getVoiceNoiseSuppressionConfig(), instanceConfigRepository.getExperimentDeliveryConfig(), instanceConfigRepository.getMessageHoverTrackingConfig(), + instanceConfigRepository.getMessageKeyboardFocusConfig(), instanceConfigRepository.getRegistrationConfig(), instanceConfigRepository.getRegistrationUrlsForAdmin(), instanceConfigRepository.getPendingRegistrations(), @@ -101,6 +104,7 @@ async function buildInstanceConfigResponse(): Promise { voice_noise_suppression: voiceNoiseSuppression, experiment_delivery: experimentDelivery, message_hover_tracking: messageHoverTracking, + message_keyboard_focus: messageKeyboardFocus, registration: { ...registrationConfig, urls: registrationUrls, @@ -256,6 +260,18 @@ export function InstanceConfigAdminController(app: HonoApp) { await instanceConfigRepository.setMessageHoverTrackingConfig(validated); } } + if (data.message_keyboard_focus) { + const patch = omitUndefinedFields(data.message_keyboard_focus); + if (Object.keys(patch).length > 0) { + const currentMessageKeyboardFocus = await instanceConfigRepository.getMessageKeyboardFocusConfig(); + const validated = MessageKeyboardFocusConfigSchema.parse({ + ...currentMessageKeyboardFocus, + ...patch, + config_version: currentMessageKeyboardFocus.config_version + 1, + }); + await instanceConfigRepository.setMessageKeyboardFocusConfig(validated); + } + } if (data.experiment_delivery) { const currentExperimentDelivery = await instanceConfigRepository.getExperimentDeliveryConfig(); const validated = ExperimentDeliveryConfigSchema.parse({ diff --git a/fluxer_api/src/api/experiment/ExperimentController.ts b/fluxer_api/src/api/experiment/ExperimentController.ts index 01b198fcd..aa7e9f861 100644 --- a/fluxer_api/src/api/experiment/ExperimentController.ts +++ b/fluxer_api/src/api/experiment/ExperimentController.ts @@ -11,6 +11,7 @@ import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers'; import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas'; import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas'; import {resolveMessageHoverTrackingAssignment} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas'; +import {resolveMessageKeyboardFocusAssignment} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; export function ExperimentController(app: HonoApp) { app.get( @@ -29,10 +30,11 @@ export function ExperimentController(app: HonoApp) { }), async (ctx) => { const instanceConfigRepository = ctx.get('instanceConfigRepository'); - const [delivery, voiceConfig, messageHoverTrackingConfig] = await Promise.all([ + const [delivery, voiceConfig, messageHoverTrackingConfig, messageKeyboardFocusConfig] = await Promise.all([ instanceConfigRepository.getExperimentDeliveryConfig(), instanceConfigRepository.getVoiceNoiseSuppressionConfig(), instanceConfigRepository.getMessageHoverTrackingConfig(), + instanceConfigRepository.getMessageKeyboardFocusConfig(), ]); const userId = ctx.get('user').id.toString(); const body: ExperimentAssignmentsResponse = { @@ -41,6 +43,7 @@ export function ExperimentController(app: HonoApp) { assignments: { voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId), message_hover_tracking: resolveMessageHoverTrackingAssignment(messageHoverTrackingConfig, userId), + message_keyboard_focus: resolveMessageKeyboardFocusAssignment(messageKeyboardFocusConfig, userId), }, }; const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`; diff --git a/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts b/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts index 379e3019f..a1c2ffefd 100644 --- a/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts +++ b/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts @@ -16,12 +16,17 @@ import { type ExperimentAssignmentsResponse, type ExperimentDeliveryConfigResponse, readMessageHoverTrackingAssignment, + readMessageKeyboardFocusAssignment, readVoiceNoiseSuppressionAssignment, } from '@fluxer/schema/src/domains/experiment/ExperimentSchemas'; import { DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG, INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT, } from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas'; +import { + DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG, + INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT, +} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; const NOT_MODIFIED = 304; @@ -57,6 +62,7 @@ describe('GET /experiments', () => { assignments: { voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT, message_hover_tracking: INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT, + message_keyboard_focus: INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT, }, }); }); @@ -134,17 +140,69 @@ describe('GET /experiments', () => { }); }); - it('resolves the two experiments independently', async () => { + it('populates the message keyboard focus key even when the rollout is disabled', async () => { + const account = await createTestAccount(harness); + + const body = await createBuilder(harness, account.token).get(ENDPOINT).execute(); + + expect(Object.hasOwn(body.assignments, 'message_keyboard_focus')).toBe(true); + expect(readMessageKeyboardFocusAssignment(body)).toEqual(INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT); + }); + + it('targets an allowlisted account for message keyboard focus', async () => { + const account = await createTestAccount(harness); + await getInstanceConfigRepository().setMessageKeyboardFocusConfig({ + ...DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG, + enabled: true, + config_version: 4, + included_user_ids: [account.userId], + }); + + const body = await createBuilder(harness, account.token).get(ENDPOINT).execute(); + + expect(readMessageKeyboardFocusAssignment(body)).toEqual({ + enabled: true, + config_version: 4, + user_targeted: true, + source: 'user_rule', + }); + }); + + it('leaves an account outside a zero-width message keyboard focus rollout', async () => { + const account = await createTestAccount(harness); + await getInstanceConfigRepository().setMessageKeyboardFocusConfig({ + ...DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG, + enabled: true, + config_version: 2, + }); + + const body = await createBuilder(harness, account.token).get(ENDPOINT).execute(); + + expect(readMessageKeyboardFocusAssignment(body)).toEqual({ + enabled: true, + config_version: 2, + user_targeted: false, + source: null, + }); + }); + + it('resolves all three experiments independently', async () => { const account = await createTestAccount(harness); await getInstanceConfigRepository().setMessageHoverTrackingConfig({ ...DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG, enabled: true, rollout_basis_points: 10000, }); + await getInstanceConfigRepository().setMessageKeyboardFocusConfig({ + ...DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG, + enabled: true, + rollout_basis_points: 10000, + }); const body = await createBuilder(harness, account.token).get(ENDPOINT).execute(); expect(readMessageHoverTrackingAssignment(body).user_targeted).toBe(true); + expect(readMessageKeyboardFocusAssignment(body).user_targeted).toBe(true); expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false); }); diff --git a/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts b/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts index 97124827c..32aa27258 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts @@ -21,10 +21,15 @@ import { DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG, type MessageHoverTrackingConfig, } from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas'; +import { + DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG, + type MessageKeyboardFocusConfig, +} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; import {afterEach, describe, expect, it, vi} from 'vitest'; const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config'; const MESSAGE_HOVER_TRACKING_CONFIG_KEY = 'message_hover_tracking_config'; +const MESSAGE_KEYBOARD_FOCUS_CONFIG_KEY = 'message_keyboard_focus_config'; const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config'; const APP_PUBLIC_CONFIG_KEY = 'app_public_config'; const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config'; @@ -362,6 +367,51 @@ describe('InstanceConfigRepository', () => { await expect(repository.getMessageHoverTrackingConfig()).resolves.toEqual(config); }); + it('returns the default message keyboard focus config when the key is absent', async () => { + const executor = new CountingInMemoryCassandraQueryExecutor(); + setCassandraQueryExecutorForTesting(executor); + const kvProvider = new MockKVProvider(); + const repository = createRepository(kvProvider); + + await expect(repository.getMessageKeyboardFocusConfig()).resolves.toEqual(DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG); + }); + + it.each([ + {name: 'unparseable text', stored: 'not-json'}, + {name: 'a json array', stored: '[]'}, + {name: 'out-of-range values', stored: '{"rollout_basis_points":99999}'}, + {name: 'a target that is not a snowflake', stored: '{"included_user_ids":["nope"]}'}, + ])('falls back to the default message keyboard focus config for $name', async ({stored}) => { + const executor = new CountingInMemoryCassandraQueryExecutor(); + setCassandraQueryExecutorForTesting(executor); + const kvProvider = new MockKVProvider(); + const repository = createRepository(kvProvider); + + await repository.setConfig(MESSAGE_KEYBOARD_FOCUS_CONFIG_KEY, stored); + + await expect(repository.getMessageKeyboardFocusConfig()).resolves.toEqual(DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG); + }); + + it('round-trips a stored message keyboard focus config', async () => { + const executor = new CountingInMemoryCassandraQueryExecutor(); + setCassandraQueryExecutorForTesting(executor); + const kvProvider = new MockKVProvider(); + const repository = createRepository(kvProvider); + + const config: MessageKeyboardFocusConfig = { + ...DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG, + enabled: true, + config_version: 5, + rollout_basis_points: 2500, + rollout_salt: 'message-keyboard-focus-v2', + included_user_ids: ['1400000000000000001'], + excluded_user_ids: ['1400000000000000002'], + }; + await repository.setMessageKeyboardFocusConfig(config); + + await expect(repository.getMessageKeyboardFocusConfig()).resolves.toEqual(config); + }); + it('fills newly added voice noise suppression fields from the schema defaults', async () => { const executor = new CountingInMemoryCassandraQueryExecutor(); setCassandraQueryExecutorForTesting(executor); diff --git a/fluxer_api/src/api/instance/InstanceConfigRepository.ts b/fluxer_api/src/api/instance/InstanceConfigRepository.ts index c8acadf20..5ba9cb5b8 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.ts @@ -40,6 +40,10 @@ import { type MessageHoverTrackingConfig, MessageHoverTrackingConfigSchema, } from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas'; +import { + type MessageKeyboardFocusConfig, + MessageKeyboardFocusConfigSchema, +} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; import { type InstanceAppPublic, InstanceAppPublicSchema, @@ -60,6 +64,7 @@ const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config'; const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config'; const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config'; const MESSAGE_HOVER_TRACKING_CONFIG_KEY = 'message_hover_tracking_config'; +const MESSAGE_KEYBOARD_FOCUS_CONFIG_KEY = 'message_keyboard_focus_config'; const REGISTRATION_CONFIG_KEY = 'registration_config'; const REGISTRATION_URLS_KEY = 'registration_urls'; const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals'; @@ -344,6 +349,7 @@ type StoredConfigSection = | 'voice noise suppression' | 'experiment delivery' | 'message hover tracking' + | 'message keyboard focus' | 'instance policy' | 'integrations' | 'media' @@ -489,6 +495,10 @@ function parseStoredMessageHoverTrackingConfig(raw: string | null): MessageHover return parseStoredConfigOrDefault(MessageHoverTrackingConfigSchema, raw, 'message hover tracking'); } +function parseStoredMessageKeyboardFocusConfig(raw: string | null): MessageKeyboardFocusConfig { + return parseStoredConfigOrDefault(MessageKeyboardFocusConfigSchema, raw, 'message keyboard focus'); +} + function validateStoredCollection(schema: z.ZodType, value: unknown, section: StoredConfigSection): Array { if (!Array.isArray(value)) { throw new Error(`Stored ${section} configuration must be an array`); @@ -1009,6 +1019,7 @@ export class InstanceConfigRepository { parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null); parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null); parseStoredMessageHoverTrackingConfig(snapshot.get(MESSAGE_HOVER_TRACKING_CONFIG_KEY) ?? null); + parseStoredMessageKeyboardFocusConfig(snapshot.get(MESSAGE_KEYBOARD_FOCUS_CONFIG_KEY) ?? null); const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null); checkStoredConfig('registration', () => parseStoredRegistrationConfig(snapshot.get(REGISTRATION_CONFIG_KEY) ?? null), @@ -1104,6 +1115,16 @@ export class InstanceConfigRepository { await this.setConfig(MESSAGE_HOVER_TRACKING_CONFIG_KEY, JSON.stringify(validated)); } + async getMessageKeyboardFocusConfig(): Promise { + const raw = await this.getConfig(MESSAGE_KEYBOARD_FOCUS_CONFIG_KEY); + return parseStoredMessageKeyboardFocusConfig(raw); + } + + async setMessageKeyboardFocusConfig(config: MessageKeyboardFocusConfig): Promise { + const validated = validateStoredConfig(MessageKeyboardFocusConfigSchema, config, 'message keyboard focus'); + await this.setConfig(MESSAGE_KEYBOARD_FOCUS_CONFIG_KEY, JSON.stringify(validated)); + } + async setExperimentDeliveryConfig(config: ExperimentDeliveryConfig): Promise { const validated = validateStoredConfig(ExperimentDeliveryConfigSchema, config, 'experiment delivery'); await this.setConfig(EXPERIMENT_DELIVERY_CONFIG_KEY, JSON.stringify(validated)); diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index c302f5491..17aa3adfd 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -28259,7 +28259,8 @@ "type": "object", "properties": { "voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"}, - "message_hover_tracking": {"$ref": "#/components/schemas/MessageHoverTrackingAssignmentResponse"} + "message_hover_tracking": {"$ref": "#/components/schemas/MessageHoverTrackingAssignmentResponse"}, + "message_keyboard_focus": {"$ref": "#/components/schemas/MessageKeyboardFocusAssignmentResponse"} }, "additionalProperties": false } @@ -31997,6 +31998,17 @@ "additionalProperties": false }, "DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try"]}, + "MessageKeyboardFocusAssignmentResponse": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "config_version": {"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, + "user_targeted": {"type": "boolean"}, + "source": {"anyOf": [{"type": "string", "enum": ["user_rule", "canary"]}, {"type": "null"}]} + }, + "required": ["enabled", "config_version", "user_targeted", "source"], + "additionalProperties": false + }, "MessageHoverTrackingAssignmentResponse": { "type": "object", "properties": { diff --git a/fluxer_app/src/features/app/components/layout/KeyboardModeListener.ts b/fluxer_app/src/features/app/components/layout/KeyboardModeListener.ts index 18528b236..c884a6185 100644 --- a/fluxer_app/src/features/app/components/layout/KeyboardModeListener.ts +++ b/fluxer_app/src/features/app/components/layout/KeyboardModeListener.ts @@ -2,6 +2,7 @@ import {Routes} from '@app/app/Routes'; import {CHANNEL_TEXTAREA_SELECTOR} from '@app/features/app/keybindings/utils/EditableElement'; +import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout'; import {useLocation} from '@app/features/platform/components/router/RouterReact'; import {ComponentBus} from '@app/features/platform/utils/ComponentBus'; import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager'; @@ -11,7 +12,7 @@ import { recordPointerActivationFocusTarget, } from '@app/features/ui/utils/PointerActivationFocus'; import {observer} from 'mobx-react-lite'; -import {useEffect, useMemo} from 'react'; +import {useEffect, useLayoutEffect, useMemo} from 'react'; const FOCUS_TRAPPING_OVERLAY_SELECTOR = [ '[role="dialog"]', @@ -86,9 +87,15 @@ export const KeyboardModeListener = observer(() => { window.removeEventListener('pointerdown', handlePointer, true); }; }, [isAuthRoute]); - useEffect(() => { + const keyboardNavigationEnabled = MessageKeyboardFocusRollout.enabled; + useLayoutEffect(() => { + if (!keyboardNavigationEnabled) return; FocusRingManager.setRingsEnabled(keyboardModeEnabled); - }, [keyboardModeEnabled]); + }, [keyboardModeEnabled, keyboardNavigationEnabled]); + useEffect(() => { + if (keyboardNavigationEnabled) return; + FocusRingManager.setRingsEnabled(keyboardModeEnabled); + }, [keyboardModeEnabled, keyboardNavigationEnabled]); useEffect(() => { const pendingFrames = new Set(); const handlePointerActivation = (event: MouseEvent) => { diff --git a/fluxer_app/src/features/channel/components/BlockedMessageGroups.tsx b/fluxer_app/src/features/channel/components/BlockedMessageGroups.tsx index d008c9da9..dc806f760 100644 --- a/fluxer_app/src/features/channel/components/BlockedMessageGroups.tsx +++ b/fluxer_app/src/features/channel/components/BlockedMessageGroups.tsx @@ -9,12 +9,15 @@ import { } from '@app/features/channel/components/MessageGroup'; import type {Channel} from '@app/features/channel/models/Channel'; 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'; +import {getMessageSelector} from '@app/features/messaging/utils/MessageNodeSelectors'; +import KeyboardMode from '@app/features/ui/state/KeyboardMode'; import type {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; import {clsx} from 'clsx'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef} from 'react'; const MESSAGE_SCROLLER_SELECTOR = '[data-fluxer-scroll-container="true"]'; const SCROLLER_BOTTOM_EPSILON = 1; @@ -99,7 +102,13 @@ export const BlockedMessageGroups = React.memo((props } = props; const {i18n} = useLingui(); const containerRef = useRef(null); + const toggleRef = useRef(null); + const contentRef = useRef(null); const scrollToBottomFrameRef = useRef(null); + const wasRevealedRef = useRef(revealed); + const revealedByKeyboardRef = useRef(false); + const focusWithinContentRef = useRef(false); + const contentId = useId(); const messageSummary = useMemo(() => { let firstMessageId: string | null = null; let totalMessageCount = 0; @@ -123,34 +132,93 @@ export const BlockedMessageGroups = React.memo((props scroller.scrollTop = scroller.scrollHeight; }); }, []); - const handleClick = useCallback(() => { - const container = containerRef.current; - const scroller = container?.closest(MESSAGE_SCROLLER_SELECTOR) as HTMLElement | null; - if (scroller) { - const wasAtBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < SCROLLER_BOTTOM_EPSILON; - if (revealed) { - onReveal(null); - if (wasAtBottom) { - scheduleScrollToBottom(scroller); - } - } else { - if (messageSummary.firstMessageId) { - onReveal(messageSummary.firstMessageId); + const handleClick = useCallback( + (event: React.MouseEvent) => { + revealedByKeyboardRef.current = event.detail === 0; + const container = containerRef.current; + const scroller = container?.closest(MESSAGE_SCROLLER_SELECTOR) as HTMLElement | null; + if (scroller) { + const wasAtBottom = + scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < SCROLLER_BOTTOM_EPSILON; + if (revealed) { + onReveal(null); if (wasAtBottom) { scheduleScrollToBottom(scroller); } + } else { + if (messageSummary.firstMessageId) { + onReveal(messageSummary.firstMessageId); + if (wasAtBottom) { + scheduleScrollToBottom(scroller); + } + } } - } - } else { - if (revealed) { - onReveal(null); } else { - if (messageSummary.firstMessageId) { - onReveal(messageSummary.firstMessageId); + if (revealed) { + onReveal(null); + } else { + if (messageSummary.firstMessageId) { + onReveal(messageSummary.firstMessageId); + } } } + }, + [messageSummary.firstMessageId, onReveal, revealed, scheduleScrollToBottom], + ); + useEffect(() => { + const container = containerRef.current; + if (container == null) { + return; } - }, [messageSummary.firstMessageId, onReveal, revealed, scheduleScrollToBottom]); + const isInsideContent = (node: EventTarget | null): boolean => + node instanceof Node && contentRef.current?.contains(node) === true; + const handleFocusIn = (event: FocusEvent) => { + focusWithinContentRef.current = isInsideContent(event.target); + }; + const handleFocusOut = (event: FocusEvent) => { + if (isInsideContent(event.relatedTarget)) { + return; + } + focusWithinContentRef.current = false; + }; + container.addEventListener('focusin', handleFocusIn); + container.addEventListener('focusout', handleFocusOut); + return () => { + container.removeEventListener('focusin', handleFocusIn); + container.removeEventListener('focusout', handleFocusOut); + }; + }, []); + useLayoutEffect(() => { + const wasRevealed = wasRevealedRef.current; + wasRevealedRef.current = revealed; + if (wasRevealed === revealed) { + return; + } + const revealedByKeyboard = revealedByKeyboardRef.current; + revealedByKeyboardRef.current = false; + if (!KeyboardMode.keyboardModeEnabled || !MessageKeyboardFocusRollout.enabled) { + focusWithinContentRef.current = false; + return; + } + if (revealed) { + if (!revealedByKeyboard) { + return; + } + const firstMessage = contentRef.current?.querySelector(getMessageSelector(channel.id)); + if (firstMessage == null) { + return; + } + if (firstMessage.tabIndex < 0) { + firstMessage.tabIndex = -1; + } + firstMessage.focus({preventScroll: true}); + return; + } + if (focusWithinContentRef.current) { + focusWithinContentRef.current = false; + toggleRef.current?.focus({preventScroll: true}); + } + }, [channel.id, revealed]); useEffect(() => { return () => { if (scrollToBottomFrameRef.current != null) { @@ -247,9 +315,12 @@ export const BlockedMessageGroups = React.memo((props /> )} {revealed && ( -
+
{messageNodes}
)} diff --git a/fluxer_app/src/features/channel/components/ChannelMessage.tsx b/fluxer_app/src/features/channel/components/ChannelMessage.tsx index db2ff8dfe..8532ee624 100644 --- a/fluxer_app/src/features/channel/components/ChannelMessage.tsx +++ b/fluxer_app/src/features/channel/components/ChannelMessage.tsx @@ -15,6 +15,7 @@ import {MarkdownContext} from '@app/features/messaging/components/markdown/rende import type {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage'; import MessageEdit from '@app/features/messaging/state/MessageEdit'; import MessageFocus from '@app/features/messaging/state/MessageFocus'; +import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout'; import MessageReply from '@app/features/messaging/state/MessageReply'; import {getMessageComponent} from '@app/features/messaging/utils/MessageComponentUtils'; import {renderAstToPlaintext} from '@app/features/messaging/utils/markdown/Plaintext'; @@ -483,6 +484,7 @@ export const Message: React.FC = observer((props) => { handlePopoutToggle, trackingEnabled: hoverTrackingEnabled, } = useMessageHoverState({messageRef, mobileLayoutEnabled, keyboardModeEnabled, contextMenuOpen}); + const keyboardNavigationEnabled = MessageKeyboardFocusRollout.enabled; const handleFocusWithin = useCallback(() => { if (!keyboardModeEnabled) { return; @@ -512,6 +514,17 @@ export const Message: React.FC = observer((props) => { MessageFocus.clearFocusedMessageIfMatches(channel.id, message.id); } }, [channel, contextMenuOpen, isFocusedWithin, keyboardModeEnabled, message, message.id]); + const isFocusedWithinRef = useRef(isFocusedWithin); + isFocusedWithinRef.current = isFocusedWithin; + const keyboardNavigationEnabledRef = useRef(keyboardNavigationEnabled); + keyboardNavigationEnabledRef.current = keyboardNavigationEnabled; + useEffect(() => { + return () => { + if (keyboardNavigationEnabledRef.current && isFocusedWithinRef.current) { + MessageFocus.clearFocusedMessageIfMatches(channel.id, message.id); + } + }; + }, [channel.id, message.id]); useEffect(() => { const wasEditing = wasEditingInPreviousUpdateRef.current; const justStartedEditing = !wasEditing && isEditing; @@ -689,7 +702,12 @@ export const Message: React.FC = observer((props) => { ); return ( <> - +
{ + it('picks the bottom-most mostly visible message', () => { + const result = resolveBottommostFocusableMessageId( + [candidate('a', 10, 100), candidate('b', 150, 100), candidate('c', 300, 100)], + VIEWPORT_TOP, + VIEWPORT_BOTTOM, + ); + expect(result).toBe('c'); + }); + + it('skips a message that is mostly scrolled past the bottom edge', () => { + const result = resolveBottommostFocusableMessageId( + [candidate('a', 10, 100), candidate('b', 150, 100), candidate('c', 550, 100)], + VIEWPORT_TOP, + VIEWPORT_BOTTOM, + ); + expect(result).toBe('b'); + }); + + it('falls back to the most visible message when nothing clears the visibility threshold', () => { + const result = resolveBottommostFocusableMessageId( + [candidate('a', -900, 1000), candidate('b', 200, 1000)], + VIEWPORT_TOP, + VIEWPORT_BOTTOM, + ); + expect(result).toBe('b'); + }); + + it('prefers the most visible message over the last one in the DOM below the viewport', () => { + const result = resolveBottommostFocusableMessageId( + [candidate('tall', -100, 1000), candidate('offscreen', 2000, 50)], + VIEWPORT_TOP, + VIEWPORT_BOTTOM, + ); + expect(result).toBe('tall'); + }); + + it('breaks a tie on visible height by taking the bottom-most message', () => { + const result = resolveBottommostFocusableMessageId( + [candidate('a', -800, 900), candidate('b', 500, 900)], + VIEWPORT_TOP, + VIEWPORT_BOTTOM, + ); + expect(result).toBe('b'); + }); + + it('falls back to the last message when nothing overlaps the viewport at all', () => { + const result = resolveBottommostFocusableMessageId( + [candidate('a', 900, 100), candidate('b', 1200, 100)], + VIEWPORT_TOP, + VIEWPORT_BOTTOM, + ); + expect(result).toBe('b'); + }); + + it('falls back to the last message when the newest one is hidden behind the composer', () => { + const result = resolveBottommostFocusableMessageId([candidate('only', 400, 400)], VIEWPORT_TOP, VIEWPORT_BOTTOM); + expect(result).toBe('only'); + }); + + it('ignores zero-height rows when scoring visibility', () => { + expect( + resolveBottommostFocusableMessageId( + [candidate('a', 10, 100), candidate('b', 200, 0)], + VIEWPORT_TOP, + VIEWPORT_BOTTOM, + ), + ).toBe('a'); + }); + + it('returns null when there are no candidates', () => { + expect(resolveBottommostFocusableMessageId([], VIEWPORT_TOP, VIEWPORT_BOTTOM)).toBeNull(); + }); +}); diff --git a/fluxer_app/src/features/channel/components/ChannelMessageStream.tsx b/fluxer_app/src/features/channel/components/ChannelMessageStream.tsx index f60189443..776bfe066 100644 --- a/fluxer_app/src/features/channel/components/ChannelMessageStream.tsx +++ b/fluxer_app/src/features/channel/components/ChannelMessageStream.tsx @@ -9,6 +9,7 @@ import type {Channel} from '@app/features/channel/models/Channel'; import type {Message} from '@app/features/messaging/models/MessagingMessage'; import type {ChannelMessages} from '@app/features/messaging/state/ChannelMessages'; import {type ChannelStreamItem, ChannelStreamType} from '@app/features/messaging/utils/MessageGroupingUtils'; +import {CHANNEL_MESSAGE_ID_PREFIX} from '@app/features/messaging/utils/MessageNodeSelectors'; import {IS_DEV} from '@app/features/platform/types/Env'; import {Logger} from '@app/features/platform/utils/AppLogger'; import type {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants'; @@ -126,7 +127,7 @@ export function renderChannelStream(props: RenderChannelStreamProps): Array('[data-message-id]'); + const messageElements = innerElement.querySelectorAll( + MessageKeyboardFocusRollout.enabled ? getMessageSelector(channel.id) : '[data-message-id]', + ); if (!messageElements.length) return; const scrollerRect = scroller.getBoundingClientRect(); const candidates: Array = []; diff --git a/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx b/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx index aa322cb83..94fdf725c 100644 --- a/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx +++ b/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx @@ -87,6 +87,7 @@ import { } from '@app/features/messaging/state/MentionConfirmationStateMachine'; import MessageEdit from '@app/features/messaging/state/MessageEdit'; import MessageEditMobile from '@app/features/messaging/state/MessageEditMobile'; +import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout'; import MessageReply from '@app/features/messaging/state/MessageReply'; import Messages from '@app/features/messaging/state/MessagingMessages'; import {CloudUpload} from '@app/features/messaging/upload/CloudUpload'; @@ -885,15 +886,17 @@ export const LexicalChannelTextareaContent = observer( onSubmit(); }, [canSubmit, channel, hasAttachments, onSubmit]); const handleArrowUpEmpty = useCallback(() => { + const claimsArrowUp = MessageKeyboardFocusRollout.enabled; if (KeyboardMode.keyboardModeEnabled) { ComponentBus.dispatch('FOCUS_BOTTOMMOST_MESSAGE', {channelId: channel.id}); - return; + return claimsArrowUp; } const message = Messages.getLastEditableMessage(channel.id); if (!message) { - return; + return false; } MessageCommands.startEdit(channel.id, message.id, message.content); + return claimsArrowUp; }, [channel.id]); useTextareaDraftAndTyping({ channelId: channel.id, diff --git a/fluxer_app/src/features/channel/components/MessageFocusRingContract.test.ts b/fluxer_app/src/features/channel/components/MessageFocusRingContract.test.ts new file mode 100644 index 000000000..86f429968 --- /dev/null +++ b/fluxer_app/src/features/channel/components/MessageFocusRingContract.test.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {readFileSync} from 'node:fs'; +import {fileURLToPath} from 'node:url'; +import {describe, expect, it} from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8'); +} + +const messageCss = readSource('../../theme/styles/Message.module.css'); +const actionBarCss = readSource('./MessageActionBar.module.css'); +const focusRingCss = readSource('../../ui/focus_ring/FocusRing.module.css'); +const channelMessageSource = readSource('./ChannelMessage.tsx'); +const messageFocusRing = channelMessageSource.match(/]*>/)?.[0] ?? ''; + +describe('message focus ring contract', () => { + it('never draws a ring from a bare :focus selector on a message row', () => { + expect(messageCss).not.toMatch(/\.message(Compact)?:focus(?!-visible)/); + }); + + it('routes the row ring through the FocusRing framework', () => { + expect(channelMessageSource).toMatch(/from '@app\/features\/ui\/focus_ring\/FocusRing'/); + expect(messageFocusRing).not.toBe(''); + }); + + it('reads the ring arm from the keyboard navigation rollout', () => { + expect(channelMessageSource).toMatch(/const keyboardNavigationEnabled = MessageKeyboardFocusRollout\.enabled;/); + }); + + it('only enables the ring in keyboard navigation mode in the experiment arm', () => { + expect(messageFocusRing).toMatch(/enabled=\{keyboardNavigationEnabled \? keyboardModeEnabled : undefined\}/); + expect(messageFocusRing).toMatch(/within=\{keyboardNavigationEnabled\}/); + }); + + it('insets the ring inside the row in the experiment arm and keeps the default geometry in control', () => { + expect(focusRingCss).toMatch(/pointer-events:\s*none/); + expect(messageFocusRing).toMatch(/offset=\{keyboardNavigationEnabled \? -2 : undefined\}/); + }); + + it('stacks the ring below the action bar', () => { + expect(actionBarCss).toMatch(/z-index:\s*var\(--z-index-elevated-1\)/); + }); + + it('falls back to a system outline under forced colors', () => { + const forcedColors = focusRingCss.match(/@media \(forced-colors: active\) \{\n\t\.focusRing \{([^{}]*)\}/)?.[1]; + expect(forcedColors).toBeDefined(); + expect(forcedColors).toMatch(/box-shadow:\s*none/); + expect(forcedColors).toMatch(/outline-color:\s*Highlight/); + }); +}); diff --git a/fluxer_app/src/features/channel/components/useChannelComposerDraftFocusRestore.test.tsx b/fluxer_app/src/features/channel/components/useChannelComposerDraftFocusRestore.test.tsx new file mode 100644 index 000000000..18d04c690 --- /dev/null +++ b/fluxer_app/src/features/channel/components/useChannelComposerDraftFocusRestore.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle'; +import {act, useRef} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; + +const {canFocusTextareaMock} = vi.hoisted(() => ({canFocusTextareaMock: vi.fn(() => true)})); + +vi.mock('@app/features/platform/utils/InputFocusManager', () => ({ + canFocusTextarea: canFocusTextareaMock, +})); + +const {useChannelComposerDraftFocusRestore} = await import( + '@app/features/channel/components/useChannelComposerDraftFocusRestore' +); + +(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; +let focusSpy: ReturnType; + +interface HarnessProps { + initialDraft: string; + textareaInputDisabled?: boolean; + inlineEditActive?: boolean; +} + +function Harness({initialDraft, textareaInputDisabled = false, inlineEditActive = false}: HarnessProps) { + const handleRef = useRef(null); + handleRef.current = {focus: focusSpy} as unknown as ComposerHandle; + const editableRef = useRef(null); + useChannelComposerDraftFocusRestore({ + handleRef, + editableRef, + initialDraft, + textareaInputDisabled, + inlineEditActive, + }); + return ( +
+ ); +} + +function render(props: HarnessProps): void { + act(() => { + root.render(); + }); +} + +beforeEach(() => { + focusSpy = vi.fn(); + canFocusTextareaMock.mockReturnValue(true); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + canFocusTextareaMock.mockReset(); +}); + +describe('useChannelComposerDraftFocusRestore', () => { + test('focuses the composer when the channel is entered with a pending draft', () => { + render({initialDraft: 'half written'}); + expect(focusSpy).toHaveBeenCalledTimes(1); + }); + + test('leaves focus alone when there is no pending draft', () => { + render({initialDraft: ''}); + expect(focusSpy).not.toHaveBeenCalled(); + }); + + test('yields to an active inline message edit', () => { + render({initialDraft: 'half written', inlineEditActive: true}); + expect(focusSpy).not.toHaveBeenCalled(); + }); + + test('does nothing when composer input is disabled', () => { + render({initialDraft: 'half written', textareaInputDisabled: true}); + expect(focusSpy).not.toHaveBeenCalled(); + }); + + test('respects the shared focus guard that blocks mobile, modals and popouts', () => { + canFocusTextareaMock.mockReturnValue(false); + render({initialDraft: 'half written'}); + expect(focusSpy).not.toHaveBeenCalled(); + }); + + test('restores focus only on the channel mount, not on every re-render', () => { + render({initialDraft: 'half written'}); + render({initialDraft: 'half written'}); + render({initialDraft: 'half written more'}); + expect(focusSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx b/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx index e6056f9aa..bcf95447b 100644 --- a/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx +++ b/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx @@ -160,7 +160,7 @@ export interface LexicalComposerInputProps { onChange: (display: string, segments: Array, wire: string) => void; onCursorMove: () => void; onEnter?: () => void; - onArrowUp: () => void; + onArrowUp: () => boolean; onKeyDown?: (event: React.KeyboardEvent) => void; onFocus?: () => void; onBlur?: () => void; @@ -608,8 +608,9 @@ const ComposerInner = ({ return false; } if (event != null && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) { - if ($isComposerEmpty()) { - cb.current.onArrowUp(); + if ($isComposerEmpty() && cb.current.onArrowUp()) { + event.preventDefault(); + return true; } } return false; diff --git a/fluxer_app/src/features/lexical/composer/LexicalRichInput.tsx b/fluxer_app/src/features/lexical/composer/LexicalRichInput.tsx index ba8d75800..dc44e8e51 100644 --- a/fluxer_app/src/features/lexical/composer/LexicalRichInput.tsx +++ b/fluxer_app/src/features/lexical/composer/LexicalRichInput.tsx @@ -58,7 +58,7 @@ export interface LexicalRichInputProps { i18n: I18n; } -const NOOP = (): void => {}; +const ARROW_UP_UNHANDLED = (): boolean => false; const SAFE_CHANNEL_TRIGGERS: Array = ['emoji', 'mention', 'channel']; const SAFE_CONTEXT_FREE_TRIGGERS: Array = ['emoji']; @@ -245,7 +245,7 @@ export const LexicalRichInput = ({ onChange={emitChange} onCursorMove={onCursorMove} onEnter={onSubmit == null ? undefined : handleEnter} - onArrowUp={NOOP} + onArrowUp={ARROW_UP_UNHANDLED} onKeyDown={onKeyDown} onFocus={onFocus} onBlur={onBlur} diff --git a/fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.test.tsx b/fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.test.tsx new file mode 100644 index 000000000..2f481e27e --- /dev/null +++ b/fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.test.tsx @@ -0,0 +1,266 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {CHANNEL_MESSAGE_ID_PREFIX, findMessageElement} from '@app/features/messaging/utils/MessageNodeSelectors'; +import {act, createElement, type RefObject} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const keyboardModeMock = {keyboardModeEnabled: true}; +const messageFocusMock = {focusedMessageId: null as string | null}; +const rolloutMock = {enabled: true}; + +vi.mock('@app/features/ui/state/KeyboardMode', () => ({default: keyboardModeMock})); +vi.mock('@app/features/messaging/state/MessageFocus', () => ({default: messageFocusMock})); +vi.mock('@app/features/messaging/state/MessageKeyboardFocusRollout', () => ({default: rolloutMock})); + +const {useMessageListKeyboardNavigation} = await import( + '@app/features/messaging/hooks/useMessageListKeyboardNavigation' +); + +(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true; + +const CHANNEL_ID = '900000000000000001'; + +interface RowSpec { + messageId: string; + idPrefix: string; +} + +let viewport: HTMLElement; +let host: HTMLDivElement; +let root: Root; + +function mountRows(specs: ReadonlyArray): void { + for (const spec of specs) { + const row = document.createElement('div'); + row.id = `${spec.idPrefix}-${CHANNEL_ID}-${spec.messageId}`; + row.dataset.messageId = spec.messageId; + row.dataset.channelId = CHANNEL_ID; + row.tabIndex = -1; + viewport.append(row); + } +} + +function focusedRowId(): string | null { + const active = document.activeElement; + return active instanceof HTMLElement ? (active.dataset.messageId ?? null) : null; +} + +function render(onFocusMessage?: (messageId: string) => void): void { + const containerRef: RefObject = {current: viewport}; + function Harness(): null { + useMessageListKeyboardNavigation({containerRef, channelId: CHANNEL_ID, onFocusMessage, allowWhenInactive: true}); + return null; + } + act(() => { + root.render(createElement(Harness)); + }); +} + +function pressArrow(key: 'ArrowUp' | 'ArrowDown'): void { + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', {key, bubbles: true, cancelable: true})); + }); +} + +beforeEach(() => { + keyboardModeMock.keyboardModeEnabled = true; + messageFocusMock.focusedMessageId = null; + rolloutMock.enabled = true; + viewport = document.createElement('div'); + document.body.append(viewport); + viewport.addEventListener('focusin', (event) => { + const target = event.target; + if (target instanceof HTMLElement && target.dataset.messageId) { + messageFocusMock.focusedMessageId = target.dataset.messageId; + } + }); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => { + root.unmount(); + }); + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +describe('useMessageListKeyboardNavigation', () => { + it('walks from the newest message up through a revealed blocked group', () => { + mountRows([ + {messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}, + {messageId: '2', idPrefix: 'blocked-messages'}, + {messageId: '3', idPrefix: 'blocked-messages'}, + {messageId: '4', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}, + ]); + render((messageId) => { + findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true}); + }); + + pressArrow('ArrowUp'); + expect(focusedRowId()).toBe('4'); + pressArrow('ArrowUp'); + expect(focusedRowId()).toBe('3'); + pressArrow('ArrowUp'); + expect(focusedRowId()).toBe('2'); + pressArrow('ArrowUp'); + expect(focusedRowId()).toBe('1'); + }); + + it('walks back down out of a revealed blocked group', () => { + mountRows([ + {messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}, + {messageId: '2', idPrefix: 'blocked-messages'}, + {messageId: '3', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}, + ]); + render((messageId) => { + findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true}); + }); + + pressArrow('ArrowDown'); + expect(focusedRowId()).toBe('1'); + pressArrow('ArrowDown'); + expect(focusedRowId()).toBe('2'); + pressArrow('ArrowDown'); + expect(focusedRowId()).toBe('3'); + }); + + it('keeps navigating when the focus delegate cannot resolve the target element', () => { + mountRows([ + {messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}, + {messageId: '2', idPrefix: 'blocked-messages'}, + {messageId: '3', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}, + ]); + const onFocusMessage = vi.fn<(messageId: string) => void>(); + render(onFocusMessage); + + pressArrow('ArrowUp'); + expect(onFocusMessage).toHaveBeenLastCalledWith('3'); + expect(focusedRowId()).toBe('3'); + pressArrow('ArrowUp'); + expect(onFocusMessage).toHaveBeenLastCalledWith('2'); + expect(focusedRowId()).toBe('2'); + }); + + it('leaves scrolling to the delegate when the delegate did move focus', () => { + mountRows([{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}]); + const scrollIntoView = vi.fn(); + vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(scrollIntoView); + render((messageId) => { + findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true}); + }); + + pressArrow('ArrowUp'); + expect(focusedRowId()).toBe('1'); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it('scrolls the target into view itself when the delegate did nothing', () => { + mountRows([{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}]); + const scrollIntoView = vi.fn(); + vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(scrollIntoView); + render(vi.fn()); + + pressArrow('ArrowUp'); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + }); + + it('keeps navigating while a checkbox inside the list holds focus', () => { + mountRows([ + {messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}, + {messageId: '2', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}, + ]); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + viewport.append(checkbox); + render((messageId) => { + findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true}); + }); + checkbox.focus(); + + pressArrow('ArrowUp'); + expect(focusedRowId()).toBe('2'); + }); + + it('stops navigating while a text input inside the list holds focus', () => { + mountRows([ + {messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}, + {messageId: '2', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}, + ]); + const textInput = document.createElement('input'); + textInput.type = 'text'; + viewport.append(textInput); + render((messageId) => { + findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true}); + }); + textInput.focus(); + + pressArrow('ArrowUp'); + expect(focusedRowId()).toBeNull(); + }); + + it('skips messages that a collapsed group has removed from the DOM', () => { + mountRows([ + {messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}, + {messageId: '4', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}, + ]); + render((messageId) => { + findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true}); + }); + + pressArrow('ArrowUp'); + expect(focusedRowId()).toBe('4'); + pressArrow('ArrowUp'); + expect(focusedRowId()).toBe('1'); + }); +}); + +describe('useMessageListKeyboardNavigation control arm', () => { + beforeEach(() => { + rolloutMock.enabled = false; + }); + + it('stops at a row the focus delegate cannot resolve', () => { + mountRows([ + {messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}, + {messageId: '2', idPrefix: 'blocked-messages'}, + ]); + const onFocusMessage = vi.fn<(messageId: string) => void>(); + render(onFocusMessage); + + pressArrow('ArrowUp'); + expect(onFocusMessage).toHaveBeenLastCalledWith('2'); + expect(focusedRowId()).toBeNull(); + }); + + it('never scrolls the target itself when a delegate is supplied', () => { + mountRows([{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}]); + const scrollIntoView = vi.fn(); + vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(scrollIntoView); + render(vi.fn()); + + pressArrow('ArrowUp'); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it('treats a focused checkbox as editable and stops navigating', () => { + mountRows([ + {messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}, + {messageId: '2', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}, + ]); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + viewport.append(checkbox); + render((messageId) => { + findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true}); + }); + checkbox.focus(); + + pressArrow('ArrowUp'); + expect(focusedRowId()).toBeNull(); + }); +}); diff --git a/fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.ts b/fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.ts index f48352d60..c3f53ae93 100644 --- a/fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.ts +++ b/fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.ts @@ -1,6 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {isEditableElement} from '@app/features/app/keybindings/utils/EditableElement'; import MessageFocus from '@app/features/messaging/state/MessageFocus'; +import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout'; +import {getMessageSelector} from '@app/features/messaging/utils/MessageNodeSelectors'; import type {ScrollerHandle} from '@app/features/ui/components/Scroller'; import KeyboardMode from '@app/features/ui/state/KeyboardMode'; import {type RefObject, useEffect} from 'react'; @@ -52,17 +55,6 @@ const EMPTY_MESSAGE_NODES_SNAPSHOT: MessageNodesSnapshot = { selector: '', ts: 0, }; -const escapeSelectorValue = (value: string): string => { - if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { - return CSS.escape(value); - } - return value.replace(/\\/gu, '\\\\').replace(/"/gu, '\\"'); -}; -const getMessageSelector = (channelId?: string, messageId?: string): string => { - const channelSelector = channelId ? `[data-channel-id="${escapeSelectorValue(channelId)}"]` : '[data-channel-id]'; - const messageSelector = messageId ? `[data-message-id="${escapeSelectorValue(messageId)}"]` : '[data-message-id]'; - return `${channelSelector}${messageSelector}`; -}; export function useMessageListKeyboardNavigation(options: MessageListKeyboardNavigationOptions): void { const { @@ -78,6 +70,7 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav allowWhenInactive = false, } = options; const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled; + const keyboardNavigationEnabled = MessageKeyboardFocusRollout.enabled; useEffect(() => { if (!keyboardModeEnabled) return; let messageNodesCache: MessageNodesSnapshot = EMPTY_MESSAGE_NODES_SNAPSHOT; @@ -140,10 +133,19 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav }; return messageNodesCache; }; + const hasFocusInside = (node: HTMLElement): boolean => { + const activeElement = node.ownerDocument?.activeElement ?? document.activeElement; + return activeElement != null && (activeElement === node || node.contains(activeElement)); + }; const focusNode = (node: HTMLElement, messageId: string) => { if (onFocusMessage) { onFocusMessage(messageId); - return; + if (!keyboardNavigationEnabled || hasFocusInside(node)) { + return; + } + } + if (keyboardNavigationEnabled && node.tabIndex < 0) { + node.tabIndex = -1; } node.focus({preventScroll: true}); node.scrollIntoView({block: 'nearest', inline: 'nearest'}); @@ -197,7 +199,10 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav }; const handleKeyDown = (event: KeyboardEvent) => { if (!keyboardModeEnabled) return; - if (isEditableTarget(document.activeElement)) return; + const activeElementIsEditable = keyboardNavigationEnabled + ? isEditableElement(document.activeElement) + : isEditableTarget(document.activeElement); + if (activeElementIsEditable) return; const delta = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0; const isNavigationKey = delta !== 0; if (isNavigationKey && hasShortcutModifier(event)) return; @@ -226,6 +231,7 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav }; }, [ keyboardModeEnabled, + keyboardNavigationEnabled, containerRef, channelId, onFocusMessage, diff --git a/fluxer_app/src/features/messaging/state/MessageKeyboardFocusRollout.test.ts b/fluxer_app/src/features/messaging/state/MessageKeyboardFocusRollout.test.ts new file mode 100644 index 000000000..3f6b6fe10 --- /dev/null +++ b/fluxer_app/src/features/messaging/state/MessageKeyboardFocusRollout.test.ts @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + type ExperimentAssignmentsResponse, + INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE, +} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas'; +import { + INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT, + type MessageKeyboardFocusAssignmentResponse, +} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; +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 {MessageKeyboardFocusRollout} = await import('@app/features/messaging/state/MessageKeyboardFocusRollout'); + +function publish(assignment: MessageKeyboardFocusAssignmentResponse): void { + const response: ExperimentAssignmentsResponse = { + poll_interval_seconds: 300, + poll_jitter_percent: 15, + assignments: {message_keyboard_focus: assignment}, + }; + runInAction(() => { + ExperimentAssignments.response = response; + }); +} + +afterEach(() => { + runInAction(() => { + ExperimentAssignments.response = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE; + }); +}); + +describe('MessageKeyboardFocusRollout', () => { + it('reads the inert assignment out of the inert envelope', () => { + expect(MessageKeyboardFocusRollout.assignment).toBe(INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT); + expect(MessageKeyboardFocusRollout.enabled).toBe(false); + }); + + it('stays on the control arm while the rollout is disabled', () => { + publish({enabled: false, config_version: 4, user_targeted: false, source: null}); + expect(MessageKeyboardFocusRollout.enabled).toBe(false); + }); + + it('stays on the control arm for an account the rollout did not target', () => { + publish({enabled: true, config_version: 4, user_targeted: false, source: null}); + expect(MessageKeyboardFocusRollout.enabled).toBe(false); + }); + + it('moves to the experiment arm for a targeted account', () => { + publish({enabled: true, config_version: 4, user_targeted: true, source: 'canary'}); + expect(MessageKeyboardFocusRollout.enabled).toBe(true); + }); + + it('follows the envelope back to the control arm when the store is reset', () => { + publish({enabled: true, config_version: 4, user_targeted: true, source: 'user_rule'}); + expect(MessageKeyboardFocusRollout.enabled).toBe(true); + ExperimentAssignments.reset(); + expect(MessageKeyboardFocusRollout.enabled).toBe(false); + }); +}); diff --git a/fluxer_app/src/features/messaging/state/MessageKeyboardFocusRollout.ts b/fluxer_app/src/features/messaging/state/MessageKeyboardFocusRollout.ts new file mode 100644 index 000000000..c5705d38c --- /dev/null +++ b/fluxer_app/src/features/messaging/state/MessageKeyboardFocusRollout.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments'; +import {readMessageKeyboardFocusAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas'; +import type {MessageKeyboardFocusAssignmentResponse} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; + +class MessageKeyboardFocusRolloutSelector { + get assignment(): MessageKeyboardFocusAssignmentResponse { + return readMessageKeyboardFocusAssignment(ExperimentAssignments.response); + } + + get enabled(): boolean { + const assignment = this.assignment; + return assignment.enabled && assignment.user_targeted; + } +} + +export const MessageKeyboardFocusRollout = new MessageKeyboardFocusRolloutSelector(); + +export default MessageKeyboardFocusRollout; diff --git a/fluxer_app/src/features/messaging/utils/MessageNodeSelectors.test.ts b/fluxer_app/src/features/messaging/utils/MessageNodeSelectors.test.ts new file mode 100644 index 000000000..dc41d36ac --- /dev/null +++ b/fluxer_app/src/features/messaging/utils/MessageNodeSelectors.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + CHANNEL_MESSAGE_ID_PREFIX, + findMessageElement, + getMessageSelector, +} from '@app/features/messaging/utils/MessageNodeSelectors'; +import {afterEach, describe, expect, it} from 'vitest'; + +const CHANNEL_ID = '900000000000000001'; + +function messageRow(messageId: string, idPrefix: string): HTMLElement { + const row = document.createElement('div'); + row.id = `${idPrefix}-${CHANNEL_ID}-${messageId}`; + row.dataset.messageId = messageId; + row.dataset.channelId = CHANNEL_ID; + return row; +} + +function viewport(): HTMLElement { + const element = document.createElement('div'); + document.body.append(element); + return element; +} + +describe('MessageNodeSelectors', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it('resolves a normal stream row through the id fast path', () => { + const scroller = viewport(); + const row = messageRow('1', CHANNEL_MESSAGE_ID_PREFIX); + scroller.append(row); + + expect(findMessageElement(document, scroller, CHANNEL_ID, '1')).toBe(row); + }); + + it('resolves a revealed blocked group row that carries a different id prefix', () => { + const scroller = viewport(); + scroller.append(messageRow('1', CHANNEL_MESSAGE_ID_PREFIX)); + const blockedRow = messageRow('2', 'blocked-messages'); + scroller.append(blockedRow); + + expect(document.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${CHANNEL_ID}-2`)).toBeNull(); + expect(findMessageElement(document, scroller, CHANNEL_ID, '2')).toBe(blockedRow); + }); + + it('resolves a revealed spammer group row', () => { + const scroller = viewport(); + const spammerRow = messageRow('3', 'spammer-messages'); + scroller.append(spammerRow); + + expect(findMessageElement(document, scroller, CHANNEL_ID, '3')).toBe(spammerRow); + }); + + it('falls back to the viewport-scoped lookup and ignores rows outside it', () => { + const scroller = viewport(); + const searchPanel = viewport(); + searchPanel.append(messageRow('4', 'blocked-messages')); + + expect(document.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${CHANNEL_ID}-4`)).toBeNull(); + expect(findMessageElement(document, scroller, CHANNEL_ID, '4')).toBeNull(); + }); + + it('resolves a row through the document-wide id lookup even when it sits outside the viewport', () => { + const scroller = viewport(); + const popout = viewport(); + const inPopout = messageRow('8', CHANNEL_MESSAGE_ID_PREFIX); + popout.append(inPopout); + + expect(findMessageElement(document, scroller, CHANNEL_ID, '8')).toBe(inPopout); + }); + + it('prefers the row inside the viewport when neither row carries the canonical id', () => { + const scroller = viewport(); + const inStream = messageRow('5', 'blocked-messages'); + scroller.append(inStream); + const searchPanel = viewport(); + const inSearch = messageRow('5', 'search-messages'); + searchPanel.append(inSearch); + + expect(document.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${CHANNEL_ID}-5`)).toBeNull(); + expect(findMessageElement(document, scroller, CHANNEL_ID, '5')).toBe(inStream); + }); + + it('does not resolve a row belonging to another channel', () => { + const scroller = viewport(); + const foreign = document.createElement('div'); + foreign.dataset.messageId = '6'; + foreign.dataset.channelId = '900000000000000002'; + scroller.append(foreign); + + expect(findMessageElement(document, scroller, CHANNEL_ID, '6')).toBeNull(); + }); + + it('requires both data attributes so message group row wrappers are not candidates', () => { + const scroller = viewport(); + const groupWrapper = document.createElement('div'); + groupWrapper.dataset.messageId = '7'; + const row = messageRow('7', CHANNEL_MESSAGE_ID_PREFIX); + groupWrapper.append(row); + scroller.append(groupWrapper); + + const matches = scroller.querySelectorAll(getMessageSelector(CHANNEL_ID)); + expect(Array.from(matches)).toEqual([row]); + }); + + it('leaves snowflake ids untouched instead of emitting identifier escapes', () => { + expect(getMessageSelector(CHANNEL_ID, '900000000000000009')).toBe( + '[data-channel-id="900000000000000001"][data-message-id="900000000000000009"]', + ); + }); + + it('escapes quotes and backslashes so an id can never break out of the attribute selector', () => { + expect(getMessageSelector(undefined, 'a"]b')).toBe('[data-channel-id][data-message-id="a\\"]b"]'); + expect(getMessageSelector(undefined, 'a\\b')).toBe('[data-channel-id][data-message-id="a\\\\b"]'); + }); +}); diff --git a/fluxer_app/src/features/messaging/utils/MessageNodeSelectors.ts b/fluxer_app/src/features/messaging/utils/MessageNodeSelectors.ts new file mode 100644 index 000000000..0bb6b2072 --- /dev/null +++ b/fluxer_app/src/features/messaging/utils/MessageNodeSelectors.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const CHANNEL_MESSAGE_ID_PREFIX = 'chat-messages'; + +export const escapeSelectorValue = (value: string): string => + value.replace(/[\\"]/gu, '\\$&').replace(/[\n\r\f]/gu, (char) => `\\${char.charCodeAt(0).toString(16)} `); + +export const getMessageSelector = (channelId?: string, messageId?: string): string => { + const channelSelector = channelId ? `[data-channel-id="${escapeSelectorValue(channelId)}"]` : '[data-channel-id]'; + const messageSelector = messageId ? `[data-message-id="${escapeSelectorValue(messageId)}"]` : '[data-message-id]'; + return `${channelSelector}${messageSelector}`; +}; + +export const findMessageElement = ( + doc: Document | null | undefined, + viewport: HTMLElement | null | undefined, + channelId: string, + messageId: string, +): HTMLElement | null => { + const byId = doc?.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${channelId}-${messageId}`) ?? null; + if (byId != null) { + return byId as HTMLElement; + } + return viewport?.querySelector(getMessageSelector(channelId, messageId)) ?? null; +}; diff --git a/fluxer_app/src/features/platform/utils/ScrollManager.ts b/fluxer_app/src/features/platform/utils/ScrollManager.ts index 2f6f34bf9..24bc412c3 100644 --- a/fluxer_app/src/features/platform/utils/ScrollManager.ts +++ b/fluxer_app/src/features/platform/utils/ScrollManager.ts @@ -4,7 +4,9 @@ import Accessibility from '@app/features/accessibility/state/Accessibility'; import type {Channel} from '@app/features/channel/models/Channel'; import * as MessageCommands from '@app/features/messaging/commands/MessageCommands'; import type {ChannelMessages} from '@app/features/messaging/state/ChannelMessages'; +import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout'; import Messages from '@app/features/messaging/state/MessagingMessages'; +import {CHANNEL_MESSAGE_ID_PREFIX, findMessageElement} from '@app/features/messaging/utils/MessageNodeSelectors'; import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands'; import Navigation from '@app/features/navigation/state/Navigation'; import {evaluateScrollPinning, type ScrollPinResult} from '@app/features/platform/utils/ScrollPosition'; @@ -210,10 +212,12 @@ export class ScrollManager { layoutGetElementFromMessageId(messageId: string): HTMLElement | null { const doc = this.scrollGetDocument(); - const {channel} = this.props; if (!doc) return null; - const elementId = `chat-messages-${channel.id}-${messageId}`; - return doc.getElementById(elementId) as HTMLElement | null; + const {channel} = this.props; + if (!MessageKeyboardFocusRollout.enabled) { + return doc.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${channel.id}-${messageId}`) as HTMLElement | null; + } + return findMessageElement(doc, this.ref.current?.getViewportElement(), channel.id, messageId); } private layoutGetContainerLayout(container: HTMLElement): ContainerLayout { diff --git a/fluxer_app/src/features/ui/focus_ring/FocusRingScope.test.tsx b/fluxer_app/src/features/ui/focus_ring/FocusRingScope.test.tsx new file mode 100644 index 000000000..fc5181989 --- /dev/null +++ b/fluxer_app/src/features/ui/focus_ring/FocusRingScope.test.tsx @@ -0,0 +1,290 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import FocusRingContext, {type FocusRingContextManager} from '@app/features/ui/focus_ring/FocusRingContext'; +import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager'; +import FocusRingScope from '@app/features/ui/focus_ring/FocusRingScope'; +import {act, useContext, useRef} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, test} from 'vitest'; + +const RING_SELECTOR = '[data-flx="ui.focus-ring.focus-ring-scope.ring.focus-ring"]'; + +(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true; + +type Slot = 'primary' | 'secondary'; + +let container: HTMLDivElement; +let root: Root; +let unmounted = false; +const ringContexts: Partial> = {}; +const targets: Partial> = {}; + +function Capture({slot = 'primary'}: {slot?: Slot}) { + ringContexts[slot] = useContext(FocusRingContext); + return ( +
{ + if (element != null) targets[slot] = element; + }} + data-flx="ui.focus-ring.focus-ring-scope-test.capture.div" + /> + ); +} + +function Harness({revision = 0}: {revision?: number}) { + const containerRef = useRef(null); + return ( +
+ + + +
+ ); +} + +function ScopePair() { + const primaryRef = useRef(null); + const secondaryRef = useRef(null); + return ( + <> +
+ + + +
+
+ + + +
+ + ); +} + +function rings(): Array { + return Array.from(container.querySelectorAll(RING_SELECTOR)); +} + +function ring(): Element | null { + return container.querySelector(RING_SELECTOR); +} + +function requireRing(): HTMLElement { + const element = ring(); + if (element == null) throw new Error('The focus ring is not painted'); + return element as HTMLElement; +} + +function requireRingContext(slot: Slot = 'primary'): FocusRingContextManager { + const value = ringContexts[slot]; + if (value == null) throw new Error(`The focus ring context for ${slot} never rendered`); + return value; +} + +function requireTarget(slot: Slot = 'primary'): HTMLDivElement { + const value = targets[slot]; + if (value == null) throw new Error(`The focus ring target for ${slot} never rendered`); + return value; +} + +function requireScopeContainer(): HTMLElement { + const element = container.firstElementChild; + if (element == null) throw new Error('The scope container never rendered'); + return element as HTMLElement; +} + +function domRect(top: number, left: number, width: number, height: number): DOMRect { + return { + top, + left, + width, + height, + right: left + width, + bottom: top + height, + x: left, + y: top, + toJSON: () => ({}), + } as DOMRect; +} + +function stubBoundingRect(element: Element, read: () => DOMRect) { + Object.defineProperty(element, 'getBoundingClientRect', {value: read, configurable: true}); +} + +function unmountRoot() { + if (unmounted) return; + unmounted = true; + act(() => { + root.unmount(); + }); +} + +beforeEach(() => { + delete ringContexts.primary; + delete ringContexts.secondary; + delete targets.primary; + delete targets.secondary; + unmounted = false; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + unmountRoot(); + container.remove(); + FocusRingManager.setRingsEnabled(true); +}); + +describe('FocusRingScope', () => { + test('paints the ring once rings are enabled, without a second focus event', () => { + FocusRingManager.setRingsEnabled(false); + act(() => { + root.render(); + }); + act(() => { + requireRingContext().showForElement(requireTarget()); + }); + expect(ring()).toBeNull(); + act(() => { + FocusRingManager.setRingsEnabled(true); + }); + expect(ring()).not.toBeNull(); + }); + + test('restores the ring for a target that never blurred while rings were disabled', () => { + FocusRingManager.setRingsEnabled(true); + act(() => { + root.render(); + }); + act(() => { + requireRingContext().showForElement(requireTarget()); + }); + expect(ring()).not.toBeNull(); + act(() => { + FocusRingManager.setRingsEnabled(false); + }); + expect(ring()).toBeNull(); + act(() => { + FocusRingManager.setRingsEnabled(true); + }); + expect(ring()).not.toBeNull(); + }); + + test('keeps the ring hidden when nothing is showing', () => { + FocusRingManager.setRingsEnabled(false); + act(() => { + root.render(); + }); + act(() => { + FocusRingManager.setRingsEnabled(true); + }); + expect(ring()).toBeNull(); + }); + + test('paints the ring on show and removes it on hide', () => { + act(() => { + root.render(); + }); + expect(ring()).toBeNull(); + act(() => { + requireRingContext().showForElement(requireTarget()); + }); + expect(ring()).not.toBeNull(); + act(() => { + requireRingContext().hide(); + }); + expect(ring()).toBeNull(); + }); + + test('repositions the ring when a parent render moves a target that did not resize', () => { + act(() => { + root.render(); + }); + stubBoundingRect(requireScopeContainer(), () => domRect(0, 0, 800, 600)); + let targetTop = 200; + stubBoundingRect(requireTarget(), () => domRect(targetTop, 0, 300, 40)); + act(() => { + requireRingContext().showForElement(requireTarget()); + }); + expect(requireRing().style.top).toBe('200px'); + targetTop = 320; + act(() => { + root.render(); + }); + expect(requireRing().style.top).toBe('320px'); + }); + + test('repositions the ring when the target itself resizes, and stops observing on unmount', () => { + const realResizeObserver = globalThis.ResizeObserver; + const realRequestAnimationFrame = globalThis.requestAnimationFrame; + const realCancelAnimationFrame = globalThis.cancelAnimationFrame; + let notifyResize: (() => void) | null = null; + let disconnectCount = 0; + class StubResizeObserver { + constructor(callback: () => void) { + notifyResize = callback; + } + observe() {} + unobserve() {} + disconnect() { + disconnectCount += 1; + } + } + globalThis.ResizeObserver = StubResizeObserver as unknown as typeof ResizeObserver; + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callback(0); + return 1; + }) as typeof requestAnimationFrame; + globalThis.cancelAnimationFrame = (() => undefined) as typeof cancelAnimationFrame; + try { + act(() => { + root.render(); + }); + stubBoundingRect(requireScopeContainer(), () => domRect(0, 0, 800, 600)); + let targetHeight = 40; + stubBoundingRect(requireTarget(), () => domRect(100, 0, 300, targetHeight)); + act(() => { + requireRingContext().showForElement(requireTarget()); + }); + expect(requireRing().style.height).toBe('40px'); + targetHeight = 90; + act(() => { + notifyResize?.(); + }); + expect(requireRing().style.height).toBe('90px'); + unmountRoot(); + expect(disconnectCount).toBe(1); + } finally { + globalThis.ResizeObserver = realResizeObserver; + globalThis.requestAnimationFrame = realRequestAnimationFrame; + globalThis.cancelAnimationFrame = realCancelAnimationFrame; + } + }); + + test('drops the ring from the previous scope when another scope takes focus', () => { + act(() => { + root.render(); + }); + act(() => { + requireRingContext('primary').showForElement(requireTarget('primary')); + }); + expect(rings()).toHaveLength(1); + act(() => { + requireRingContext('secondary').showForElement(requireTarget('secondary')); + }); + expect(rings()).toHaveLength(1); + expect(requireRingContext('primary').visible).toBe(false); + expect(requireRingContext('secondary').visible).toBe(true); + }); +}); diff --git a/fluxer_docs/src/content/docs/admin-api/instance.mdx b/fluxer_docs/src/content/docs/admin-api/instance.mdx index 1adbd0cc0..6b64a1c3b 100644 --- a/fluxer_docs/src/content/docs/admin-api/instance.mdx +++ b/fluxer_docs/src/content/docs/admin-api/instance.mdx @@ -37,6 +37,7 @@ Missing settings use the defaults documented below. Invalid stored configuration | voice_noise_suppression | [voice noise suppression configuration](#voice-noise-suppression-configuration-object) object | Client-side noise suppression rollout | | experiment_delivery | [experiment delivery configuration](#experiment-delivery-configuration-object) object | Cadence every client polls the experiments route on | | message_hover_tracking | [message hover tracking configuration](#message-hover-tracking-configuration-object) object | Message hover implementation rollout | +| message_keyboard_focus | [message keyboard focus configuration](#message-keyboard-focus-configuration-object) object | Message list keyboard navigation rollout | | registration | [registration configuration](#registration-configuration-object) object | Registration policy, issued URLs, and pending registrations | | self_hosted | boolean | Whether the deployment runs in self-hosted mode | | app_public | [public application configuration](#public-application-configuration-object) object | Branding, legal, setup, and registration field policy | @@ -158,6 +159,27 @@ Every field is present on read. An absent document or missing field uses the def How often a client revalidates this rollout is set once for every experiment in the [experiment delivery configuration](#experiment-delivery-configuration-object) above. +## Message keyboard focus configuration object + +The instance rollout of the keyboard navigation implementation the client runs in the message list. [Experiments](/http-api/experiments/) defines what a client resolves from it. + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| enabled | boolean | Whether the rollout runs at all (default false) | +| config_version | integer | Revision counter, raised by Fluxer and never accepted from a request | +| rollout_basis_points | integer | Share of accounts drawn, in basis points (0-10000, default 0) | +| rollout_salt | string | Salt of the sampling hash (1-64 characters, default `message-keyboard-focus-v1`) | +| included_user_ids | array[snowflake] | Accounts always drawn, up to 1000 entries (default empty) | +| excluded_user_ids | array[snowflake] | Accounts never drawn, up to 1000 entries (default empty) | + +Every field is present on read. An absent document or missing field uses the defaults above. + +`excluded_user_ids` is applied before `included_user_ids`, so an account in both is never drawn. A drawn client reaches the message list from the composer with one Tab and walks it with the arrow keys, and a client that is not drawn keeps the keyboard navigation it ships with. Neither arm changes any response this API produces. + +How often a client revalidates this rollout is set once for every experiment in the [experiment delivery configuration](#experiment-delivery-configuration-object) above. + ## Registration configuration object Registration policy in force, plus every issued registration URL and every account awaiting a decision. @@ -549,6 +571,7 @@ The body has one optional object for each section. Fluxer leaves an absent secti | voice_noise_suppression? | object | Any subset of the [noise suppression](#voice-noise-suppression-configuration-object) fields | | experiment_delivery? | object | Any subset of the [experiment delivery](#experiment-delivery-configuration-object) fields | | message_hover_tracking? | object | Any subset of the [message hover tracking](#message-hover-tracking-configuration-object) fields | +| message_keyboard_focus? | object | Any subset of the [message keyboard focus](#message-keyboard-focus-configuration-object) fields | | registration? | object | `mode` and `admin_registration_urls_enabled` | | app_public?2 | object | `branding`, `setup`, `legal`, and `registration` sub-objects, each merged field by field | | integrations?3 | object | `gif`, `youtube`, `captcha`, `email`, and `bluesky` sub-objects, the last of which also has the `keys` array | @@ -562,6 +585,7 @@ The body has one optional object for each section. Fluxer leaves an absent secti `voice_noise_suppression` takes every [voice noise suppression configuration](#voice-noise-suppression-configuration-object) field except `config_version`, each bound as documented there. Fluxer raises `config_version` by one on each request that supplies at least one of them. A section that is absent, or present with no field set, writes nothing and leaves `config_version` alone. `message_hover_tracking` takes every [message hover tracking configuration](#message-hover-tracking-configuration-object) field except `config_version`, each bound as documented there. Fluxer raises that section's own `config_version` by one on each request that supplies at least one of them, independently of the noise suppression revision. A section that is absent, or present with no field set, writes nothing and leaves `config_version` alone. +`message_keyboard_focus` takes every [message keyboard focus configuration](#message-keyboard-focus-configuration-object) field except `config_version`, each bound as documented there. Fluxer raises that section's own `config_version` by one on each request that supplies at least one of them, independently of the noise suppression revision. A section that is absent, or present with no field set, writes nothing and leaves `config_version` alone. `experiment_delivery` takes both [experiment delivery configuration](#experiment-delivery-configuration-object) fields, each bound as documented there. It is a section of its own, so a write to it changes no `config_version` and changes no assignment, only the cadence on which clients ask for one. @@ -599,7 +623,7 @@ Fluxer skips URL validation while the merged configuration leaves single sign-on | 400 | [error response](/admin-api/#error-response) | A policy transition is refused, returned as `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED` | :::caution[Sections are applied one after another] -The order is `gateway_rollout`, `voice_noise_suppression`, `message_hover_tracking`, `experiment_delivery`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written. +The order is `gateway_rollout`, `voice_noise_suppression`, `message_hover_tracking`, `message_keyboard_focus`, `experiment_delivery`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written. ::: ### Side effects diff --git a/fluxer_docs/src/content/docs/http-api/experiments.mdx b/fluxer_docs/src/content/docs/http-api/experiments.mdx index 383daf92b..7abef3a88 100644 --- a/fluxer_docs/src/content/docs/http-api/experiments.mdx +++ b/fluxer_docs/src/content/docs/http-api/experiments.mdx @@ -6,7 +6,7 @@ description: The experiment assignments envelope, the revalidation and polling c import RouteHeader from '@/components/RouteHeader.astro'; -An experiment is an instance-wide rollout that an operator configures. For each account, Fluxer works out from that configuration whether the account is in the rollout and which settings the account receives. The single route on this page resolves every experiment the server defines and returns them in one envelope, together with the polling cadence they share. This server defines `voice_noise_suppression`, whose placement protocol [Voice](/voice/) defines, and `message_hover_tracking`, which selects one of two client implementations of the message hover state. +An experiment is an instance-wide rollout that an operator configures. For each account, Fluxer works out from that configuration whether the account is in the rollout and which settings the account receives. The single route on this page resolves every experiment the server defines and returns them in one envelope, together with the polling cadence they share. This server defines `voice_noise_suppression`, whose placement protocol [Voice](/voice/) defines, `message_hover_tracking`, which selects one of two client implementations of the message hover state, and `message_keyboard_focus`, which selects one of two client implementations of keyboard navigation in the message list. Every assignment is advice. A client that ignores one behaves as it does with the rollout off, and no route and no Gateway event reports what a client actually ran. @@ -34,10 +34,11 @@ One entry per experiment. The envelope reports this object even when it is empty | --- | --- | --- | | voice_noise_suppression? | [noise suppression assignment](#noise-suppression-assignment-object) object | The caller's noise suppression assignment | | message_hover_tracking? | [message hover tracking assignment](#message-hover-tracking-assignment-object) object | The caller's message hover tracking assignment | +| message_keyboard_focus? | [message keyboard focus assignment](#message-keyboard-focus-assignment-object) object | The caller's message keyboard focus assignment | Ignore unknown experiments and treat a missing experiment as off. -This server version writes `voice_noise_suppression` and `message_hover_tracking` on every response, including while a rollout is disabled. The disabled value is the first [resolution outcome](#resolution-outcomes) below, which reports `enabled` false and the stored `config_version`, so a client that compares `config_version` with the value from its previous response can see that an operator saved the configuration, even while the rollout stays disabled, and needs no second request for it. +This server version writes `voice_noise_suppression`, `message_hover_tracking`, and `message_keyboard_focus` on every response, including while a rollout is disabled. The disabled value is the first [resolution outcome](#resolution-outcomes) below, which reports `enabled` false and the stored `config_version`, so a client that compares `config_version` with the value from its previous response can see that an operator saved the configuration, even while the rollout stays disabled, and needs no second request for it. ## Noise suppression backends @@ -130,6 +131,33 @@ A caller is drawn either by the operator's allowlist, which sets `source` to `us `config_version` reports the stored revision in all outcomes, the rollout being off included. A client branches on `user_targeted` alone. +## Message keyboard focus assignment object + +One resolution of the instance message keyboard focus rollout against one account. Every field is present whenever the key is written. + +The rollout selects which implementation of keyboard navigation a client runs in the message list. A drawn client reaches the message list from the composer with one Tab, walks messages with the arrow keys through revealed blocked groups, and draws the focus ring inside each row. A client that is not drawn keeps the implementation it ships with. Neither arm changes what the API returns, and no route reports which arm a client ran. + +### Structure + +| Field | Type | Description | +| --- | --- | --- | +| enabled | boolean | Whether the rollout is running on this instance | +| config_version | integer | The revision of the instance configuration this assignment was resolved from | +| user_targeted | boolean | Whether the caller is inside the rollout | +| source | ?string | Which rule targeted the caller, one of `user_rule` or `canary`, and null where the caller is not targeted | + +### Keyboard navigation resolution outcomes + +The outcomes below set `user_targeted` to false, and they differ in what else they report. + +1. The rollout is off. `enabled` is false and `source` is null. +2. The operator has excluded the caller. `enabled` is true and `source` is null. +3. The caller was not drawn. `enabled` is true and `source` is null. + +A caller is drawn either by the operator's allowlist, which sets `source` to `user_rule`, or by the sampled share of the account population, which sets `source` to `canary`. The blocklist is read before the allowlist, so an account named in both is not drawn. + +`config_version` reports the stored revision in all outcomes, the rollout being off included. A client branches on `user_targeted` alone. + ## Get experiment assignments diff --git a/packages/schema/src/domains/admin/AdminSchemas.ts b/packages/schema/src/domains/admin/AdminSchemas.ts index dbfd1f38d..98812a5b9 100644 --- a/packages/schema/src/domains/admin/AdminSchemas.ts +++ b/packages/schema/src/domains/admin/AdminSchemas.ts @@ -27,6 +27,10 @@ import { MessageHoverTrackingConfigResponse, MessageHoverTrackingConfigUpdateRequest, } from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas'; +import { + MessageKeyboardFocusConfigResponse, + MessageKeyboardFocusConfigUpdateRequest, +} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; import {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import { InstanceCaptchaProviderSchema, @@ -635,6 +639,7 @@ export const InstanceConfigResponse = z.object({ voice_noise_suppression: VoiceNoiseSuppressionConfigResponse, experiment_delivery: ExperimentDeliveryConfigResponse, message_hover_tracking: MessageHoverTrackingConfigResponse, + message_keyboard_focus: MessageKeyboardFocusConfigResponse, registration: InstanceRegistrationResponse, self_hosted: z.boolean(), app_public: AppPublicConfigResponse, @@ -672,6 +677,7 @@ export const InstanceConfigUpdateRequest = z.object({ voice_noise_suppression: VoiceNoiseSuppressionConfigUpdateRequest.nullish(), experiment_delivery: ExperimentDeliveryConfigUpdateRequest.nullish(), message_hover_tracking: MessageHoverTrackingConfigUpdateRequest.nullish(), + message_keyboard_focus: MessageKeyboardFocusConfigUpdateRequest.nullish(), registration: z .object({ mode: InstanceRegistrationModeSchema.optional(), diff --git a/packages/schema/src/domains/experiment/ExperimentSchemas.ts b/packages/schema/src/domains/experiment/ExperimentSchemas.ts index a2f38e1a6..c3b54ad25 100644 --- a/packages/schema/src/domains/experiment/ExperimentSchemas.ts +++ b/packages/schema/src/domains/experiment/ExperimentSchemas.ts @@ -8,6 +8,10 @@ import { INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT, MessageHoverTrackingAssignmentResponse, } from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas'; +import { + INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT, + MessageKeyboardFocusAssignmentResponse, +} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; import {z} from 'zod'; export const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS = 60; @@ -47,6 +51,7 @@ export type ExperimentDeliveryConfigResponse = z.infer; + +export const DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG: MessageKeyboardFocusConfig = messageKeyboardFocus.DEFAULT_CONFIG; + +export const MessageKeyboardFocusConfigUpdateRequest = messageKeyboardFocus.ConfigUpdateRequest; + +export type MessageKeyboardFocusConfigUpdateRequest = z.infer; + +export const MessageKeyboardFocusConfigResponse = messageKeyboardFocus.ConfigSchema; + +export type MessageKeyboardFocusConfigResponse = MessageKeyboardFocusConfig; + +export const MessageKeyboardFocusAssignmentResponse = messageKeyboardFocus.AssignmentResponse; + +export type MessageKeyboardFocusAssignmentResponse = z.infer; + +export const INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT: MessageKeyboardFocusAssignmentResponse = + messageKeyboardFocus.INERT_ASSIGNMENT; + +export function resolveMessageKeyboardFocusAssignment( + config: MessageKeyboardFocusConfig, + userId: string, +): MessageKeyboardFocusAssignmentResponse { + return messageKeyboardFocus.resolveAssignment(config, userId); +} diff --git a/packages/schema/src/domains/tests/MessageKeyboardFocusSchemas.test.ts b/packages/schema/src/domains/tests/MessageKeyboardFocusSchemas.test.ts new file mode 100644 index 000000000..8dc7a8bcd --- /dev/null +++ b/packages/schema/src/domains/tests/MessageKeyboardFocusSchemas.test.ts @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket'; +import { + DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG, + MessageKeyboardFocusConfigSchema, + resolveMessageKeyboardFocusAssignment, +} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; +import {describe, expect, it} from 'vitest'; + +const USER_ID = '1485064866382176262'; +const OTHER_USER_ID = '1485064866382176263'; + +function config(overrides: Partial> = {}) { + return MessageKeyboardFocusConfigSchema.parse({...overrides}); +} + +describe('message keyboard focus configuration', () => { + it('defaults to a disabled rollout that targets nobody', () => { + expect(DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG).toEqual({ + enabled: false, + config_version: 0, + rollout_basis_points: 0, + rollout_salt: 'message-keyboard-focus-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('message keyboard focus resolution', () => { + it('reports the stored revision while the rollout is disabled', () => { + const assignment = resolveMessageKeyboardFocusAssignment(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 = resolveMessageKeyboardFocusAssignment( + 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 = resolveMessageKeyboardFocusAssignment( + 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 = resolveMessageKeyboardFocusAssignment( + 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_MESSAGE_KEYBOARD_FOCUS_CONFIG.rollout_salt; + const bucket = experimentBucket(USER_ID, salt); + const inside = resolveMessageKeyboardFocusAssignment( + config({enabled: true, rollout_basis_points: bucket + 1}), + USER_ID, + ); + const outside = resolveMessageKeyboardFocusAssignment( + 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_MESSAGE_KEYBOARD_FOCUS_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, 'message-keyboard-focus-v1')).not.toBe( + experimentBucket(USER_ID, 'message-keyboard-focus-v2'), + ); + }); +});