From c212d315f4be78216fb15f52cb652000e58a5f3a Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 14 Sep 2026 21:54:52 +0200 Subject: [PATCH] fix(app): gate reworked typing indicators behind an experiment (#2782) --- fluxer_admin/openapi-admin.json | 54 +++ fluxer_admin/src/api/types/instance_config.rs | 95 +++++ fluxer_admin/src/routes/system_actions.rs | 114 +++++- .../src/templates/pages/instance_config.rs | 130 +++++- fluxer_admin/tests/api_deserialization.rs | 9 + fluxer_admin/tests/htmx_acceptance.rs | 9 + .../InstanceConfigAdminController.ts | 16 + .../api/experiment/ExperimentController.ts | 4 + .../tests/ExperimentController.test.ts | 88 +++- .../instance/InstanceConfigRepository.test.ts | 69 ++++ .../api/instance/InstanceConfigRepository.ts | 21 + fluxer_api/src/api/openapi/openapi.json | 14 +- fluxer_app/src/app/App.tsx | 7 +- .../channel/components/LegacyTypingUsers.tsx | 123 ++++++ .../LexicalChannelTextareaContent.tsx | 6 +- .../components/RollingTypingUsers.module.css | 20 + .../components/RollingTypingUsers.test.tsx | 379 ++++++++++++++++++ .../channel/components/RollingTypingUsers.tsx | 149 +++++++ .../channel/components/TypingUsers.test.tsx | 235 +++++++++++ .../channel/components/TypingUsers.tsx | 205 +++------- .../src/features/i18n/locales/ar/messages.po | 56 ++- .../src/features/i18n/locales/bg/messages.po | 56 ++- .../src/features/i18n/locales/cs/messages.po | 56 ++- .../src/features/i18n/locales/da/messages.po | 56 ++- .../src/features/i18n/locales/de/messages.po | 56 ++- .../src/features/i18n/locales/el/messages.po | 56 ++- .../features/i18n/locales/en-GB/messages.po | 56 ++- .../features/i18n/locales/en-US/messages.po | 56 ++- .../features/i18n/locales/es-419/messages.po | 56 ++- .../features/i18n/locales/es-ES/messages.po | 56 ++- .../src/features/i18n/locales/fi/messages.po | 56 ++- .../src/features/i18n/locales/fr/messages.po | 56 ++- .../src/features/i18n/locales/he/messages.po | 56 ++- .../src/features/i18n/locales/hi/messages.po | 56 ++- .../src/features/i18n/locales/hr/messages.po | 56 ++- .../src/features/i18n/locales/hu/messages.po | 56 ++- .../src/features/i18n/locales/id/messages.po | 56 ++- .../src/features/i18n/locales/it/messages.po | 56 ++- .../src/features/i18n/locales/ja/messages.po | 56 ++- .../src/features/i18n/locales/ko/messages.po | 56 ++- .../src/features/i18n/locales/lt/messages.po | 56 ++- .../src/features/i18n/locales/nl/messages.po | 56 ++- .../src/features/i18n/locales/no/messages.po | 56 ++- .../src/features/i18n/locales/pl/messages.po | 56 ++- .../features/i18n/locales/pt-BR/messages.po | 56 ++- .../src/features/i18n/locales/ro/messages.po | 56 ++- .../src/features/i18n/locales/ru/messages.po | 56 ++- .../features/i18n/locales/sv-SE/messages.po | 56 ++- .../src/features/i18n/locales/th/messages.po | 56 ++- .../src/features/i18n/locales/tr/messages.po | 56 ++- .../src/features/i18n/locales/uk/messages.po | 56 ++- .../src/features/i18n/locales/vi/messages.po | 56 ++- .../features/i18n/locales/zh-CN/messages.po | 56 ++- .../features/i18n/locales/zh-TW/messages.po | 56 ++- .../hooks/useMessageSubmission.test.tsx | 31 +- .../messaging/hooks/useMessageSubmission.tsx | 4 +- .../hooks/useTextareaDraftAndTyping.test.tsx | 306 ++++++++++++++ .../hooks/useTextareaDraftAndTyping.ts | 31 +- .../messaging/utils/SlashCommandUtils.ts | 4 + .../typing/commands/TypingCommands.ts | 59 +-- .../typing/legacy/LegacyComposerTyping.ts | 33 ++ .../typing/legacy/LegacyTypingCommands.ts | 56 +++ .../typing/legacy/LegacyTypingIndicator.ts | 299 ++++++++++++++ .../legacy/LegacyTypingSourceParity.test.ts | 190 +++++++++ .../typing/legacy/LegacyTypingUtils.ts | 206 ++++++++++ .../rolling/RollingComposerTypingGate.test.ts | 94 +++++ .../rolling/RollingComposerTypingGate.ts | 39 ++ .../rolling/RollingTypingSender.test.ts | 372 +++++++++++++++++ .../typing/rolling/RollingTypingSender.ts | 92 +++++ .../typing/rolling/RollingTypingStore.test.ts | 250 ++++++++++++ .../typing/rolling/RollingTypingStore.ts | 139 +++++++ .../typing/rolling/RollingTypingText.test.tsx | 170 ++++++++ .../typing/rolling/RollingTypingText.tsx | 99 +++++ .../typing/rolling/TypingSendThrottle.test.ts | 71 ++++ .../typing/rolling/TypingSendThrottle.ts | 37 ++ .../features/typing/state/TypingIndicator.ts | 310 ++------------ .../state/TypingIndicatorFacade.test.ts | 181 +++++++++ .../TypingIndicatorReworkRollout.test.ts | 107 +++++ .../state/TypingIndicatorReworkRollout.ts | 22 + .../typing/state/TypingPolicy.test.ts | 289 +++++++++++++ .../src/features/typing/state/TypingPolicy.ts | 48 +++ .../typing/utils/TypingTierText.test.ts | 52 +++ .../features/typing/utils/TypingTierText.ts | 27 ++ .../src/features/typing/utils/TypingUtils.ts | 233 +++-------- .../typing/utils/TypingUtilsFacade.test.ts | 176 ++++++++ .../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 + .../TypingIndicatorReworkSchemas.ts | 38 ++ .../TypingIndicatorReworkSchemas.test.ts | 102 +++++ 91 files changed, 6137 insertions(+), 1756 deletions(-) create mode 100644 fluxer_app/src/features/channel/components/LegacyTypingUsers.tsx create mode 100644 fluxer_app/src/features/channel/components/RollingTypingUsers.module.css create mode 100644 fluxer_app/src/features/channel/components/RollingTypingUsers.test.tsx create mode 100644 fluxer_app/src/features/channel/components/RollingTypingUsers.tsx create mode 100644 fluxer_app/src/features/channel/components/TypingUsers.test.tsx create mode 100644 fluxer_app/src/features/messaging/hooks/useTextareaDraftAndTyping.test.tsx create mode 100644 fluxer_app/src/features/typing/legacy/LegacyComposerTyping.ts create mode 100644 fluxer_app/src/features/typing/legacy/LegacyTypingCommands.ts create mode 100644 fluxer_app/src/features/typing/legacy/LegacyTypingIndicator.ts create mode 100644 fluxer_app/src/features/typing/legacy/LegacyTypingSourceParity.test.ts create mode 100644 fluxer_app/src/features/typing/legacy/LegacyTypingUtils.ts create mode 100644 fluxer_app/src/features/typing/rolling/RollingComposerTypingGate.test.ts create mode 100644 fluxer_app/src/features/typing/rolling/RollingComposerTypingGate.ts create mode 100644 fluxer_app/src/features/typing/rolling/RollingTypingSender.test.ts create mode 100644 fluxer_app/src/features/typing/rolling/RollingTypingSender.ts create mode 100644 fluxer_app/src/features/typing/rolling/RollingTypingStore.test.ts create mode 100644 fluxer_app/src/features/typing/rolling/RollingTypingStore.ts create mode 100644 fluxer_app/src/features/typing/rolling/RollingTypingText.test.tsx create mode 100644 fluxer_app/src/features/typing/rolling/RollingTypingText.tsx create mode 100644 fluxer_app/src/features/typing/rolling/TypingSendThrottle.test.ts create mode 100644 fluxer_app/src/features/typing/rolling/TypingSendThrottle.ts create mode 100644 fluxer_app/src/features/typing/state/TypingIndicatorFacade.test.ts create mode 100644 fluxer_app/src/features/typing/state/TypingIndicatorReworkRollout.test.ts create mode 100644 fluxer_app/src/features/typing/state/TypingIndicatorReworkRollout.ts create mode 100644 fluxer_app/src/features/typing/state/TypingPolicy.test.ts create mode 100644 fluxer_app/src/features/typing/state/TypingPolicy.ts create mode 100644 fluxer_app/src/features/typing/utils/TypingTierText.test.ts create mode 100644 fluxer_app/src/features/typing/utils/TypingTierText.ts create mode 100644 fluxer_app/src/features/typing/utils/TypingUtilsFacade.test.ts create mode 100644 packages/schema/src/domains/experiment/TypingIndicatorReworkSchemas.ts create mode 100644 packages/schema/src/domains/tests/TypingIndicatorReworkSchemas.test.ts diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index b5e50f35b..55f8b2c8d 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -10571,6 +10571,7 @@ "blocked_message_groups": {"$ref": "#/components/schemas/BlockedMessageGroupsConfigResponse"}, "expression_info_card": {"$ref": "#/components/schemas/ExpressionInfoCardConfigResponse"}, "guild_header_collapse": {"$ref": "#/components/schemas/GuildHeaderCollapseConfigResponse"}, + "typing_indicator_rework": {"$ref": "#/components/schemas/TypingIndicatorReworkConfigResponse"}, "registration": { "type": "object", "properties": { @@ -10980,6 +10981,7 @@ "blocked_message_groups", "expression_info_card", "guild_header_collapse", + "typing_indicator_rework", "registration", "self_hosted", "app_public", @@ -11141,6 +11143,10 @@ "nullable": true, "allOf": [{"$ref": "#/components/schemas/GuildHeaderCollapseConfigUpdateRequest"}] }, + "typing_indicator_rework": { + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/TypingIndicatorReworkConfigUpdateRequest"}] + }, "registration": { "nullable": true, "type": "object", @@ -15207,6 +15213,24 @@ "enum": ["open", "approval", "closed"], "type": "string" }, + "TypingIndicatorReworkConfigUpdateRequest": { + "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}$"} + } + } + }, "GuildHeaderCollapseConfigUpdateRequest": { "type": "object", "properties": { @@ -15379,6 +15403,36 @@ "type": "string", "enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"] }, + "TypingIndicatorReworkConfigResponse": { + "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": "typing-indicator-rework-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 + }, "GuildHeaderCollapseConfigResponse": { "type": "object", "properties": { diff --git a/fluxer_admin/src/api/types/instance_config.rs b/fluxer_admin/src/api/types/instance_config.rs index 5287be286..7beb5de00 100644 --- a/fluxer_admin/src/api/types/instance_config.rs +++ b/fluxer_admin/src/api/types/instance_config.rs @@ -36,6 +36,8 @@ pub struct InstanceConfigResponse { pub expression_info_card: ExpressionInfoCardConfigResponse, #[serde(default)] pub guild_header_collapse: GuildHeaderCollapseConfigResponse, + #[serde(default)] + pub typing_indicator_rework: TypingIndicatorReworkConfigResponse, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -777,6 +779,44 @@ pub struct GuildHeaderCollapseConfigUpdateRequest { pub excluded_user_ids: Option>, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub struct TypingIndicatorReworkConfigResponse { + 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 TypingIndicatorReworkConfigResponse { + fn default() -> Self { + Self { + enabled: false, + config_version: 0, + rollout_basis_points: 0, + rollout_salt: "typing-indicator-rework-v1".to_owned(), + included_user_ids: Vec::new(), + excluded_user_ids: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct TypingIndicatorReworkConfigUpdateRequest { + #[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 { @@ -906,6 +946,8 @@ pub struct InstanceConfigUpdateRequest { pub expression_info_card: Option, #[serde(skip_serializing_if = "Option::is_none")] pub guild_header_collapse: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub typing_indicator_rework: Option, } #[derive(Clone, Debug, Default, Serialize)] @@ -1253,6 +1295,8 @@ mod tests { .expect("default expression info card config"); let collapse = serde_json::from_value::(json!({})) .expect("default guild header collapse config"); + let typing = serde_json::from_value::(json!({})) + .expect("default typing indicator rework 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 = @@ -1267,6 +1311,8 @@ mod tests { serde_json::to_value(expression).expect("serializable expression info card config"); let collapse = serde_json::to_value(collapse).expect("serializable guild header collapse config"); + let typing = + serde_json::to_value(typing).expect("serializable typing indicator rework config"); let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse = serde_json::from_value(noise.clone()).expect("generated noise config contract"); let generated_delivery: generated_types::ExperimentDeliveryConfigResponse = @@ -1289,6 +1335,9 @@ mod tests { let generated_collapse: generated_types::GuildHeaderCollapseConfigResponse = serde_json::from_value(collapse.clone()) .expect("generated guild header collapse config contract"); + let generated_typing: generated_types::TypingIndicatorReworkConfigResponse = + serde_json::from_value(typing.clone()) + .expect("generated typing indicator rework config contract"); assert_eq!( serde_json::to_value(generated_noise).expect("serializable generated noise config"), noise @@ -1328,6 +1377,11 @@ mod tests { .expect("serializable generated guild header collapse config"), collapse ); + assert_eq!( + serde_json::to_value(generated_typing) + .expect("serializable generated typing indicator rework config"), + typing + ); for (name, value) in [ ("VoiceNoiseSuppressionConfigResponse", noise), ("ExperimentDeliveryConfigResponse", delivery), @@ -1337,6 +1391,7 @@ mod tests { ("GuildActivityLogPresentationConfigResponse", activity_log), ("ExpressionInfoCardConfigResponse", expression), ("GuildHeaderCollapseConfigResponse", collapse), + ("TypingIndicatorReworkConfigResponse", typing), ] { for (field, value) in value.as_object().expect("config object") { assert_eq!( @@ -1440,6 +1495,46 @@ mod tests { ); } + #[test] + fn typing_indicator_rework_update_preserves_empty_lists_and_omitted_fields() { + let update = TypingIndicatorReworkConfigUpdateRequest { + 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(TypingIndicatorReworkConfigUpdateRequest::default()) + .expect("serializable update"), + json!({}) + ); + } + + #[test] + fn typing_indicator_rework_response_defaults_to_the_typing_indicator_rework_v1_salt() { + let config = TypingIndicatorReworkConfigResponse::default(); + assert!(!config.enabled); + assert_eq!(config.config_version, 0); + assert_eq!(config.rollout_basis_points, 0); + assert_eq!(config.rollout_salt, "typing-indicator-rework-v1"); + assert!(config.included_user_ids.is_empty()); + assert!(config.excluded_user_ids.is_empty()); + assert_eq!( + serde_json::from_value::(json!({})) + .expect("default typing indicator rework config") + .rollout_salt, + "typing-indicator-rework-v1" + ); + } + #[test] fn message_hover_tracking_update_preserves_empty_lists_and_omitted_fields() { let update = MessageHoverTrackingConfigUpdateRequest { diff --git a/fluxer_admin/src/routes/system_actions.rs b/fluxer_admin/src/routes/system_actions.rs index 2d7c43d9a..06cd84e15 100644 --- a/fluxer_admin/src/routes/system_actions.rs +++ b/fluxer_admin/src/routes/system_actions.rs @@ -22,8 +22,9 @@ use crate::{ LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, MessageHoverTrackingConfigUpdateRequest, MessageKeyboardFocusConfigUpdateRequest, NoiseSuppressionBackend, PremiumMode, RegistrationMode, SsoConfigUpdateRequest, - VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS, VoiceE2eeScope, - VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride, + TypingIndicatorReworkConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, + VOICE_NS_MAX_TARGETED_USERS, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest, + VoiceNoiseSuppressionGuildOverride, }, }, config::AdminConfig, @@ -236,6 +237,10 @@ pub async fn instance_config_post( Ok(update) => instance_config_result(client.update_instance_config(&update).await), Err(message) => FlashData::error(message), }, + "update_typing_indicator_rework" => match build_typing_indicator_rework_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), @@ -842,6 +847,38 @@ fn build_guild_header_collapse_update( }) } +fn build_typing_indicator_rework_update( + form: &MultiValueForm, +) -> Result { + Ok(InstanceConfigUpdateRequest { + typing_indicator_rework: Some(TypingIndicatorReworkConfigUpdateRequest { + enabled: Some(form.bool_value("typing_indicator_rework_enabled")), + rollout_basis_points: parse_form_number( + form, + "typing_indicator_rework_rollout_basis_points", + "Rollout basis points", + 0, + EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX, + )?, + rollout_salt: parse_experiment_rollout_salt( + form, + "typing_indicator_rework_rollout_salt", + )?, + included_user_ids: Some(parse_experiment_user_ids( + form.first("typing_indicator_rework_included_user_ids") + .unwrap_or_default(), + "Included user IDs", + )?), + excluded_user_ids: Some(parse_experiment_user_ids( + form.first("typing_indicator_rework_excluded_user_ids") + .unwrap_or_default(), + "Excluded user IDs", + )?), + }), + ..Default::default() + }) +} + fn build_experiment_delivery_update( form: &MultiValueForm, ) -> Result { @@ -1836,6 +1873,79 @@ mod tests { assert_eq!(update.excluded_user_ids, Some(Vec::new())); } + #[test] + fn build_typing_indicator_rework_update_reads_the_whole_form() { + let form = MultiValueForm::parse( + b"typing_indicator_rework_enabled=true&typing_indicator_rework_rollout_basis_points=2500&typing_indicator_rework_rollout_salt=%20typing-indicator-rework-v2%20&typing_indicator_rework_included_user_ids=1500000000000000001%0A1500000000000000001&typing_indicator_rework_excluded_user_ids=1500000000000000002%2C%201500000000000000003", + ); + let update = build_typing_indicator_rework_update(&form) + .expect("valid form") + .typing_indicator_rework + .expect("typing indicator rework update"); + assert_eq!(update.enabled, Some(true)); + assert_eq!(update.rollout_basis_points, Some(2_500)); + assert_eq!( + update.rollout_salt, + Some("typing-indicator-rework-v2".to_owned()) + ); + assert_eq!( + update.included_user_ids, + Some(vec!["1500000000000000001".to_owned()]) + ); + assert_eq!( + update.excluded_user_ids, + Some(vec![ + "1500000000000000002".to_owned(), + "1500000000000000003".to_owned() + ]) + ); + } + + #[test] + fn build_typing_indicator_rework_update_leaves_the_rollout_inert_when_nothing_is_submitted() { + let form = MultiValueForm::parse(b"_csrf=token"); + let request = build_typing_indicator_rework_update(&form).expect("valid form"); + assert_eq!( + serde_json::to_value(request).expect("serializable update"), + serde_json::json!({"typing_indicator_rework": { + "enabled": false, + "included_user_ids": [], + "excluded_user_ids": [], + }}) + ); + } + + #[test] + fn build_typing_indicator_rework_update_rejects_a_rollout_above_the_maximum() { + let form = MultiValueForm::parse(b"typing_indicator_rework_rollout_basis_points=10001"); + assert_eq!( + build_typing_indicator_rework_update(&form).expect_err("invalid rollout"), + "Rollout basis points must be a whole number between 0 and 10000" + ); + } + + #[test] + fn build_typing_indicator_rework_update_reads_only_its_own_prefix() { + let form = MultiValueForm::parse( + b"expression_card_enabled=true&expression_card_rollout_basis_points=2500&expression_card_rollout_salt=expression-info-card-v2&expression_card_included_user_ids=1500000000000000001&typing_indicator_rework_rollout_basis_points=750&typing_indicator_rework_rollout_salt=typing-indicator-rework-v2&typing_indicator_rework_excluded_user_ids=1500000000000000009", + ); + let update = build_typing_indicator_rework_update(&form) + .expect("valid form") + .typing_indicator_rework + .expect("typing indicator rework update"); + assert_eq!(update.enabled, Some(false)); + assert_eq!(update.rollout_basis_points, Some(750)); + assert_eq!( + update.rollout_salt, + Some("typing-indicator-rework-v2".to_owned()) + ); + assert_eq!(update.included_user_ids, Some(Vec::new())); + assert_eq!( + update.excluded_user_ids, + Some(vec!["1500000000000000009".to_owned()]) + ); + } + #[test] fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() { let form = MultiValueForm::parse(b"_csrf=token"); diff --git a/fluxer_admin/src/templates/pages/instance_config.rs b/fluxer_admin/src/templates/pages/instance_config.rs index cf2009536..0a0b0b844 100644 --- a/fluxer_admin/src/templates/pages/instance_config.rs +++ b/fluxer_admin/src/templates/pages/instance_config.rs @@ -9,8 +9,9 @@ use crate::{ InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse, MessageHoverTrackingConfigResponse, MessageKeyboardFocusConfigResponse, NoiseSuppressionBackend, PendingRegistrationResponse, - RegistrationUrlResponse, SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, - VOICE_NS_MAX_TARGETED_USERS, VoiceNoiseSuppressionConfigResponse, + RegistrationUrlResponse, SsoConfigResponse, TypingIndicatorReworkConfigResponse, + VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS, + VoiceNoiseSuppressionConfigResponse, }, config::AdminConfig, middleware::auth::AuthContext, @@ -157,6 +158,7 @@ pub fn instance_config_page( (guild_activity_log_presentation_section(base, csrf_token, &instance_config.guild_activity_log_presentation)) (expression_info_card_section(base, csrf_token, &instance_config.expression_info_card)) (guild_header_collapse_section(base, csrf_token, &instance_config.guild_header_collapse)) + (typing_indicator_rework_section(base, csrf_token, &instance_config.typing_indicator_rework)) (experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery)) @if let Some(limit_config) = limit_config { (limit_config_section(base, limit_config)) @@ -1807,6 +1809,110 @@ fn guild_header_collapse_section( ) } +fn typing_indicator_rework_section( + base: &str, + csrf_token: &str, + typing_indicator_rework: &TypingIndicatorReworkConfigResponse, +) -> Markup { + let status = if typing_indicator_rework.enabled { + ("Live", BadgeVariant::Success) + } else { + ("Inert", BadgeVariant::Default) + }; + let included_user_ids = typing_indicator_rework.included_user_ids.join("\n"); + let excluded_user_ids = typing_indicator_rework.excluded_user_ids.join("\n"); + section_card_with_description( + "Typing Indicator Rework", + "Picks how a targeted client sends and shows typing indicators. A targeted client sends a \ + typing signal 1.5 seconds after someone starts typing and then at most once every 8 \ + seconds, names up to three typists, and announces those names to screen readers even \ + where the visible row collapses them. While the master switch below is off every client \ + keeps the typing behaviour it ships with, whatever the rest of these fields say.", + html! { + form method="post" action={(base) "/instance-config?action=update_typing_indicator_rework"} { + (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 " (typing_indicator_rework.config_version) + } + } + (checkbox( + "typing_indicator_rework_enabled", + "true", + "Serve typing indicator rework assignments to clients", + typing_indicator_rework.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 typing behaviour, so the rollout \ + and targeting fields below have no effect at all." + } + + h3 class="text-sm font-semibold text-neutral-900" { "Rollout" } + (number_field( + "typing_indicator_rework_rollout_basis_points", + "Rollout (basis points)", + &typing_indicator_rework.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( + "typing_indicator_rework_rollout_salt", + "Rollout Salt", + &typing_indicator_rework.rollout_salt, + "typing-indicator-rework-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( + "typing_indicator_rework_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( + "typing_indicator_rework_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 Typing Indicator Rework Configuration")) + })) + } + } + }, + ) +} + fn experiment_delivery_section( base: &str, csrf_token: &str, @@ -2482,4 +2588,24 @@ mod tests { assert!(markup.contains("guild_header_collapse_excluded_user_ids")); assert!(!markup.contains("expression_card_")); } + + #[test] + fn typing_indicator_rework_section_posts_its_own_action_and_fields() { + let typing_indicator_rework = TypingIndicatorReworkConfigResponse { + included_user_ids: vec!["1500000000000000001".to_owned()], + excluded_user_ids: vec!["1500000000000000002".to_owned()], + ..TypingIndicatorReworkConfigResponse::default() + }; + let markup = typing_indicator_rework_section("/admin", "csrf", &typing_indicator_rework) + .into_string(); + assert!(markup.contains("/instance-config?action=update_typing_indicator_rework")); + assert!(markup.contains("Typing Indicator Rework")); + assert!(markup.contains("typing-indicator-rework-v1")); + assert!(markup.contains("typing_indicator_rework_enabled")); + assert!(markup.contains("typing_indicator_rework_rollout_basis_points")); + assert!(markup.contains("typing_indicator_rework_rollout_salt")); + assert!(markup.contains("typing_indicator_rework_included_user_ids")); + assert!(markup.contains("typing_indicator_rework_excluded_user_ids")); + assert!(!markup.contains("expression_card_")); + } } diff --git a/fluxer_admin/tests/api_deserialization.rs b/fluxer_admin/tests/api_deserialization.rs index 2e2b4b4dd..083f1ae95 100644 --- a/fluxer_admin/tests/api_deserialization.rs +++ b/fluxer_admin/tests/api_deserialization.rs @@ -457,6 +457,14 @@ fn deserialize_instance_config_response_with_unknown_keys() { "included_user_ids": [], "excluded_user_ids": [] }, + "typing_indicator_rework": { + "enabled": false, + "config_version": 0, + "rollout_basis_points": 0, + "rollout_salt": "typing-indicator-rework-v1", + "included_user_ids": [], + "excluded_user_ids": [] + }, "registration": { "mode": "open", "admin_registration_urls_enabled": false, @@ -601,6 +609,7 @@ fn deserialize_instance_config_response_with_unknown_keys() { assert!(!resp.message_keyboard_focus.enabled); assert!(!resp.blocked_message_groups.enabled); assert!(!resp.guild_header_collapse.enabled); + assert!(!resp.typing_indicator_rework.enabled); assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300); assert!(resp.policy.single_community_guild_id.is_none()); assert_eq!(resp.policy.services.gif_enabled, Some(true)); diff --git a/fluxer_admin/tests/htmx_acceptance.rs b/fluxer_admin/tests/htmx_acceptance.rs index 6e298c1df..5d64bd46b 100644 --- a/fluxer_admin/tests/htmx_acceptance.rs +++ b/fluxer_admin/tests/htmx_acceptance.rs @@ -433,6 +433,7 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() { "/instance-config?action=update_sso", "/instance-config?action=update_voice_noise_suppression", "/instance-config?action=update_guild_header_collapse", + "/instance-config?action=update_typing_indicator_rework", "/instance-config?action=update_experiment_delivery", ][..], ), @@ -1106,6 +1107,14 @@ fn instance_config() -> Value { "included_user_ids": [], "excluded_user_ids": [] }, + "typing_indicator_rework": { + "enabled": false, + "config_version": 0, + "rollout_basis_points": 0, + "rollout_salt": "typing-indicator-rework-v1", + "included_user_ids": [], + "excluded_user_ids": [] + }, "voice_noise_suppression": { "enabled": false, "config_version": 0, diff --git a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts index 7546d8b8d..07b731520 100644 --- a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts @@ -38,6 +38,7 @@ import {GuildActivityLogPresentationConfigSchema} from '@fluxer/schema/src/domai import {GuildHeaderCollapseConfigSchema} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas'; import {MessageHoverTrackingConfigSchema} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas'; import {MessageKeyboardFocusConfigSchema} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; +import {TypingIndicatorReworkConfigSchema} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas'; import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas'; import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider'; import type {Context} from 'hono'; @@ -70,6 +71,7 @@ async function buildInstanceConfigResponse(): Promise { blockedMessageGroups, expressionInfoCard, guildHeaderCollapse, + typingIndicatorRework, registrationConfig, registrationUrls, pendingRegistrations, @@ -84,6 +86,7 @@ async function buildInstanceConfigResponse(): Promise { instanceConfigRepository.getBlockedMessageGroupsConfig(), instanceConfigRepository.getExpressionInfoCardConfig(), instanceConfigRepository.getGuildHeaderCollapseConfig(), + instanceConfigRepository.getTypingIndicatorReworkConfig(), instanceConfigRepository.getRegistrationConfig(), instanceConfigRepository.getRegistrationUrlsForAdmin(), instanceConfigRepository.getPendingRegistrations(), @@ -121,6 +124,7 @@ async function buildInstanceConfigResponse(): Promise { blocked_message_groups: blockedMessageGroups, expression_info_card: expressionInfoCard, guild_header_collapse: guildHeaderCollapse, + typing_indicator_rework: typingIndicatorRework, registration: { ...registrationConfig, urls: registrationUrls, @@ -337,6 +341,18 @@ export function InstanceConfigAdminController(app: HonoApp) { await instanceConfigRepository.setGuildHeaderCollapseConfig(validated); } } + if (data.typing_indicator_rework) { + const patch = omitUndefinedFields(data.typing_indicator_rework); + if (Object.keys(patch).length > 0) { + const currentTypingIndicatorRework = await instanceConfigRepository.getTypingIndicatorReworkConfig(); + const validated = TypingIndicatorReworkConfigSchema.parse({ + ...currentTypingIndicatorRework, + ...patch, + config_version: currentTypingIndicatorRework.config_version + 1, + }); + await instanceConfigRepository.setTypingIndicatorReworkConfig(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 960f58523..038f470bb 100644 --- a/fluxer_api/src/api/experiment/ExperimentController.ts +++ b/fluxer_api/src/api/experiment/ExperimentController.ts @@ -16,6 +16,7 @@ import {resolveGuildActivityLogPresentationAssignment} from '@fluxer/schema/src/ import {resolveGuildHeaderCollapseAssignment} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas'; import {resolveMessageHoverTrackingAssignment} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas'; import {resolveMessageKeyboardFocusAssignment} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; +import {resolveTypingIndicatorReworkAssignment} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas'; export function ExperimentController(app: HonoApp) { app.get( @@ -43,6 +44,7 @@ export function ExperimentController(app: HonoApp) { guildActivityLogPresentationConfig, expressionInfoCardConfig, guildHeaderCollapseConfig, + typingIndicatorReworkConfig, ] = await Promise.all([ instanceConfigRepository.getExperimentDeliveryConfig(), instanceConfigRepository.getVoiceNoiseSuppressionConfig(), @@ -52,6 +54,7 @@ export function ExperimentController(app: HonoApp) { instanceConfigRepository.getGuildActivityLogPresentationConfig(), instanceConfigRepository.getExpressionInfoCardConfig(), instanceConfigRepository.getGuildHeaderCollapseConfig(), + instanceConfigRepository.getTypingIndicatorReworkConfig(), ]); const userId = ctx.get('user').id.toString(); const body: ExperimentAssignmentsResponse = { @@ -68,6 +71,7 @@ export function ExperimentController(app: HonoApp) { ), expression_info_card: resolveExpressionInfoCardAssignment(expressionInfoCardConfig, userId), guild_header_collapse: resolveGuildHeaderCollapseAssignment(guildHeaderCollapseConfig, userId), + typing_indicator_rework: resolveTypingIndicatorReworkAssignment(typingIndicatorReworkConfig, 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 d3cdf8ff0..f5ade140d 100644 --- a/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts +++ b/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts @@ -25,6 +25,7 @@ import { readGuildHeaderCollapseAssignment, readMessageHoverTrackingAssignment, readMessageKeyboardFocusAssignment, + readTypingIndicatorReworkAssignment, readVoiceNoiseSuppressionAssignment, } from '@fluxer/schema/src/domains/experiment/ExperimentSchemas'; import { @@ -47,6 +48,10 @@ import { DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG, INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT, } from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; +import { + DEFAULT_TYPING_INDICATOR_REWORK_CONFIG, + INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT, +} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas'; import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; const NOT_MODIFIED = 304; @@ -87,6 +92,7 @@ describe('GET /experiments', () => { guild_activity_log_presentation: INERT_GUILD_ACTIVITY_LOG_PRESENTATION_ASSIGNMENT, expression_info_card: INERT_EXPRESSION_INFO_CARD_ASSIGNMENT, guild_header_collapse: INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT, + typing_indicator_rework: INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT, }, }); }); @@ -416,7 +422,81 @@ describe('GET /experiments', () => { }); }); - it('resolves all seven experiments independently', async () => { + it('populates the typing indicator rework 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, 'typing_indicator_rework')).toBe(true); + expect(readTypingIndicatorReworkAssignment(body)).toEqual(INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT); + }); + + it('targets an allowlisted account for typing indicator rework', async () => { + const account = await createTestAccount(harness); + await getInstanceConfigRepository().setTypingIndicatorReworkConfig({ + ...DEFAULT_TYPING_INDICATOR_REWORK_CONFIG, + enabled: true, + config_version: 6, + included_user_ids: [account.userId], + }); + + const body = await createBuilder(harness, account.token).get(ENDPOINT).execute(); + + expect(readTypingIndicatorReworkAssignment(body)).toEqual({ + enabled: true, + config_version: 6, + user_targeted: true, + source: 'user_rule', + }); + }); + + it('leaves an account outside a zero-width typing indicator rework rollout', async () => { + const account = await createTestAccount(harness); + await getInstanceConfigRepository().setTypingIndicatorReworkConfig({ + ...DEFAULT_TYPING_INDICATOR_REWORK_CONFIG, + enabled: true, + config_version: 2, + }); + + const body = await createBuilder(harness, account.token).get(ENDPOINT).execute(); + + expect(readTypingIndicatorReworkAssignment(body)).toEqual({ + enabled: true, + config_version: 2, + user_targeted: false, + source: null, + }); + }); + + it('serves a fresh body once the typing indicator rework config changes', async () => { + const account = await createTestAccount(harness); + + const first = await createBuilder(harness, account.token) + .get(ENDPOINT) + .executeWithResponse(); + const staleEtag = first.response.headers.get('etag') as string; + + await getInstanceConfigRepository().setTypingIndicatorReworkConfig({ + ...DEFAULT_TYPING_INDICATOR_REWORK_CONFIG, + enabled: true, + config_version: 1, + rollout_basis_points: 10000, + }); + + const refreshed = await createBuilder(harness, account.token) + .get(ENDPOINT) + .header('If-None-Match', staleEtag) + .executeWithResponse(); + expect(refreshed.response.status).toBe(HTTP_STATUS.OK); + expect(refreshed.response.headers.get('etag')).not.toBe(staleEtag); + expect(refreshed.json?.assignments.typing_indicator_rework).toMatchObject({ + enabled: true, + config_version: 1, + user_targeted: true, + }); + }); + + it('resolves all eight experiments independently', async () => { const account = await createTestAccount(harness); await getInstanceConfigRepository().setMessageHoverTrackingConfig({ ...DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG, @@ -448,6 +528,11 @@ describe('GET /experiments', () => { enabled: true, rollout_basis_points: 10000, }); + await getInstanceConfigRepository().setTypingIndicatorReworkConfig({ + ...DEFAULT_TYPING_INDICATOR_REWORK_CONFIG, + enabled: true, + rollout_basis_points: 10000, + }); const body = await createBuilder(harness, account.token).get(ENDPOINT).execute(); @@ -457,6 +542,7 @@ describe('GET /experiments', () => { expect(readGuildActivityLogPresentationAssignment(body).user_targeted).toBe(true); expect(readExpressionInfoCardAssignment(body).user_targeted).toBe(true); expect(readGuildHeaderCollapseAssignment(body).user_targeted).toBe(true); + expect(readTypingIndicatorReworkAssignment(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 173bb79df..5bf712b1d 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts @@ -41,6 +41,10 @@ import { DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG, type MessageKeyboardFocusConfig, } from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; +import { + DEFAULT_TYPING_INDICATOR_REWORK_CONFIG, + type TypingIndicatorReworkConfig, +} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas'; import {afterEach, describe, expect, it, vi} from 'vitest'; const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config'; @@ -50,6 +54,7 @@ const BLOCKED_MESSAGE_GROUPS_CONFIG_KEY = 'blocked_message_groups_config'; const GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG_KEY = 'guild_activity_log_presentation_config'; const EXPRESSION_INFO_CARD_CONFIG_KEY = 'expression_info_card_config'; const GUILD_HEADER_COLLAPSE_CONFIG_KEY = 'guild_header_collapse_config'; +const TYPING_INDICATOR_REWORK_CONFIG_KEY = 'typing_indicator_rework_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'; @@ -635,6 +640,70 @@ describe('InstanceConfigRepository', () => { }); }); + it('returns the default typing indicator rework config when the key is absent', async () => { + const executor = new CountingInMemoryCassandraQueryExecutor(); + setCassandraQueryExecutorForTesting(executor); + const kvProvider = new MockKVProvider(); + const repository = createRepository(kvProvider); + + await expect(repository.getTypingIndicatorReworkConfig()).resolves.toEqual(DEFAULT_TYPING_INDICATOR_REWORK_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 typing indicator rework config for $name', async ({stored}) => { + const executor = new CountingInMemoryCassandraQueryExecutor(); + setCassandraQueryExecutorForTesting(executor); + const kvProvider = new MockKVProvider(); + const repository = createRepository(kvProvider); + + await repository.setConfig(TYPING_INDICATOR_REWORK_CONFIG_KEY, stored); + + await expect(repository.getTypingIndicatorReworkConfig()).resolves.toEqual(DEFAULT_TYPING_INDICATOR_REWORK_CONFIG); + }); + + it('round-trips a stored typing indicator rework config', async () => { + const executor = new CountingInMemoryCassandraQueryExecutor(); + setCassandraQueryExecutorForTesting(executor); + const kvProvider = new MockKVProvider(); + const repository = createRepository(kvProvider); + + const config: TypingIndicatorReworkConfig = { + ...DEFAULT_TYPING_INDICATOR_REWORK_CONFIG, + enabled: true, + config_version: 5, + rollout_basis_points: 1500, + rollout_salt: 'typing-indicator-rework-v2', + included_user_ids: ['1400000000000000001'], + excluded_user_ids: ['1400000000000000002'], + }; + await repository.setTypingIndicatorReworkConfig(config); + + await expect(repository.getTypingIndicatorReworkConfig()).resolves.toEqual(config); + }); + + it('fills newly added typing indicator rework fields from the schema defaults', async () => { + const executor = new CountingInMemoryCassandraQueryExecutor(); + setCassandraQueryExecutorForTesting(executor); + const kvProvider = new MockKVProvider(); + const repository = createRepository(kvProvider); + + await repository.setConfig( + TYPING_INDICATOR_REWORK_CONFIG_KEY, + JSON.stringify({enabled: true, config_version: 2, rollout_basis_points: 1000}), + ); + + await expect(repository.getTypingIndicatorReworkConfig()).resolves.toEqual({ + ...DEFAULT_TYPING_INDICATOR_REWORK_CONFIG, + enabled: true, + config_version: 2, + rollout_basis_points: 1000, + }); + }); + 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 d231ab160..f05fba0c1 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.ts @@ -60,6 +60,10 @@ import { type MessageKeyboardFocusConfig, MessageKeyboardFocusConfigSchema, } from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; +import { + type TypingIndicatorReworkConfig, + TypingIndicatorReworkConfigSchema, +} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas'; import { type InstanceAppPublic, InstanceAppPublicSchema, @@ -85,6 +89,7 @@ const MESSAGE_KEYBOARD_FOCUS_CONFIG_KEY = 'message_keyboard_focus_config'; const BLOCKED_MESSAGE_GROUPS_CONFIG_KEY = 'blocked_message_groups_config'; const EXPRESSION_INFO_CARD_CONFIG_KEY = 'expression_info_card_config'; const GUILD_HEADER_COLLAPSE_CONFIG_KEY = 'guild_header_collapse_config'; +const TYPING_INDICATOR_REWORK_CONFIG_KEY = 'typing_indicator_rework_config'; const REGISTRATION_CONFIG_KEY = 'registration_config'; const REGISTRATION_URLS_KEY = 'registration_urls'; const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals'; @@ -374,6 +379,7 @@ type StoredConfigSection = | 'blocked message groups' | 'expression info card' | 'guild header collapse' + | 'typing indicator rework' | 'instance policy' | 'integrations' | 'media' @@ -539,6 +545,10 @@ function parseStoredGuildHeaderCollapseConfig(raw: string | null): GuildHeaderCo return parseStoredConfigOrDefault(GuildHeaderCollapseConfigSchema, raw, 'guild header collapse'); } +function parseStoredTypingIndicatorReworkConfig(raw: string | null): TypingIndicatorReworkConfig { + return parseStoredConfigOrDefault(TypingIndicatorReworkConfigSchema, raw, 'typing indicator rework'); +} + function validateStoredCollection(schema: z.ZodType, value: unknown, section: StoredConfigSection): Array { if (!Array.isArray(value)) { throw new Error(`Stored ${section} configuration must be an array`); @@ -1064,6 +1074,7 @@ export class InstanceConfigRepository { parseStoredBlockedMessageGroupsConfig(snapshot.get(BLOCKED_MESSAGE_GROUPS_CONFIG_KEY) ?? null); parseStoredExpressionInfoCardConfig(snapshot.get(EXPRESSION_INFO_CARD_CONFIG_KEY) ?? null); parseStoredGuildHeaderCollapseConfig(snapshot.get(GUILD_HEADER_COLLAPSE_CONFIG_KEY) ?? null); + parseStoredTypingIndicatorReworkConfig(snapshot.get(TYPING_INDICATOR_REWORK_CONFIG_KEY) ?? null); const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null); checkStoredConfig('registration', () => parseStoredRegistrationConfig(snapshot.get(REGISTRATION_CONFIG_KEY) ?? null), @@ -1213,6 +1224,16 @@ export class InstanceConfigRepository { await this.setConfig(GUILD_HEADER_COLLAPSE_CONFIG_KEY, JSON.stringify(validated)); } + async getTypingIndicatorReworkConfig(): Promise { + const raw = await this.getConfig(TYPING_INDICATOR_REWORK_CONFIG_KEY); + return parseStoredTypingIndicatorReworkConfig(raw); + } + + async setTypingIndicatorReworkConfig(config: TypingIndicatorReworkConfig): Promise { + const validated = validateStoredConfig(TypingIndicatorReworkConfigSchema, config, 'typing indicator rework'); + await this.setConfig(TYPING_INDICATOR_REWORK_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 230bc77ee..16672b05e 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -28320,7 +28320,8 @@ "$ref": "#/components/schemas/GuildActivityLogPresentationAssignmentResponse" }, "expression_info_card": {"$ref": "#/components/schemas/ExpressionInfoCardAssignmentResponse"}, - "guild_header_collapse": {"$ref": "#/components/schemas/GuildHeaderCollapseAssignmentResponse"} + "guild_header_collapse": {"$ref": "#/components/schemas/GuildHeaderCollapseAssignmentResponse"}, + "typing_indicator_rework": {"$ref": "#/components/schemas/TypingIndicatorReworkAssignmentResponse"} }, "additionalProperties": false } @@ -32075,6 +32076,17 @@ "additionalProperties": false, "description": "Public presentation of the source guild of an expression" }, + "TypingIndicatorReworkAssignmentResponse": { + "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 + }, "GuildHeaderCollapseAssignmentResponse": { "type": "object", "properties": { diff --git a/fluxer_app/src/app/App.tsx b/fluxer_app/src/app/App.tsx index aea0a8a39..669b964bb 100644 --- a/fluxer_app/src/app/App.tsx +++ b/fluxer_app/src/app/App.tsx @@ -45,6 +45,7 @@ import Theme from '@app/features/theme/state/Theme'; import ThemeLibrary from '@app/features/theme/state/ThemeLibrary'; import {useThemeStudioBroadcast} from '@app/features/theme_studio/state/ThemeStudioBroadcast'; import ThemeStudioState from '@app/features/theme_studio/state/ThemeStudioState'; +import TypingPolicy from '@app/features/typing/state/TypingPolicy'; import {SVGMasks} from '@app/features/ui/components/SVGMasks'; import FocusRingScope from '@app/features/ui/focus_ring/FocusRingScope'; import {useTextInputContextMenu} from '@app/features/ui/hooks/useTextInputContextMenu'; @@ -125,8 +126,12 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => { const handleSkipLinkFocus = useTabKeyFocusGuard(); useInertBackground(ringsContainerRef, hasBlockingModal || topPopoutRequiresBackdrop); useEffect(() => { + TypingPolicy.start(); showMyselfTypingHelper.start(); - return () => showMyselfTypingHelper.stop(); + return () => { + showMyselfTypingHelper.stop(); + TypingPolicy.stop(); + }; }, []); useEffect( () => diff --git a/fluxer_app/src/features/channel/components/LegacyTypingUsers.tsx b/fluxer_app/src/features/channel/components/LegacyTypingUsers.tsx new file mode 100644 index 000000000..e85ec6b57 --- /dev/null +++ b/fluxer_app/src/features/channel/components/LegacyTypingUsers.tsx @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Authentication from '@app/features/auth/state/Authentication'; +import {Typing} from '@app/features/channel/components/ChannelTyping'; +import styles from '@app/features/channel/components/TypingUsers.module.css'; +import type {Channel} from '@app/features/channel/models/Channel'; +import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions'; +import GuildMembers from '@app/features/member/state/GuildMembers'; +import Relationships from '@app/features/relationship/state/Relationships'; +import messageStyles from '@app/features/theme/styles/Message.module.css'; +import TypingIndicator from '@app/features/typing/legacy/LegacyTypingIndicator'; +import {getTypingTierText} from '@app/features/typing/utils/TypingTierText'; +import {AvatarStack} from '@app/features/ui/avatars/AvatarStack'; +import type {User} from '@app/features/user/models/User'; +import Users from '@app/features/user/state/Users'; +import * as NicknameUtils from '@app/features/user/utils/NicknameUtils'; +import type {I18n} from '@lingui/core'; +import {Trans, useLingui} from '@lingui/react/macro'; +import {observer} from 'mobx-react-lite'; + +const getDisplayName = (user: User, guildId?: string | null) => NicknameUtils.getNickname(user, guildId ?? null); +export const getTypingText = (i18n: I18n, typingUsers: ReadonlyArray, channel: Channel) => { + const [a, b, c] = typingUsers.map((user) => { + const member = GuildMembers.getMember(channel.guildId ?? '', user.id); + return ( + + {getDisplayName(user, channel.guildId)} + + ); + }); + if (typingUsers.length === 1) { + return {a} is typing...; + } + if (typingUsers.length === 2) { + return ( + + {a} and {b} are typing... + + ); + } + if (typingUsers.length === 3) { + return ( + + {a}, {b} and {c} are typing... + + ); + } + return getTypingTierText(i18n, typingUsers.length); +}; +const EMPTY_TYPING_USER_RECORDS: ReadonlyArray = Object.freeze([]); +export const usePresentableTypingUsers = (channel: Channel): ReadonlyArray => { + const typingUserIds = TypingIndicator.getTypingUsers(channel.id); + if (typingUserIds.length === 0) return EMPTY_TYPING_USER_RECORDS; + const currentUserId = Authentication.currentUserId; + const showSelf = DeveloperOptions.showMyselfTyping; + const result: Array = []; + for (const userId of typingUserIds) { + if (!showSelf && userId === currentUserId) continue; + if (Relationships.isBlocked(userId)) continue; + const user = Users.getUser(userId); + if (user) result.push(user); + } + if (result.length === 0) return EMPTY_TYPING_USER_RECORDS; + return result; +}; +const AVATAR_THRESHOLD = 5; +export const TypingUsers = observer( + ({channel, withText = true, showAvatars = true}: {channel: Channel; withText?: boolean; showAvatars?: boolean}) => { + const {i18n} = useLingui(); + const typingUsers = usePresentableTypingUsers(channel); + if (typingUsers.length === 0) { + return null; + } + return ( +
+
+
+ +
+ {withText && ( + <> + {showAvatars && ( + + )} + + {getTypingText(i18n, typingUsers, channel)} + + + )} +
+
+ ); + }, +); diff --git a/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx b/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx index 9341b0a97..1c5cb1a97 100644 --- a/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx +++ b/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx @@ -33,7 +33,7 @@ import { import {MessageCharacterCounter} from '@app/features/channel/components/MessageCharacterCounter'; import {SlashCommandParamBar} from '@app/features/channel/components/SlashCommandParamBar'; import {SlowmodeIndicator} from '@app/features/channel/components/SlowmodeIndicator'; -import {TypingUsers, usePresentableTypingUsers} from '@app/features/channel/components/TypingUsers'; +import {TypingAnnouncer, TypingUsers, usePresentableTypingUsers} from '@app/features/channel/components/TypingUsers'; import wrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css'; import {MobileTextareaPlusBottomSheet} from '@app/features/channel/components/textarea/MobileTextareaPlusBottomSheet'; import {TextareaButton} from '@app/features/channel/components/textarea/TextareaButton'; @@ -190,6 +190,7 @@ export const LexicalChannelTextareaContent = observer( const expressionPickerTriggerRef = useRef(null); const invisibleExpressionPickerTriggerRef = useRef(null); const containerRef = useRef(null); + const typingStatusRailLeftRef = useRef(null); const contentAreaRef = useRef(null); const plusButtonRef = useRef(null); const plusMenuOpenedAtRef = useRef(0); @@ -1246,6 +1247,7 @@ export const LexicalChannelTextareaContent = observer( data-flx="channel.lexical-channel-textarea-content.flx-channel-textarea-status-rail" > @@ -1258,10 +1260,12 @@ export const LexicalChannelTextareaContent = observer( channel={channel} withText={true} showAvatars={true} + overflowContainerRef={typingStatusRailLeftRef} data-flx="channel.lexical-channel-textarea-content.typing-users" /> )} + {isSlowmodeIndicatorVisible && ( { + const {observable} = await import('mobx'); + return { + auth: observable({currentUserId: 'me' as string | null}), + developerOptions: observable({showMyselfTyping: false}), + blockedUserIds: observable.set(), + users: observable.map(), + }; +}); + +vi.mock('@lingui/core/macro', () => ({msg: (descriptor: {message: string}) => descriptor})); +vi.mock('@lingui/react/macro', async () => { + const {createElement: create, Fragment: ReactFragment} = await import('react'); + return { + Trans: ({children}: {children?: ReactNode}) => create(ReactFragment, null, children), + useLingui: () => ({ + i18n: { + locale: 'en-US', + _: (descriptor: {message: string}, values: Record = {}) => + descriptor.message.replace(/\{(\w+)\}/g, (_placeholder, name: string) => values[name] ?? ''), + }, + }), + }; +}); +vi.mock('@app/features/auth/state/Authentication', () => ({default: doubles.auth})); +vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: doubles.developerOptions})); +vi.mock('@app/features/relationship/state/Relationships', () => ({ + default: {isBlocked: (userId: string) => doubles.blockedUserIds.has(userId)}, +})); +vi.mock('@app/features/user/state/Users', () => ({ + default: {getUser: (userId: string) => doubles.users.get(userId)}, +})); +vi.mock('@app/features/user/utils/NicknameUtils', () => ({ + getNickname: (user: {username: string}) => user.username, +})); +vi.mock('@app/features/member/state/GuildMembers', () => ({default: {getMember: () => undefined}})); +vi.mock('@app/features/channel/components/ChannelTyping', async () => { + const {createElement: create} = await import('react'); + return {Typing: () => create('svg')}; +}); +vi.mock('@app/features/ui/avatars/AvatarStack', async () => { + const {createElement: create} = await import('react'); + return { + AvatarStack: ({className, users}: {className?: string; users: ReadonlyArray}) => + create('div', {className, 'data-avatar-count': users.length}), + }; +}); + +const {RollingTypingAnnouncer, RollingTypingUsers} = await import( + '@app/features/channel/components/RollingTypingUsers' +); +const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore'); + +class StubResizeObserver { + static instances: Array = []; + readonly targets: Array = []; + disconnected = false; + + constructor(private readonly callback: () => void) { + StubResizeObserver.instances.push(this); + } + + observe(target: Element): void { + this.targets.push(target); + } + + disconnect(): void { + this.disconnected = true; + } + + trigger(): void { + this.callback(); + } +} + +const CHANNEL = {id: 'channel', guildId: 'guild'} as unknown as Channel; +const TYPIST_IDS = ['alice', 'bob', 'carol', 'dave', 'erin']; + +let host: HTMLDivElement; +let root: Root; +let overflowContainer: HTMLElement; +let overflowContainerRef: {current: HTMLElement | null}; + +function setWidth(element: Element, property: 'clientWidth' | 'scrollWidth', width: number): void { + Object.defineProperty(element, property, {configurable: true, value: width}); +} + +function render(node: ReactNode): void { + act(() => { + root.render(node); + }); +} + +function renderRow(props: {showAvatars?: boolean} = {}): void { + render( + createElement( + Fragment, + null, + createElement(RollingTypingUsers, {channel: CHANNEL, overflowContainerRef, showAvatars: props.showAvatars}), + createElement(RollingTypingAnnouncer, {channel: CHANNEL}), + ), + ); +} + +function startTyping(...userIds: Array): void { + act(() => { + for (const userId of userIds) { + RollingTypingStore.start(CHANNEL.id, userId, userId === 'me' ? 'local' : 'gateway'); + } + }); +} + +function query(flx: string): HTMLElement | null { + return host.querySelector(`[data-flx="${flx}"]`); +} + +function visibleText(): string | null { + return query('channel.typing-users.span')?.textContent ?? null; +} + +function announcer(): HTMLElement { + return query('channel.rolling-typing-users.announcer')!; +} + +function measure(): HTMLElement | null { + return query('channel.rolling-typing-users.measure'); +} + +function activeResizeObservers(): Array { + return StubResizeObserver.instances.filter((instance) => !instance.disconnected); +} + +function remeasure(): void { + act(() => { + for (const instance of activeResizeObservers()) { + instance.trigger(); + } + }); +} + +beforeEach(() => { + (globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true; + StubResizeObserver.instances = []; + vi.stubGlobal('ResizeObserver', StubResizeObserver); + host = document.createElement('div'); + overflowContainer = document.createElement('div'); + document.body.append(overflowContainer, host); + setWidth(overflowContainer, 'clientWidth', 1000); + overflowContainerRef = {current: overflowContainer}; + root = createRoot(host); + runInAction(() => { + doubles.auth.currentUserId = 'me'; + doubles.developerOptions.showMyselfTyping = false; + doubles.blockedUserIds.clear(); + doubles.users.clear(); + for (const id of ['me', ...TYPIST_IDS]) { + doubles.users.set(id, {id, username: id}); + } + }); +}); + +afterEach(() => { + act(() => { + root.unmount(); + }); + RollingTypingStore.reset(); + document.body.replaceChildren(); + vi.unstubAllGlobals(); +}); + +describe('RollingTypingUsers', () => { + it('hides the current user from the typing row', () => { + renderRow(); + startTyping('me', 'alice'); + + expect(visibleText()).toBe('alice is typing...'); + expect(host.querySelector('[data-avatar-count]')?.getAttribute('data-avatar-count')).toBe('1'); + }); + + it('shows the current user when the developer option is on', () => { + runInAction(() => { + doubles.developerOptions.showMyselfTyping = true; + }); + renderRow(); + startTyping('me', 'alice'); + + expect(visibleText()).toBe('me and alice are typing...'); + }); + + it('hides blocked users from the typing row', () => { + runInAction(() => { + doubles.blockedUserIds.add('bob'); + }); + renderRow(); + startTyping('bob', 'alice'); + + expect(visibleText()).toBe('alice is typing...'); + }); + + it('skips typists missing from the user cache', () => { + renderRow(); + startTyping('stranger', 'alice'); + + expect(RollingTypingStore.countTypists(CHANNEL.id)).toBe(2); + expect(visibleText()).toBe('alice is typing...'); + }); + + it('renders nothing visible when only self is typing', () => { + renderRow(); + startTyping('me'); + + expect(query('channel.typing-users.div')).toBeNull(); + expect(announcer().textContent).toBe(''); + }); + + it('marks the visible text as hidden from assistive technology', () => { + renderRow(); + startTyping('alice'); + + const visible = query('channel.typing-users.span')!; + expect(visible.getAttribute('aria-hidden')).toBe('true'); + expect(visible.hasAttribute('aria-live')).toBe(false); + expect(measure()?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('keeps the announcer mounted when nobody is typing', () => { + renderRow(); + const mounted = announcer(); + expect(mounted.textContent).toBe(''); + + startTyping('alice'); + expect(announcer()).toBe(mounted); + expect(mounted.textContent).toBe('alice is typing...'); + + act(() => { + RollingTypingStore.remove(CHANNEL.id, 'alice'); + }); + expect(announcer()).toBe(mounted); + expect(mounted.textContent).toBe(''); + }); + + it('announces through a polite atomic live region', () => { + renderRow(); + startTyping('alice', 'bob', 'carol', 'dave'); + + expect(announcer().getAttribute('aria-live')).toBe('polite'); + expect(announcer().getAttribute('aria-atomic')).toBe('true'); + expect(announcer().textContent).toBe('Several people are typing...'); + }); + + it('swaps to multiple people when the measured text plus 48 px exceeds the available width', () => { + setWidth(overflowContainer, 'clientWidth', 300); + renderRow({showAvatars: false}); + startTyping('alice', 'bob'); + expect(visibleText()).toBe('alice and bob are typing...'); + + setWidth(measure()!, 'scrollWidth', 253); + remeasure(); + + expect(visibleText()).toBe('Multiple people are typing...'); + expect(measure()?.textContent).toBe('alice and bob are typing...'); + }); + + it('keeps the names when they fit with 48 px to spare', () => { + setWidth(overflowContainer, 'clientWidth', 300); + renderRow({showAvatars: false}); + startTyping('alice', 'bob'); + + setWidth(measure()!, 'scrollWidth', 252); + remeasure(); + + expect(visibleText()).toBe('alice and bob are typing...'); + }); + + it('keeps the full string in the announcer while the visible text overflows', () => { + setWidth(overflowContainer, 'clientWidth', 300); + renderRow({showAvatars: false}); + startTyping('alice', 'bob', 'carol'); + + setWidth(measure()!, 'scrollWidth', 400); + remeasure(); + + expect(visibleText()).toBe('Multiple people are typing...'); + expect(announcer().textContent).toBe('alice, bob and carol are typing...'); + }); + + it('restores the names when the container grows again', () => { + setWidth(overflowContainer, 'clientWidth', 300); + renderRow({showAvatars: false}); + startTyping('alice'); + setWidth(measure()!, 'scrollWidth', 280); + remeasure(); + expect(visibleText()).toBe('Multiple people are typing...'); + + setWidth(overflowContainer, 'clientWidth', 400); + remeasure(); + + expect(visibleText()).toBe('alice is typing...'); + }); + + it('does not measure overflow with four or more typists', () => { + setWidth(overflowContainer, 'clientWidth', 10); + renderRow(); + startTyping('alice', 'bob', 'carol'); + expect(visibleText()).toBe('Multiple people are typing...'); + expect(activeResizeObservers()).toHaveLength(1); + + startTyping('dave'); + + expect(visibleText()).toBe('Several people are typing...'); + expect(measure()).toBeNull(); + expect(activeResizeObservers()).toHaveLength(0); + }); + + it('subtracts the avatar stack width from the available width', () => { + setWidth(overflowContainer, 'clientWidth', 300); + renderRow({showAvatars: true}); + startTyping('alice'); + setWidth(measure()!, 'scrollWidth', 200); + remeasure(); + expect(visibleText()).toBe('alice is typing...'); + + const avatarStack = host.querySelector(`.${messageStyles.typingAvatarContainer}`)!; + avatarStack.getBoundingClientRect = () => ({width: 60}) as DOMRect; + remeasure(); + + expect(visibleText()).toBe('Multiple people are typing...'); + }); + + it('disconnects the resize observer on unmount', () => { + renderRow(); + startTyping('alice'); + const [resizeObserver] = activeResizeObservers(); + expect(resizeObserver?.targets).toEqual([overflowContainer, measure()]); + + act(() => { + root.unmount(); + }); + root = createRoot(host); + + expect(resizeObserver?.disconnected).toBe(true); + expect(activeResizeObservers()).toHaveLength(0); + }); + + it("does not re-render on the current user's own typing", () => { + const onRender = vi.fn(); + render( + createElement( + Profiler, + {id: 'typing-row', onRender}, + createElement(RollingTypingUsers, {channel: CHANNEL, overflowContainerRef}), + ), + ); + startTyping('alice'); + const commits = onRender.mock.calls.length; + + startTyping('me'); + act(() => { + RollingTypingStore.start(CHANNEL.id, 'me', 'gateway'); + RollingTypingStore.start(CHANNEL.id, 'alice', 'gateway'); + }); + act(() => { + RollingTypingStore.remove(CHANNEL.id, 'me'); + }); + + expect(onRender).toHaveBeenCalledTimes(commits); + expect(visibleText()).toBe('alice is typing...'); + }); +}); diff --git a/fluxer_app/src/features/channel/components/RollingTypingUsers.tsx b/fluxer_app/src/features/channel/components/RollingTypingUsers.tsx new file mode 100644 index 000000000..22c665ad9 --- /dev/null +++ b/fluxer_app/src/features/channel/components/RollingTypingUsers.tsx @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Typing} from '@app/features/channel/components/ChannelTyping'; +import rollingStyles from '@app/features/channel/components/RollingTypingUsers.module.css'; +import styles from '@app/features/channel/components/TypingUsers.module.css'; +import type {Channel} from '@app/features/channel/models/Channel'; +import messageStyles from '@app/features/theme/styles/Message.module.css'; +import RollingTypingStore from '@app/features/typing/rolling/RollingTypingStore'; +import {getRollingTypingAnnouncement, getRollingTypingText} from '@app/features/typing/rolling/RollingTypingText'; +import { + TYPING_ROLLING_MAX_NAMES, + TYPING_ROLLING_OVERFLOW_SLACK_PX, +} from '@app/features/typing/rolling/TypingSendThrottle'; +import {AvatarStack} from '@app/features/ui/avatars/AvatarStack'; +import type {User} from '@app/features/user/models/User'; +import Users from '@app/features/user/state/Users'; +import {useLingui} from '@lingui/react/macro'; +import {observer} from 'mobx-react-lite'; +import {type RefObject, useLayoutEffect, useRef, useState} from 'react'; + +const AVATAR_THRESHOLD = 5; +const EMPTY_TYPING_USER_RECORDS: ReadonlyArray = Object.freeze([]); + +export const rollingUsePresentableTypingUsers = (channel: Channel): ReadonlyArray => { + const typingUserIds = RollingTypingStore.getTypingUserIds(channel.id); + if (typingUserIds.length === 0) { + return EMPTY_TYPING_USER_RECORDS; + } + const typingUsers: Array = []; + for (const userId of typingUserIds) { + const user = Users.getUser(userId); + if (user) { + typingUsers.push(user); + } + } + return typingUsers; +}; + +function getRenderedAvatarStackWidth(row: HTMLElement | null): number { + if (row === null) { + return 0; + } + for (const child of row.children) { + if (child.classList.contains(messageStyles.typingAvatarContainer)) { + return child.getBoundingClientRect().width; + } + } + return 0; +} + +interface RollingTypingUsersProps { + channel: Channel; + withText?: boolean; + showAvatars?: boolean; + overflowContainerRef?: RefObject; +} + +export const RollingTypingUsers = observer( + ({channel, withText = true, showAvatars = true, overflowContainerRef}: RollingTypingUsersProps) => { + const {i18n} = useLingui(); + const typingUsers = rollingUsePresentableTypingUsers(channel); + const rowRef = useRef(null); + const measureRef = useRef(null); + const [overflowing, setOverflowing] = useState(false); + const measuresOverflow = withText && typingUsers.length > 0 && typingUsers.length <= TYPING_ROLLING_MAX_NAMES; + useLayoutEffect(() => { + const container = overflowContainerRef?.current ?? null; + const measure = measureRef.current; + if (!measuresOverflow || container === null || measure === null) { + return; + } + const evaluate = () => { + const availableWidth = container.clientWidth - getRenderedAvatarStackWidth(rowRef.current); + setOverflowing(measure.scrollWidth + TYPING_ROLLING_OVERFLOW_SLACK_PX > availableWidth); + }; + evaluate(); + const resizeObserver = new ResizeObserver(evaluate); + resizeObserver.observe(container); + resizeObserver.observe(measure); + return () => resizeObserver.disconnect(); + }, [measuresOverflow, overflowContainerRef]); + if (typingUsers.length === 0) { + return null; + } + return ( +
+
+
+ +
+ {withText && ( + <> + {showAvatars && ( + + )} + + {getRollingTypingText(i18n, typingUsers, channel, measuresOverflow && overflowing)} + + {measuresOverflow && ( + + {getRollingTypingText(i18n, typingUsers, channel, false)} + + )} + + )} +
+
+ ); + }, +); + +export const RollingTypingAnnouncer = observer(({channel}: {channel: Channel}) => { + const {i18n} = useLingui(); + const typingUsers = rollingUsePresentableTypingUsers(channel); + return ( + + {getRollingTypingAnnouncement(i18n, typingUsers, channel)} + + ); +}); diff --git a/fluxer_app/src/features/channel/components/TypingUsers.test.tsx b/fluxer_app/src/features/channel/components/TypingUsers.test.tsx new file mode 100644 index 000000000..c85263bda --- /dev/null +++ b/fluxer_app/src/features/channel/components/TypingUsers.test.tsx @@ -0,0 +1,235 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {Channel} from '@app/features/channel/models/Channel'; +import type {User} from '@app/features/user/models/User'; +import type {I18n} from '@lingui/core'; +import {act, createElement, Fragment, type ReactNode} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {renderToStaticMarkup} from 'react-dom/server'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const doubles = vi.hoisted(() => ({locale: 'en-US'})); + +vi.mock('@lingui/core/macro', () => ({msg: (descriptor: {message: string}) => descriptor})); +vi.mock('@lingui/react/macro', async () => { + const {createElement: create, Fragment: ReactFragment} = await import('react'); + return { + Trans: ({children}: {children?: ReactNode}) => create(ReactFragment, null, children), + useLingui: () => ({ + i18n: { + locale: doubles.locale, + _: (descriptor: {message: string}, values: Record = {}) => + descriptor.message.replace(/\{(\w+)\}/g, (_placeholder, name: string) => values[name] ?? ''), + }, + }), + }; +}); +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(() => Promise.resolve({ok: true}))}, +})); +vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: 'me'}})); +vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: {showMyselfTyping: false}})); +vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}})); +vi.mock('@app/features/user/state/Users', () => ({ + default: {getUser: (userId: string) => ({id: userId, username: userId})}, +})); +vi.mock('@app/features/user/utils/NicknameUtils', () => ({ + getNickname: (user: {username: string}) => user.username, +})); +vi.mock('@app/features/member/state/GuildMembers', () => ({ + default: {getMember: (_guildId: string, userId: string) => ({getColorString: () => `var(--member-${userId})`})}, +})); +vi.mock('@app/features/channel/components/ChannelTyping', async () => { + const {createElement: create} = await import('react'); + return {Typing: ({className}: {className?: string}) => create('svg', {className})}; +}); +vi.mock('@app/features/ui/avatars/AvatarStack', async () => { + const {createElement: create} = await import('react'); + return { + AvatarStack: ({className, users}: {className?: string; users: ReadonlyArray<{id: string}>}) => + create('div', {className, 'data-avatars': users.map((user) => user.id).join(' ')}), + }; +}); + +const {TypingAnnouncer, TypingUsers, getTypingText} = await import('@app/features/channel/components/TypingUsers'); +const {TypingUsers: LegacyTypingUsers} = await import('@app/features/channel/components/LegacyTypingUsers'); +const {default: TypingPolicy} = await import('@app/features/typing/state/TypingPolicy'); +const {default: LegacyTypingIndicator} = await import('@app/features/typing/legacy/LegacyTypingIndicator'); +const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore'); +const {getTypingTierText} = await import('@app/features/typing/utils/TypingTierText'); + +const CHANNEL = {id: 'channel', guildId: 'guild'} as unknown as Channel; +const TYPIST_IDS = ['alice', 'bob', 'carol', 'dave', 'erin', 'frank', 'grace']; + +let hosts: Array<{host: HTMLDivElement; root: Root}> = []; + +function mount(node: ReactNode): HTMLDivElement { + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + hosts.push({host, root}); + act(() => { + root.render(node); + }); + return host; +} + +function i18nFor(locale: string): I18n { + return {locale, _: (descriptor: {message: string}) => `${locale}: ${descriptor.message}`} as unknown as I18n; +} + +function usersFor(count: number): Array { + return TYPIST_IDS.slice(0, count).map((id) => ({id, username: id}) as unknown as User); +} + +function manyUsers(count: number): Array { + return Array.from( + {length: count}, + (_, index) => ({id: `typist-${index}`, username: `typist-${index}`}) as unknown as User, + ); +} + +function markupOf(node: ReactNode): string { + return renderToStaticMarkup(createElement(Fragment, null, node)); +} + +beforeEach(() => { + (globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true; + doubles.locale = 'en-US'; + vi.useFakeTimers(); +}); + +afterEach(() => { + act(() => { + for (const {root} of hosts) { + root.unmount(); + } + }); + hosts = []; + TypingPolicy.applyPolicy('legacy'); + LegacyTypingIndicator.reset(); + RollingTypingStore.reset(); + document.body.replaceChildren(); + vi.useRealTimers(); +}); + +describe('TypingUsers facade', () => { + it('renders the same markup as the legacy component under control', () => { + for (const count of [1, 2, 3, 4, 7]) { + LegacyTypingIndicator.reset(); + for (const userId of TYPIST_IDS.slice(0, count)) { + LegacyTypingIndicator.startRemoteTyping(CHANNEL.id, userId); + } + + const facadeHost = mount(createElement(TypingUsers, {channel: CHANNEL, withText: true, showAvatars: true})); + const legacyHost = mount(createElement(LegacyTypingUsers, {channel: CHANNEL, withText: true, showAvatars: true})); + + expect(facadeHost.innerHTML).not.toBe(''); + expect(facadeHost.innerHTML).toBe(legacyHost.innerHTML); + } + }); + + it('renders no announcer under control', () => { + LegacyTypingIndicator.startRemoteTyping(CHANNEL.id, 'alice'); + + const host = mount(createElement(TypingAnnouncer, {channel: CHANNEL})); + + expect(host.innerHTML).toBe(''); + expect(host.querySelector('[aria-live]')).toBeNull(); + }); + + it('renders the rolling row under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + RollingTypingStore.start(CHANNEL.id, 'alice', 'gateway'); + + const host = mount( + createElement( + Fragment, + null, + createElement(TypingUsers, {channel: CHANNEL, withText: true, showAvatars: true}), + createElement(TypingAnnouncer, {channel: CHANNEL}), + ), + ); + + const visible = host.querySelector('[data-flx="channel.typing-users.span"]'); + expect(visible?.textContent).toBe('alice is typing...'); + expect(visible?.getAttribute('aria-hidden')).toBe('true'); + expect(host.querySelector('[data-flx="channel.rolling-typing-users.measure"]')).not.toBeNull(); + expect(host.querySelector('[aria-live="polite"]')?.textContent).toBe('alice is typing...'); + }); + + it('switches rows when the policy flips', () => { + LegacyTypingIndicator.startRemoteTyping(CHANNEL.id, 'alice'); + const host = mount( + createElement( + Fragment, + null, + createElement(TypingUsers, {channel: CHANNEL}), + createElement(TypingAnnouncer, {channel: CHANNEL}), + ), + ); + expect(host.querySelector('[data-flx="channel.typing-users.span"]')?.getAttribute('aria-live')).toBe('polite'); + expect(host.querySelector('[data-flx="channel.rolling-typing-users.announcer"]')).toBeNull(); + + act(() => { + TypingPolicy.applyPolicy('rolling'); + }); + expect(host.querySelector('[data-flx="channel.typing-users.div"]')).toBeNull(); + expect(host.querySelector('[data-flx="channel.rolling-typing-users.announcer"]')?.textContent).toBe(''); + + act(() => { + RollingTypingStore.start(CHANNEL.id, 'bob', 'gateway'); + }); + expect(host.querySelector('[data-flx="channel.typing-users.span"]')?.getAttribute('aria-hidden')).toBe('true'); + expect(host.querySelector('[data-flx="channel.typing-users.span"]')?.textContent).toBe('bob is typing...'); + + act(() => { + TypingPolicy.applyPolicy('legacy'); + }); + expect(host.querySelector('[data-flx="channel.typing-users.div"]')).toBeNull(); + expect(host.querySelector('[data-flx="channel.rolling-typing-users.announcer"]')).toBeNull(); + }); + + it('keeps the one, two and three name forms identical in both arms', () => { + for (const count of [1, 2, 3]) { + const users = usersFor(count); + const legacyMarkup = markupOf(getTypingText(i18nFor('en-US'), users, CHANNEL)); + TypingPolicy.applyPolicy('rolling'); + const rollingMarkup = markupOf(getTypingText(i18nFor('en-US'), users, CHANNEL)); + TypingPolicy.applyPolicy('legacy'); + + expect(rollingMarkup).toBe(legacyMarkup); + } + expect(markupOf(getTypingText(i18nFor('en-US'), usersFor(3), CHANNEL)).replace(/<[^>]+>/g, '')).toBe( + 'alice, bob and carol are typing...', + ); + }); + + it('resolves the four or more ladder per locale identically in both arms', () => { + for (const locale of ['en-US', 'en-GB', 'de', 'fr', 'ja', 'pt-BR']) { + const i18n = i18nFor(locale); + for (const count of [4, 5, 9, 10, 14, 15, 19, 20, 31]) { + const users = manyUsers(count); + const expected = getTypingTierText(i18n, count); + const legacyText = getTypingText(i18n, users, CHANNEL); + TypingPolicy.applyPolicy('rolling'); + const rollingText = getTypingText(i18n, users, CHANNEL); + TypingPolicy.applyPolicy('legacy'); + + expect(legacyText, `${locale} ${count}`).toBe(expected); + expect(rollingText, `${locale} ${count}`).toBe(expected); + } + } + expect(getTypingText(i18nFor('en-GB'), manyUsers(12), CHANNEL)).toBe('A symphony of clacking keys is underway...'); + expect(getTypingText(i18nFor('de'), manyUsers(12), CHANNEL)).toBe('de: Several people are typing...'); + }); +}); diff --git a/fluxer_app/src/features/channel/components/TypingUsers.tsx b/fluxer_app/src/features/channel/components/TypingUsers.tsx index 11e3aab01..bba3a449a 100644 --- a/fluxer_app/src/features/channel/components/TypingUsers.tsx +++ b/fluxer_app/src/features/channel/components/TypingUsers.tsx @@ -1,160 +1,61 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import Authentication from '@app/features/auth/state/Authentication'; -import {Typing} from '@app/features/channel/components/ChannelTyping'; -import styles from '@app/features/channel/components/TypingUsers.module.css'; +import { + getTypingText as getLegacyTypingText, + TypingUsers as LegacyTypingUsers, + usePresentableTypingUsers as legacyUsePresentableTypingUsers, +} from '@app/features/channel/components/LegacyTypingUsers'; +import { + RollingTypingAnnouncer, + RollingTypingUsers, + rollingUsePresentableTypingUsers, +} from '@app/features/channel/components/RollingTypingUsers'; import type {Channel} from '@app/features/channel/models/Channel'; -import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions'; -import GuildMembers from '@app/features/member/state/GuildMembers'; -import Relationships from '@app/features/relationship/state/Relationships'; -import messageStyles from '@app/features/theme/styles/Message.module.css'; -import TypingIndicator from '@app/features/typing/state/TypingIndicator'; -import {AvatarStack} from '@app/features/ui/avatars/AvatarStack'; +import {getRollingTypingText} from '@app/features/typing/rolling/RollingTypingText'; +import TypingPolicy from '@app/features/typing/state/TypingPolicy'; import type {User} from '@app/features/user/models/User'; -import Users from '@app/features/user/state/Users'; -import * as NicknameUtils from '@app/features/user/utils/NicknameUtils'; import type {I18n} from '@lingui/core'; -import {msg} from '@lingui/core/macro'; -import {Trans, useLingui} from '@lingui/react/macro'; import {observer} from 'mobx-react-lite'; +import type {ReactNode, RefObject} from 'react'; -const SEVERAL_PEOPLE_ARE_TYPING_DESCRIPTOR = msg({ - message: 'Several people are typing...', - comment: 'Label in the channel and chat typing users.', -}); -const A_HANDFUL_OF_KEYBOARD_WARRIORS_ARE_ASSEMBLING_DESCRIPTOR = msg({ - message: 'A handful of keyboard warriors are assembling...', - comment: 'Description text in the channel and chat typing users.', -}); -const A_SYMPHONY_OF_CLACKING_KEYS_IS_UNDERWAY_DESCRIPTOR = msg({ - message: 'A symphony of clacking keys is underway...', - comment: 'Description text in the channel and chat typing users.', -}); -const IT_S_A_FULL_BLOWN_TYPING_FIESTA_IN_DESCRIPTOR = msg({ - message: "It's a full-blown typing fiesta in here", - comment: 'Label in the channel and chat typing users.', -}); -const WHOA_IT_S_A_TYPING_APOCALYPSE_DESCRIPTOR = msg({ - message: "Whoa, it's a typing apocalypse", - comment: 'Label in the channel and chat typing users.', -}); -const SEVERAL_PEOPLE_DESCRIPTOR = SEVERAL_PEOPLE_ARE_TYPING_DESCRIPTOR; -const HANDFUL_DESCRIPTOR = A_HANDFUL_OF_KEYBOARD_WARRIORS_ARE_ASSEMBLING_DESCRIPTOR; -const SYMPHONY_DESCRIPTOR = A_SYMPHONY_OF_CLACKING_KEYS_IS_UNDERWAY_DESCRIPTOR; -const FIESTA_DESCRIPTOR = IT_S_A_FULL_BLOWN_TYPING_FIESTA_IN_DESCRIPTOR; -const APOCALYPSE_DESCRIPTOR = WHOA_IT_S_A_TYPING_APOCALYPSE_DESCRIPTOR; -const getDisplayName = (user: User, guildId?: string | null) => NicknameUtils.getNickname(user, guildId ?? null); -export const getTypingText = (i18n: I18n, typingUsers: ReadonlyArray, channel: Channel) => { - const [a, b, c] = typingUsers.map((user) => { - const member = GuildMembers.getMember(channel.guildId ?? '', user.id); - return ( - - {getDisplayName(user, channel.guildId)} - - ); - }); - if (typingUsers.length === 1) { - return {a} is typing...; - } - if (typingUsers.length === 2) { - return ( - - {a} and {b} are typing... - - ); - } - if (typingUsers.length === 3) { - return ( - - {a}, {b} and {c} are typing... - - ); - } - if (typingUsers.length === 4) { - return i18n._(SEVERAL_PEOPLE_DESCRIPTOR); - } - if (typingUsers.length > 4 && typingUsers.length < 10) { - return i18n._(HANDFUL_DESCRIPTOR); - } - if (typingUsers.length > 9 && typingUsers.length < 15) { - return i18n._(SYMPHONY_DESCRIPTOR); - } - if (typingUsers.length > 14 && typingUsers.length < 20) { - return i18n._(FIESTA_DESCRIPTOR); - } - return i18n._(APOCALYPSE_DESCRIPTOR); -}; -const EMPTY_TYPING_USER_RECORDS: ReadonlyArray = Object.freeze([]); -export const usePresentableTypingUsers = (channel: Channel): ReadonlyArray => { - const typingUserIds = TypingIndicator.getTypingUsers(channel.id); - if (typingUserIds.length === 0) return EMPTY_TYPING_USER_RECORDS; - const currentUserId = Authentication.currentUserId; - const showSelf = DeveloperOptions.showMyselfTyping; - const result: Array = []; - for (const userId of typingUserIds) { - if (!showSelf && userId === currentUserId) continue; - if (Relationships.isBlocked(userId)) continue; - const user = Users.getUser(userId); - if (user) result.push(user); - } - if (result.length === 0) return EMPTY_TYPING_USER_RECORDS; - return result; -}; -const AVATAR_THRESHOLD = 5; -export const TypingUsers = observer( - ({channel, withText = true, showAvatars = true}: {channel: Channel; withText?: boolean; showAvatars?: boolean}) => { - const {i18n} = useLingui(); - const typingUsers = usePresentableTypingUsers(channel); - if (typingUsers.length === 0) { - return null; - } - return ( -
-
-
- -
- {withText && ( - <> - {showAvatars && ( - - )} - - {getTypingText(i18n, typingUsers, channel)} - - - )} -
-
- ); - }, +interface TypingUsersProps { + channel: Channel; + withText?: boolean; + showAvatars?: boolean; + overflowContainerRef?: RefObject; +} + +export const getTypingText = (i18n: I18n, typingUsers: ReadonlyArray, channel: Channel): ReactNode => + TypingPolicy.active === 'rolling' + ? getRollingTypingText(i18n, typingUsers, channel, false) + : getLegacyTypingText(i18n, typingUsers, channel); + +export const usePresentableTypingUsers = (channel: Channel): ReadonlyArray => + TypingPolicy.active === 'rolling' + ? rollingUsePresentableTypingUsers(channel) + : legacyUsePresentableTypingUsers(channel); + +export const TypingUsers = observer(({channel, withText, showAvatars, overflowContainerRef}: TypingUsersProps) => + TypingPolicy.active === 'rolling' ? ( + + ) : ( + + ), +); + +export const TypingAnnouncer = observer(({channel}: {channel: Channel}) => + TypingPolicy.active === 'rolling' ? ( + + ) : null, ); diff --git a/fluxer_app/src/features/i18n/locales/ar/messages.po b/fluxer_app/src/features/i18n/locales/ar/messages.po index 78ba99563..d4712ae6d 100644 --- a/fluxer_app/src/features/i18n/locales/ar/messages.po +++ b/fluxer_app/src/features/i18n/locales/ar/messages.po @@ -44,15 +44,24 @@ msgstr "(أنت)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, zero {# عضو} one {عضو واحد} two {عضوان} few {# أعضاء} many {# عضوًا} other {# عضو}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} و{b} يكتبان..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} يكتب..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a} و{b} و{c} يكتبون..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} إطار/ث" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "قنوات {guildName}" @@ -2681,11 +2690,6 @@ msgstr "امتداد ملف، مثل png" msgid "a forwarded message" msgstr "رسالة مُعاد توجيهها" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "يحتشد بعض محاربي لوحة المفاتيح..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "تم تقديم طلب تسجيل دخول من الجهاز التال msgid "a sticker" msgstr "ملصق" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "تجري سيمفونية من طقطقة المفاتيح..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "تم حذف الفئة" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "الفئة ممتلئة" @@ -19091,11 +19090,6 @@ msgstr "ربما تم حذفه. تمت إعادة الاسم إلى ما كان msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "ربما خرج هذا المجتمع من الاكتشاف أو أوقف انضمام أعضاء جدد. حدّث الصفحة ولن يظهر لك مرة أخرى." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "الكل يكتب هنا" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "يمكن للأعضاء الذين ليس لديهم إذن {readMessageHistoryPermissionLabel} عرض الرسائل المرسلة بعد هذا التاريخ." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "الأعضاء، محدد" @@ -22124,6 +22118,11 @@ msgstr "المصادقة متعددة العوامل" msgid "Multi-line" msgstr "أسطر متعددة" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "يكتب عدة أشخاص..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "رسالة جديدة" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "رسائل جديدة" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "فتح المجتمع" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "فتح قائمة المجتمع لـ {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "مفتاح الإعداد" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "عدة أشخاص يكتبون..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "تخطّي هذه الخطوة" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "تخطي إلى المحتوى" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "هذه البطاقة لا تتطابق مع بلد الفوترة الخاص بك، لذا لا يمكن استخدامها بهذه العملة المحلية. استخدم بطاقة محلية، أو عد إلى {productName} واختر طريقة دفع أخرى." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "تحتوي هذه الفئة بالفعل على الحد الأقصى من القنوات وهو {maxChannelsPerCategory}." @@ -39701,11 +39700,6 @@ msgstr "من يمكنه رؤية ملفك الشخصي بالكامل" msgid "Who is typing" msgstr "من يكتب" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "يا إلهي، الكل يكتب في نفس الوقت" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/bg/messages.po b/fluxer_app/src/features/i18n/locales/bg/messages.po index e6b12821d..079e4fdcc 100644 --- a/fluxer_app/src/features/i18n/locales/bg/messages.po +++ b/fluxer_app/src/features/i18n/locales/bg/messages.po @@ -44,15 +44,24 @@ msgstr "(ти)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# член} other {# членове}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} и {b} пишат..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} пише..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} и {c} пишат..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Канали на {guildName}" @@ -2681,11 +2690,6 @@ msgstr "файлово разширение, напр. png" msgid "a forwarded message" msgstr "препратено съобщение" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Събира се отряд клавиатурни бойци..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Направена е заявка за влизане от следно msgid "a sticker" msgstr "стикер" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Започна симфония от тракащи клавиши..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Категорията е изтрита" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Категорията е пълна" @@ -19091,11 +19090,6 @@ msgstr "Възможно е да е било изтрито. Името беше msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Може да е напуснала Откриване или да е изключила новите присъединявания. Презареди страницата и няма да я виждаш повече." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Тук е пълна фиеста от писане" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Членове без разрешение „{readMessageHistoryPermissionLabel}“ могат да виждат съобщения, изпратени след тази дата." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Членове, избрано" @@ -22124,6 +22118,11 @@ msgstr "Многофакторно удостоверяване" msgid "Multi-line" msgstr "Няколко реда" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Няколко души пишат..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Ново съобщение" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Нови съобщения" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Отваряне на общността" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Отваряне на менюто на общността {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Ключ за настройка" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Няколко души пишат..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Пропусни тази стъпка" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Към съдържанието" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Тази карта не съответства на държавата ти за фактуриране, така че не може да се използва за тази локализирана валута. Използвай местна карта или се върни към {productName} и избери друг метод на плащане." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Тази категория вече съдържа максималния брой от {maxChannelsPerCategory} канала." @@ -39701,11 +39700,6 @@ msgstr "Кой може да вижда пълния ти профил" msgid "Who is typing" msgstr "Кой пише" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Леле, тук настава апокалипсис от писане" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/cs/messages.po b/fluxer_app/src/features/i18n/locales/cs/messages.po index 97a7d3e16..8092a5266 100644 --- a/fluxer_app/src/features/i18n/locales/cs/messages.po +++ b/fluxer_app/src/features/i18n/locales/cs/messages.po @@ -44,15 +44,24 @@ msgstr "(vy)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# člen} few {# členové} many {# člena} other {# členů}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} a {b} píšou..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} píše..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} a {c} píšou..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Kanály {guildName}" @@ -2681,11 +2690,6 @@ msgstr "přípona souboru, např. png" msgid "a forwarded message" msgstr "přeposlaná zpráva" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Několik klávesnicových bojovníků se shromažďuje..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Byl odeslán požadavek na přihlášení z následujícího zařízení msgid "a sticker" msgstr "samolepka" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Rozezněla se symfonie klapajících kláves..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Kategorie smazána" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Kategorie plná" @@ -19091,11 +19090,6 @@ msgstr "Možná byla položka smazána. Název byl obnoven na původní." msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Komunita už možná není v Objevování nebo nepřijímá nové členy. Obnovte stránku a už se vám nezobrazí." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Tady se píše o sto šest" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Členové bez oprávnění {readMessageHistoryPermissionLabel} si mohou prohlížet zprávy odeslané po tomto datu." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Členové, vybráno" @@ -22124,6 +22118,11 @@ msgstr "Vícefaktorové" msgid "Multi-line" msgstr "Více řádků" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Více lidí píše..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Nová zpráva" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Nové zprávy" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Otevřít komunitu" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Otevřít nabídku komunity {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Nastavovací klíč" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Několik lidí píše..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Přeskočit tento krok" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Přejít na obsah" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Tato karta neodpovídá vaší fakturační zemi, proto ji nelze použít pro tuto místní měnu. Použijte místní kartu nebo se vraťte do aplikace {productName} a vyberte jinou platební metodu." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Tato kategorie již obsahuje maximální počet {maxChannelsPerCategory} kanálů." @@ -39701,11 +39700,6 @@ msgstr "Kdo může vidět váš celý profil" msgid "Who is typing" msgstr "Kdo píše" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Páni, to je psací apokalypsa" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/da/messages.po b/fluxer_app/src/features/i18n/locales/da/messages.po index a6a89e471..9f6c15b5c 100644 --- a/fluxer_app/src/features/i18n/locales/da/messages.po +++ b/fluxer_app/src/features/i18n/locales/da/messages.po @@ -44,15 +44,24 @@ msgstr "(dig)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# medlem} other {# medlemmer}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} og {b} skriver ..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} skriver ..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} og {c} skriver ..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Kanaler i {guildName}" @@ -2681,11 +2690,6 @@ msgstr "en filendelse, f.eks. png" msgid "a forwarded message" msgstr "en videresendt besked" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "En håndfuld tastaturkrigere er ved at samles ..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Der er blevet anmodet om login fra følgende enhed. Godkend kun, hvis du msgid "a sticker" msgstr "et klistermærke" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "En symfoni af tastaturklapren er i gang ..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Kategori slettet" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Kategorien er fuld" @@ -19091,11 +19090,6 @@ msgstr "Den er muligvis blevet slettet. Navnet blev gendannet til det, det var f msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Fællesskabet vises muligvis ikke længere under Opdag eller har lukket for nye medlemmer. Genindlæs siden, så vises det ikke igen." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Der bliver skrevet løs herinde" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Medlemmer uden tilladelsen {readMessageHistoryPermissionLabel} kan se beskeder sendt efter denne dato." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Medlemmer valgt" @@ -22124,6 +22118,11 @@ msgstr "Flerfaktorgodkendelse" msgid "Multi-line" msgstr "Flere linjer" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Flere skriver..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Ny besked" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Nye beskeder" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Åbn fællesskab" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Åbn fællesskabsmenu for {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Opsætningsnøgle" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Flere skriver..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Spring dette trin over" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Spring til indhold" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Dette kort er ikke fra dit faktureringsland, så det kan ikke bruges til denne lokale valuta. Brug et lokalt kort, eller vend tilbage til {productName} og vælg en anden betalingsmetode." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Denne kategori indeholder allerede det maksimale antal på {maxChannelsPerCategory} kanaler." @@ -39701,11 +39700,6 @@ msgstr "Hvem kan se din fulde profil" msgid "Who is typing" msgstr "Hvem skriver" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Hold da op, der er mange, der skriver" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/de/messages.po b/fluxer_app/src/features/i18n/locales/de/messages.po index 1de374d43..e0239fb0d 100644 --- a/fluxer_app/src/features/i18n/locales/de/messages.po +++ b/fluxer_app/src/features/i18n/locales/de/messages.po @@ -44,15 +44,24 @@ msgstr "(du)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# Mitglied} other {# Mitglieder}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} und {b} schreiben …" -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} schreibt …" -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} und {c} schreiben …" @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "{guildName}-Kanäle" @@ -2681,11 +2690,6 @@ msgstr "eine Dateierweiterung, z. B. png" msgid "a forwarded message" msgstr "eine weitergeleitete Nachricht" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Ein paar Tastaturkrieger versammeln sich ..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Eine Anmeldeanfrage wurde von folgendem Gerät gesendet. Genehmige sie n msgid "a sticker" msgstr "ein Sticker" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Eine Symphonie klackernder Tasten ist im Gange ..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Kategorie gelöscht" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Kategorie voll" @@ -19091,11 +19090,6 @@ msgstr "Es wurde möglicherweise gelöscht. Der Name wurde auf den vorherigen We msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Sie wird möglicherweise nicht mehr unter „Entdecken“ aufgeführt oder hat neue Beitritte deaktiviert. Aktualisiere die Seite, dann wird sie nicht mehr angezeigt." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Hier ist eine richtige Tipp-Party im Gange" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Mitglieder ohne die Berechtigung \"{readMessageHistoryPermissionLabel}\" können Nachrichten sehen, die nach diesem Datum gesendet wurden." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Mitglieder, ausgewählt" @@ -22124,6 +22118,11 @@ msgstr "Multi-Faktor" msgid "Multi-line" msgstr "Mehrere Zeilen" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Mehrere Personen tippen..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Neue Nachricht" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Neue Nachrichten" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Community öffnen" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Community-Menü für {guildName} öffnen" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Einrichtungsschlüssel" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Mehrere Personen tippen …" @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Diesen Schritt überspringen" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Zum Inhalt springen" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Diese Karte entspricht nicht deinem Abrechnungsland und kann daher nicht für diese lokalisierte Währung verwendet werden. Nutze eine lokale Karte oder kehre zu {productName} zurück und wähle eine andere Zahlungsmethode." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Diese Kategorie enthält bereits die maximale Anzahl von {maxChannelsPerCategory} Kanälen." @@ -39701,11 +39700,6 @@ msgstr "Wer dein vollständiges Profil sehen kann" msgid "Who is typing" msgstr "Wer tippt gerade" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Uff, hier tippen aber viele" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/el/messages.po b/fluxer_app/src/features/i18n/locales/el/messages.po index 285c85114..f7f4bb2cb 100644 --- a/fluxer_app/src/features/i18n/locales/el/messages.po +++ b/fluxer_app/src/features/i18n/locales/el/messages.po @@ -44,15 +44,24 @@ msgstr "(εσείς)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# μέλος} other {# μέλη}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "Ο/Η {a} και ο/η {b} πληκτρολογούν..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "Ο/Η {a} πληκτρολογεί..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "Ο/Η {a}, ο/η {b} και ο/η {c} πληκτρολογούν..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Κανάλια του {guildName}" @@ -2681,11 +2690,6 @@ msgstr "μια επέκταση αρχείου, π.χ. png" msgid "a forwarded message" msgstr "ένα προωθημένο μήνυμα" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Μερικοί μαχητές του πληκτρολογίου συγκεντρώνονται..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Έγινε αίτημα σύνδεσης από την παρακάτω msgid "a sticker" msgstr "ένα αυτοκόλλητο" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Μια συμφωνία από πλήκτρα που χτυπούν βρίσκεται σε εξέλιξη..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Η κατηγορία διαγράφηκε" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Η κατηγορία είναι πλήρης" @@ -19091,11 +19090,6 @@ msgstr "Ενδέχεται να έχει διαγραφεί. Το όνομα ε msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Ενδέχεται να έχει καταργηθεί από την Ανακάλυψη ή να έχει απενεργοποιήσει τις νέες συμμετοχές. Ανανεώστε τη σελίδα και δεν θα το ξαναδείτε." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Έχουμε πάρτι πληκτρολόγησης εδώ μέσα" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Τα μέλη χωρίς το δικαίωμα {readMessageHistoryPermissionLabel} μπορούν να δουν μηνύματα που στάλθηκαν μετά από αυτήν την ημερομηνία." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Μέλη, επιλεγμένα" @@ -22124,6 +22118,11 @@ msgstr "Έλεγχος ταυτότητας δύο παραγόντων" msgid "Multi-line" msgstr "Πολλές γραμμές" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Πολλοί χρήστες πληκτρολογούν..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Νέο μήνυμα" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Νέα μηνύματα" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Άνοιγμα κοινότητας" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Άνοιγμα μενού κοινότητας για {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Κλειδί εγκατάστασης" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Αρκετά άτομα πληκτρολογούν..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Παράλειψη αυτού του βήματος" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Μετάβαση στο περιεχόμενο" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Αυτή η κάρτα δεν αντιστοιχεί στη χώρα χρέωσής σας, επομένως δεν μπορεί να χρησιμοποιηθεί για αυτό το τοπικό νόμισμα. Χρησιμοποιήστε μια τοπική κάρτα ή επιστρέψτε στο {productName} και επιλέξτε άλλη μέθοδο πληρωμής." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Αυτή η κατηγορία περιέχει ήδη τον μέγιστο αριθμό των {maxChannelsPerCategory} καναλιών." @@ -39701,11 +39700,6 @@ msgstr "Ποιος μπορεί να δει το πλήρες προφίλ σα msgid "Who is typing" msgstr "Ποιος πληκτρολογεί" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Ωχ, γίνεται χαμός με την πληκτρολόγηση" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/en-GB/messages.po b/fluxer_app/src/features/i18n/locales/en-GB/messages.po index 72695c36b..2c2672393 100644 --- a/fluxer_app/src/features/i18n/locales/en-GB/messages.po +++ b/fluxer_app/src/features/i18n/locales/en-GB/messages.po @@ -44,15 +44,24 @@ msgstr "(you)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# member} other {# members}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} and {b} are typing..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} is typing..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} and {c} are typing..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "{guildName} channels" @@ -2681,11 +2690,6 @@ msgstr "a file extension, e.g. png" msgid "a forwarded message" msgstr "a forwarded message" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "A handful of keyboard warriors are assembling..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "A sign-in request was made from the following device. Only approve if yo msgid "a sticker" msgstr "a sticker" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "A symphony of clacking keys is underway..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Category deleted" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Category full" @@ -19091,11 +19090,6 @@ msgstr "It may have been deleted. The name was reverted to what it was before." msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "It's a full-blown typing fiesta in here" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Members without the {readMessageHistoryPermissionLabel} permission can view messages sent after this date." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Members, selected" @@ -22124,6 +22118,11 @@ msgstr "Multi-factor" msgid "Multi-line" msgstr "Multi-line" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Multiple people are typing…" + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "New message" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "New messages" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Open community" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Open community menu for {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Setup key" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Several people are typing..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Skip this step" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Skip to content" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "This card does not match your billing country, so it cannot be used for this localised currency. Use a local card, or return to {productName} and choose another payment method." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "This category already contains the maximum of {maxChannelsPerCategory} channels." @@ -39701,11 +39700,6 @@ msgstr "Who can see your full profile" msgid "Who is typing" msgstr "Who is typing" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Whoa, it's a typing apocalypse" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/en-US/messages.po b/fluxer_app/src/features/i18n/locales/en-US/messages.po index dfc88b7c8..3f18f11e7 100644 --- a/fluxer_app/src/features/i18n/locales/en-US/messages.po +++ b/fluxer_app/src/features/i18n/locales/en-US/messages.po @@ -44,15 +44,24 @@ msgstr "(you)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# member} other {# members}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} and {b} are typing..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} is typing..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} and {c} are typing..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "{guildName} channels" @@ -2681,11 +2690,6 @@ msgstr "a file extension, e.g. png" msgid "a forwarded message" msgstr "a forwarded message" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "A handful of keyboard warriors are assembling..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "A sign-in request was made from the following device. Only approve if yo msgid "a sticker" msgstr "a sticker" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "A symphony of clacking keys is underway..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Category deleted" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Category full" @@ -19091,11 +19090,6 @@ msgstr "It may have been deleted. The name was reverted to what it was before." msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "It's a full-blown typing fiesta in here" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Members without the {readMessageHistoryPermissionLabel} permission can view messages sent after this date." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Members, selected" @@ -22124,6 +22118,11 @@ msgstr "Multi-factor" msgid "Multi-line" msgstr "Multi-line" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Multiple people are typing..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "New message" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "New messages" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Open community" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Open community menu for {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Setup key" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Several people are typing..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Skip this step" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Skip to content" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "This card does not match your billing country, so it cannot be used for this localized currency. Use a local card, or return to {productName} and choose another payment method." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "This category already contains the maximum of {maxChannelsPerCategory} channels." @@ -39701,11 +39700,6 @@ msgstr "Who can see your full profile" msgid "Who is typing" msgstr "Who is typing" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Whoa, it's a typing apocalypse" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/es-419/messages.po b/fluxer_app/src/features/i18n/locales/es-419/messages.po index 91d9940e7..e6127eec6 100644 --- a/fluxer_app/src/features/i18n/locales/es-419/messages.po +++ b/fluxer_app/src/features/i18n/locales/es-419/messages.po @@ -44,15 +44,24 @@ msgstr "(tú)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# miembro} other {# miembros}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} y {b} están escribiendo..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} está escribiendo..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} y {c} están escribiendo..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Canales de {guildName}" @@ -2681,11 +2690,6 @@ msgstr "una extensión de archivo, p. ej., png" msgid "a forwarded message" msgstr "un mensaje reenviado" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Unos cuantos guerreros del teclado se están reuniendo..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Se hizo una solicitud de inicio de sesión desde el siguiente dispositiv msgid "a sticker" msgstr "un sticker" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Una sinfonía de teclas repiqueteando está en marcha..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Categoría eliminada" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Categoría llena" @@ -19091,11 +19090,6 @@ msgstr "Es posible que se haya eliminado. El nombre volvió a ser el que tenía msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Es posible que haya dejado de aparecer en Descubrir o haya desactivado la admisión de nuevos miembros. Actualiza la página y no volverás a verla." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Esto es una fiesta de escritura" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Los miembros sin el permiso {readMessageHistoryPermissionLabel} pueden ver los mensajes enviados después de esta fecha." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Miembros, seleccionados" @@ -22124,6 +22118,11 @@ msgstr "Varios factores" msgid "Multi-line" msgstr "Varias líneas" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Varias personas están escribiendo..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Mensaje nuevo" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Mensajes nuevos" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Abrir comunidad" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Abrir menú de la comunidad para {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Clave de configuración" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Varias personas están escribiendo..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Omitir este paso" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Saltar al contenido" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Esta tarjeta no se emitió en tu país de facturación, por lo que no puedes usarla para pagar en esta moneda local. Usa una tarjeta local o vuelve a {productName} y elige otro método de pago." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Esta categoría ya contiene el máximo de {maxChannelsPerCategory} canales." @@ -39701,11 +39700,6 @@ msgstr "Quién puede ver tu perfil completo" msgid "Who is typing" msgstr "Quién está escribiendo" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Uy, es un apocalipsis de escritura" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/es-ES/messages.po b/fluxer_app/src/features/i18n/locales/es-ES/messages.po index 7750c3ef3..bb4b8fd39 100644 --- a/fluxer_app/src/features/i18n/locales/es-ES/messages.po +++ b/fluxer_app/src/features/i18n/locales/es-ES/messages.po @@ -44,15 +44,24 @@ msgstr "(tú)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# miembro} other {# miembros}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} y {b} están escribiendo..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} está escribiendo..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} y {c} están escribiendo..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Canales de {guildName}" @@ -2681,11 +2690,6 @@ msgstr "una extensión de archivo, p. ej., png" msgid "a forwarded message" msgstr "un mensaje reenviado" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Unos cuantos \"guerreros del teclado\" se están reuniendo..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Se ha realizado una solicitud de inicio de sesión desde el siguiente di msgid "a sticker" msgstr "una pegatina" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Una sinfonía de teclas repiqueteando está en marcha..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Categoría eliminada" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Categoría llena" @@ -19091,11 +19090,6 @@ msgstr "Puede que se haya eliminado. El nombre se ha revertido al que tenía ant msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Es posible que haya dejado de aparecer en Descubrir o haya desactivado la admisión de nuevos miembros. Actualiza la página y no volverás a verla." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Esto es una fiesta de escritura" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Los miembros sin el permiso {readMessageHistoryPermissionLabel} pueden ver los mensajes enviados después de esta fecha." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Miembros, seleccionados" @@ -22124,6 +22118,11 @@ msgstr "Varios factores" msgid "Multi-line" msgstr "Varias líneas" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Varias personas están escribiendo..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Nuevo mensaje" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Mensajes nuevos" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Abrir comunidad" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Abrir menú de la comunidad para {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Clave de configuración" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Varias personas están escribiendo..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Omitir este paso" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Saltar al contenido" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Esta tarjeta no se emitió en tu país de facturación, por lo que no puedes usarla para pagar en esta moneda local. Usa una tarjeta local o vuelve a {productName} y elige otro método de pago." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Esta categoría ya contiene el máximo de {maxChannelsPerCategory} canales." @@ -39701,11 +39700,6 @@ msgstr "Quién puede ver tu perfil completo" msgid "Who is typing" msgstr "Quién está escribiendo" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Vaya, es un apocalipsis de escritura" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/fi/messages.po b/fluxer_app/src/features/i18n/locales/fi/messages.po index 81408d894..f025418ec 100644 --- a/fluxer_app/src/features/i18n/locales/fi/messages.po +++ b/fluxer_app/src/features/i18n/locales/fi/messages.po @@ -44,15 +44,24 @@ msgstr "(sinä)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# jäsen} other {# jäsentä}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} ja {b} kirjoittavat..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} kirjoittaa..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} ja {c} kirjoittavat..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Yhteisön {guildName} kanavat" @@ -2681,11 +2690,6 @@ msgstr "tiedostopääte, esim. png" msgid "a forwarded message" msgstr "edelleenlähetetty viesti" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Muutama näppäimistösoturi kokoontuu..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Kirjautumispyyntö tehtiin seuraavalta laitteelta. Hyväksy vain, jos tu msgid "a sticker" msgstr "tarra" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Näppäimistöt naksuvat tahdissa..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Kategoria poistettu" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Kategoria täynnä" @@ -19091,11 +19090,6 @@ msgstr "Se on ehkä poistettu. Nimi palautettiin entiselleen." msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Se on ehkä poistunut Löydä-osiosta tai estänyt uudet liittymiset. Päivitä sivu, niin et näe sitä enää." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Täällä kirjoitetaan nyt urakalla" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Jäsenet, joilla ei ole {readMessageHistoryPermissionLabel} -käyttöoikeutta, voivat nähdä tämän päivämäärän jälkeen lähetetyt viestit." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Jäsenet, valittu" @@ -22124,6 +22118,11 @@ msgstr "Monivaiheinen todennus" msgid "Multi-line" msgstr "Useita rivejä" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Useat henkilöt kirjoittavat..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Uusi viesti" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Uudet viestit" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Avaa yhteisö" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Avaa yhteisön valikko ({guildName})" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Asetusavain" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Useita henkilöitä kirjoittaa..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Ohita tämä vaihe" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Siirry sisältöön" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Tämä kortti ei vastaa laskutusmaatasi, joten sitä ei voi käyttää tähän paikalliseen valuuttaan. Käytä paikallista korttia tai palaa {productName}-sovellukseen ja valitse toinen maksutapa." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Tässä kategoriassa on jo enimmäismäärä kanavia ({maxChannelsPerCategory})." @@ -39701,11 +39700,6 @@ msgstr "Kuka voi nähdä koko profiilisi" msgid "Who is typing" msgstr "Kuka kirjoittaa" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Huh, täällä kirjoitetaan urakalla" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/fr/messages.po b/fluxer_app/src/features/i18n/locales/fr/messages.po index c609f8c0b..2ac05d0b3 100644 --- a/fluxer_app/src/features/i18n/locales/fr/messages.po +++ b/fluxer_app/src/features/i18n/locales/fr/messages.po @@ -44,15 +44,24 @@ msgstr "(vous)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# membre} other {# membres}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} et {b} sont en train d'écrire..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} est en train d'écrire..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} et {c} sont en train d'écrire..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Salons {guildName}" @@ -2681,11 +2690,6 @@ msgstr "une extension de fichier, par exemple png" msgid "a forwarded message" msgstr "un message transféré" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Plusieurs personnes sont en train d'écrire..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Une demande de connexion a été effectuée depuis l'appareil suivant. N msgid "a sticker" msgstr "un sticker" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Une symphonie de touches en action..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Catégorie supprimée" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Catégorie pleine" @@ -19091,11 +19090,6 @@ msgstr "Il a peut-être été supprimé. Le nom a été restauré à sa version msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Cette communauté a peut-être quitté Découverte ou désactivé les nouvelles adhésions. Actualisez la page pour qu’elle n’apparaisse plus." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Ça tape de partout ici" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Les membres sans la permission {readMessageHistoryPermissionLabel} peuvent consulter les messages envoyés après cette date." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Membres, sélectionnés" @@ -22124,6 +22118,11 @@ msgstr "Authentification multifacteur" msgid "Multi-line" msgstr "Multiligne" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Plusieurs personnes tapent..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Nouveau message" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Nouveaux messages" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Ouvrir la communauté" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Ouvrir le menu de la communauté pour {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Clé de configuration" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Plusieurs personnes sont en train d'écrire..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Passer cette étape" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Aller au contenu" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Cette carte n’a pas été émise dans votre pays de facturation et ne permet donc pas de payer dans cette devise locale. Utilisez une carte locale ou revenez dans {productName} et choisissez un autre mode de paiement." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Cette catégorie contient déjà le nombre maximal de {maxChannelsPerCategory} salons." @@ -39701,11 +39700,6 @@ msgstr "Qui peut voir votre profil complet" msgid "Who is typing" msgstr "Qui est en train d'écrire" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Ouh là, c'est une apocalypse de frappe" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/he/messages.po b/fluxer_app/src/features/i18n/locales/he/messages.po index 27a71f2e1..0cbf50180 100644 --- a/fluxer_app/src/features/i18n/locales/he/messages.po +++ b/fluxer_app/src/features/i18n/locales/he/messages.po @@ -44,15 +44,24 @@ msgstr "(אתה)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# חבר} other {# חברים}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} ו-{b} מקלידים..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} מקליד/ה..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} ו-{c} מקלידים..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "ערוצי {guildName}" @@ -2681,11 +2690,6 @@ msgstr "סיומת קובץ, לדוגמה png" msgid "a forwarded message" msgstr "הודעה שהועברה" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "קומץ לוחמי מקלדת מתאספים..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "התקבלה בקשת התחברות מהמכשיר הבא. אשר רק msgid "a sticker" msgstr "מדבקה" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "סימפוניית הקלדות בעיצומה..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "הקטגוריה נמחקה" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "הקטגוריה מלאה" @@ -19091,11 +19090,6 @@ msgstr "ייתכן שהוא נמחק. השם שוחזר למצבו הקודם." msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "ייתכן שהקהילה יצאה מגילוי או ביטלה הצטרפות של משתמשים חדשים. רענן את הדף ולא תראה אותה שוב." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "מסיבת הקלדה מטורפת פה" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "חברים ללא הרשאת {readMessageHistoryPermissionLabel} יכולים לראות הודעות שנשלחו לאחר תאריך זה." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "חברים, נבחר" @@ -22124,6 +22118,11 @@ msgstr "אימות דו-שלבי" msgid "Multi-line" msgstr "מספר שורות" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "מספר אנשים מקלידים..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "הודעה חדשה" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "הודעות חדשות" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "פתח קהילה" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "פתח תפריט קהילה עבור {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "מפתח הגדרה" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "כמה אנשים מקלידים..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "דלג על שלב זה" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "דלג לתוכן" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "הכרטיס הזה אינו תואם למדינת החיוב שלך, ולכן לא ניתן להשתמש בו עבור המטבע המקומי הזה. השתמש בכרטיס מקומי, או חזור אל {productName} ובחר אמצעי תשלום אחר." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "קטגוריה זו כבר מכילה את המספר המרבי של {maxChannelsPerCategory} ערוצים." @@ -39701,11 +39700,6 @@ msgstr "מי יכול לראות את הפרופיל המלא שלך" msgid "Who is typing" msgstr "מי מקליד" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "וואו, כולם מקלידים בבת אחת" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/hi/messages.po b/fluxer_app/src/features/i18n/locales/hi/messages.po index cf7d9f7d1..7492a8c3e 100644 --- a/fluxer_app/src/features/i18n/locales/hi/messages.po +++ b/fluxer_app/src/features/i18n/locales/hi/messages.po @@ -44,15 +44,24 @@ msgstr "(आप)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# सदस्य} other {# सदस्य}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} और {b} टाइप कर रहे हैं..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} टाइप कर रहे हैं..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} और {c} टाइप कर रहे हैं..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "{guildName} चैनल" @@ -2681,11 +2690,6 @@ msgstr "एक फ़ाइल एक्सटेंशन, जैसे png" msgid "a forwarded message" msgstr "एक फ़ॉरवर्ड किया गया मैसेज" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "कुछ लोग टाइप कर रहे हैं..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "नीचे दिए गए डिवाइस से साइन-इ msgid "a sticker" msgstr "एक स्टिकर" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "टाइपिंग जारी है..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "श्रेणी मिटा दी गई" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "श्रेणी भरी हुई है" @@ -19091,11 +19090,6 @@ msgstr "हो सकता है इसे हटा दिया गया msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "हो सकता है कि यह डिस्कवरी से हट गई हो या इसने नए लोगों का शामिल होना बंद कर दिया हो। पेज रीफ़्रेश करें और यह आपको दोबारा नहीं दिखेगी।" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "यहां तो टाइपिंग की धूम मची है" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "जिन सदस्यों के पास {readMessageHistoryPermissionLabel} अनुमति नहीं है, वे इस तारीख के बाद भेजे गए मैसेज देख सकते हैं।" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "सदस्य, चुने गए" @@ -22124,6 +22118,11 @@ msgstr "मल्टी-फ़ैक्टर" msgid "Multi-line" msgstr "बहु-पंक्ति" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "कई लोग टाइप कर रहे हैं..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "नया मैसेज" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "नए मैसेज" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "कम्युनिटी खोलें" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "{guildName} के लिए कम्युनिटी मेन्यू खोलें" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "सेटअप कुंजी" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "कई लोग टाइप कर रहे हैं..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "यह चरण छोड़ें" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "सामग्री पर जाएं" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "यह कार्ड आपके बिलिंग देश से मेल नहीं खाता है, इसलिए इसका उपयोग इस स्थानीय मुद्रा के लिए नहीं किया जा सकता है। कोई स्थानीय कार्ड इस्तेमाल करें, या {productName} पर वापस जाएं और कोई अन्य भुगतान विधि चुनें।" #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "इस श्रेणी में पहले से ही अधिकतम {maxChannelsPerCategory} चैनल हैं।" @@ -39701,11 +39700,6 @@ msgstr "आपकी पूरी प्रोफ़ाइल कौन दे msgid "Who is typing" msgstr "कौन टाइप कर रहा है" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "अरे बाप रे, इतने सारे लोग टाइप कर रहे हैं" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/hr/messages.po b/fluxer_app/src/features/i18n/locales/hr/messages.po index d01b399fa..d80d33fe4 100644 --- a/fluxer_app/src/features/i18n/locales/hr/messages.po +++ b/fluxer_app/src/features/i18n/locales/hr/messages.po @@ -44,15 +44,24 @@ msgstr "(vi)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# član} few {# člana} other {# članova}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} i {b} tipkaju..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} tipka..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} i {c} tipkaju..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Kanali zajednice {guildName}" @@ -2681,11 +2690,6 @@ msgstr "nastavak datoteke, npr. png" msgid "a forwarded message" msgstr "proslijeđena poruka" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Okuplja se nekoliko tipkovničkih ratnika..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Zahtjev za prijavu poslan je sa sljedećeg uređaja. Odobrite samo ako g msgid "a sticker" msgstr "naljepnica" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "U tijeku je simfonija kuckanja po tipkovnici..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Kategorija izbrisana" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Kategorija je puna" @@ -19091,11 +19090,6 @@ msgstr "Možda je izbrisan. Naziv je vraćen na prethodno stanje." msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Moguće je da je napustila Otkrivanje ili isključila nova pridruživanja. Osvježite stranicu i više je nećete vidjeti." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Ovdje je prava tipkačka fešta" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Članovi bez dopuštenja {readMessageHistoryPermissionLabel} mogu vidjeti poruke poslane nakon ovog datuma." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Članovi, odabrano" @@ -22124,6 +22118,11 @@ msgstr "Višestruka provjera autentičnosti" msgid "Multi-line" msgstr "Više redaka" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Više ljudi piše..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Nova poruka" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Nove poruke" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Otvori zajednicu" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Otvori izbornik zajednice za {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Ključ za postavljanje" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Nekoliko ljudi piše..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Preskoči ovaj korak" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Preskoči na sadržaj" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Ova se kartica ne podudara s vašom zemljom naplate, stoga se ne može koristiti za ovu lokaliziranu valutu. Upotrijebite lokalnu karticu ili se vratite na {productName} i odaberite drugi način plaćanja." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Ova kategorija već sadrži maksimalan broj od {maxChannelsPerCategory} kanala." @@ -39701,11 +39700,6 @@ msgstr "Tko može vidjeti vaš cijeli profil" msgid "Who is typing" msgstr "Tko tipka" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Uf, tipka se kao ludo" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/hu/messages.po b/fluxer_app/src/features/i18n/locales/hu/messages.po index 008f13998..7b5891dbf 100644 --- a/fluxer_app/src/features/i18n/locales/hu/messages.po +++ b/fluxer_app/src/features/i18n/locales/hu/messages.po @@ -44,15 +44,24 @@ msgstr "(te)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# tag} other {# tag}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} és {b} épp ír..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} épp ír…" -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} és {c} épp ír..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "{guildName} csatornái" @@ -2681,11 +2690,6 @@ msgstr "fájlkiterjesztés, pl. png" msgid "a forwarded message" msgstr "továbbított üzenet" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Néhány billentyűzetharcos már gyülekezik..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Bejelentkezési kérelem érkezett a következő eszközről. Csak akkor msgid "a sticker" msgstr "matrica" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Már szól a billentyűk kattogásának szimfóniája..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Kategória törölve" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Kategória megtelt" @@ -19091,11 +19090,6 @@ msgstr "Lehet, hogy törölték. A név visszaállt az eredetire." msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Lehet, hogy már nem szerepel a Felfedezésben, vagy letiltotta az új csatlakozásokat. Frissítsd az oldalt, és többé nem fogod látni." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Nagy a nyüzsgés itt, mindenki ír" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "A(z) {readMessageHistoryPermissionLabel} jogosultsággal nem rendelkező tagok az e dátum után küldött üzeneteket láthatják." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Tagok, kiválasztva" @@ -22124,6 +22118,11 @@ msgstr "Többfaktoros" msgid "Multi-line" msgstr "Többsoros" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Többen írnak..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Új üzenet" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Új üzenetek" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Közösség megnyitása" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Közösségi menü megnyitása – {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Beállítási kulcs" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Többen is épp írnak..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Lépés kihagyása" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Ugrás a tartalomra" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Ezt a kártyát nem a számlázási országodban bocsátották ki, ezért nem használható az adott helyi pénznemhez. Használj helyi kártyát, vagy térj vissza a(z) {productName} alkalmazásba, és válassz másik fizetési módot." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Ez a kategória már tartalmazza a maximális {maxChannelsPerCategory} csatornát." @@ -39701,11 +39700,6 @@ msgstr "Ki láthatja a teljes profilodat" msgid "Who is typing" msgstr "Ki gépel" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Hú, ez egy gépelési apokalipszis" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/id/messages.po b/fluxer_app/src/features/i18n/locales/id/messages.po index e6731207a..449ca0386 100644 --- a/fluxer_app/src/features/i18n/locales/id/messages.po +++ b/fluxer_app/src/features/i18n/locales/id/messages.po @@ -44,15 +44,24 @@ msgstr "(kamu)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# anggota} other {# anggota}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} dan {b} sedang mengetik..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} sedang mengetik..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b}, dan {c} sedang mengetik..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Saluran {guildName}" @@ -2681,11 +2690,6 @@ msgstr "ekstensi file, mis. png" msgid "a forwarded message" msgstr "pesan yang diteruskan" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Sekelompok jagoan keyboard sedang berkumpul..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Ada permintaan masuk dari perangkat berikut. Setujui hanya jika kamu men msgid "a sticker" msgstr "stiker" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Simfoni ketikan sedang berlangsung..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Kategori dihapus" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Kategori penuh" @@ -19091,11 +19090,6 @@ msgstr "Mungkin emoji ini sudah dihapus. Namanya dikembalikan seperti semula." msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Mungkin komunitas ini sudah keluar dari Discovery atau tidak lagi menerima anggota baru. Segarkan halaman dan kamu tidak akan melihatnya lagi." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Ramai sekali yang sedang mengetik di sini" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Anggota tanpa izin {readMessageHistoryPermissionLabel} dapat melihat pesan yang dikirim setelah tanggal ini." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Anggota, terpilih" @@ -22124,6 +22118,11 @@ msgstr "Autentikasi multi-faktor" msgid "Multi-line" msgstr "Banyak baris" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Beberapa orang sedang mengetik..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Pesan baru" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Pesan baru" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Buka komunitas" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Buka menu komunitas untuk {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Kunci penyiapan" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Beberapa orang sedang mengetik..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Lewati langkah ini" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Lewati ke konten" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Kartu ini tidak cocok dengan negara penagihanmu, jadi tidak dapat digunakan untuk mata uang lokal ini. Gunakan kartu lokal, atau kembali ke {productName} dan pilih metode pembayaran lain." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Kategori ini sudah berisi jumlah maksimum {maxChannelsPerCategory} saluran." @@ -39701,11 +39700,6 @@ msgstr "Siapa yang dapat melihat profil lengkapmu" msgid "Who is typing" msgstr "Siapa yang mengetik" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Wah, banyak banget yang ngetik" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/it/messages.po b/fluxer_app/src/features/i18n/locales/it/messages.po index 87a116a50..3aae8ea4d 100644 --- a/fluxer_app/src/features/i18n/locales/it/messages.po +++ b/fluxer_app/src/features/i18n/locales/it/messages.po @@ -44,15 +44,24 @@ msgstr "(tu)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# membro} other {# membri}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} e {b} stanno scrivendo..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} sta scrivendo..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} e {c} stanno scrivendo..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Canali di {guildName}" @@ -2681,11 +2690,6 @@ msgstr "un'estensione di file, ad es. png" msgid "a forwarded message" msgstr "un messaggio inoltrato" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Un manipolo di guerrieri della tastiera si sta radunando..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "È stata effettuata una richiesta di accesso dal seguente dispositivo. A msgid "a sticker" msgstr "uno sticker" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "È in corso una sinfonia di tasti che ticchettano..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Categoria eliminata" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Categoria piena" @@ -19091,11 +19090,6 @@ msgstr "Potrebbe essere stata eliminata. Il nome è stato ripristinato a quello msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Potrebbe aver lasciato la sezione \"Scopri\" o aver disattivato i nuovi accessi. Aggiorna la pagina e non la vedrai più." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Qui è una festa della digitazione" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "I membri senza il permesso {readMessageHistoryPermissionLabel} possono vedere i messaggi inviati dopo questa data." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Membri, voce selezionata" @@ -22124,6 +22118,11 @@ msgstr "Autenticazione a più fattori" msgid "Multi-line" msgstr "Più righe" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Più persone stanno scrivendo..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Nuovo messaggio" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Nuovi messaggi" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Apri community" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Apri il menu della community per {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Chiave di configurazione" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Diversi utenti stanno scrivendo..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Salta questo passaggio" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Vai al contenuto" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Questa carta non corrisponde al tuo paese di fatturazione, quindi non può essere utilizzata per questa valuta localizzata. Usa una carta locale o torna a {productName} e scegli un altro metodo di pagamento." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Questa categoria contiene già il numero massimo di {maxChannelsPerCategory} canali." @@ -39701,11 +39700,6 @@ msgstr "Chi può vedere il tuo profilo completo" msgid "Who is typing" msgstr "Chi sta scrivendo" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Wow, stanno scrivendo in tantissimi" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/ja/messages.po b/fluxer_app/src/features/i18n/locales/ja/messages.po index 2f717fe7c..7a5716367 100644 --- a/fluxer_app/src/features/i18n/locales/ja/messages.po +++ b/fluxer_app/src/features/i18n/locales/ja/messages.po @@ -44,15 +44,24 @@ msgstr "(自分)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {#人のメンバー} other {#人のメンバー}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a}と{b}が入力中…" -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a}が入力中…" -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}、{b}、{c}が入力中…" @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "{guildName}のチャンネル" @@ -2681,11 +2690,6 @@ msgstr "ファイル拡張子(例: png)" msgid "a forwarded message" msgstr "転送されたメッセージ" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "キーボード戦士たちが集結中…" - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "以下のデバイスからサインインが要求されました。身 msgid "a sticker" msgstr "スタンプ" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "カタカタとキーボードの音が響いています…" - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "カテゴリを削除しました" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "カテゴリの上限に達しました" @@ -19091,11 +19090,6 @@ msgstr "削除された可能性があります。名前は以前のものに戻 msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "このコミュニティは「見つける」の掲載から外れたか、新規参加を停止した可能性があります。ページを更新すると、表示されなくなります。" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "みんなが入力中" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "{readMessageHistoryPermissionLabel}の権限がないメンバーは、この日付以降に送信されたメッセージを閲覧できます。" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "メンバー、選択済み" @@ -22124,6 +22118,11 @@ msgstr "多要素認証" msgid "Multi-line" msgstr "複数行" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "複数のユーザーが入力中です…" + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "新着メッセージ" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "新着メッセージ" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "コミュニティを開く" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "{guildName}のコミュニティメニューを開く" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "セットアップキー" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "複数人が入力中…" @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "この手順をスキップ" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "コンテンツにスキップ" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "このカードは請求先住所の国と一致しないため、この現地通貨では使用できません。現地のカードを使用するか、{productName}に戻って別の支払い方法を選択してください。" #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "このカテゴリにはすでにチャンネルが{maxChannelsPerCategory}個あり、上限に達しています。" @@ -39701,11 +39700,6 @@ msgstr "自分のプロフィール全体を閲覧できるユーザー" msgid "Who is typing" msgstr "入力中" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "たくさんの人が入力中" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/ko/messages.po b/fluxer_app/src/features/i18n/locales/ko/messages.po index ff472560a..0dbcb1c16 100644 --- a/fluxer_app/src/features/i18n/locales/ko/messages.po +++ b/fluxer_app/src/features/i18n/locales/ko/messages.po @@ -44,15 +44,24 @@ msgstr "(나)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {#명} other {#명}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a}님과 {b}님이 입력 중..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a}님이 입력 중..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}님, {b}님, {c}님이 입력 중..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "{guildName} 채널" @@ -2681,11 +2690,6 @@ msgstr "파일 확장자(예: png)" msgid "a forwarded message" msgstr "전달된 메시지" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "여러 명이 모여 타자를 치기 시작했습니다..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "다음 기기에서 로그인 요청이 들어왔습니다. 아는 기 msgid "a sticker" msgstr "스티커" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "키보드 두드리는 소리가 울려 퍼지고 있습니다..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "카테고리 삭제됨" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "카테고리가 가득 찼습니다" @@ -19091,11 +19090,6 @@ msgstr "삭제되었을 수 있습니다. 이름이 이전 상태로 되돌려 msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "디스커버리 등록을 철회했거나 새로운 참여를 받지 않을 수 있습니다. 페이지를 새로고침하면 다시 표시되지 않습니다." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "모두 열심히 채팅을 입력하고 있어요" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "{readMessageHistoryPermissionLabel} 권한이 없는 멤버는 이 날짜 이후에 전송된 메시지를 볼 수 있습니다." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "멤버 선택됨" @@ -22124,6 +22118,11 @@ msgstr "다단계 인증" msgid "Multi-line" msgstr "여러 줄" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "여러 명이 입력 중입니다..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "새 메시지" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "새 메시지" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "커뮤니티 열기" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "{guildName} 커뮤니티 메뉴 열기" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "설정 키" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "여러 명이 입력 중..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "건너뛰기" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "본문으로 건너뛰기" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "이 카드는 청구 국가와 일치하지 않아 해당 지역 통화에 사용할 수 없습니다. 현지 카드를 사용하거나 {productName}으로 돌아가 다른 결제 방법을 선택하세요." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "이 카테고리에는 이미 최대 {maxChannelsPerCategory}개의 채널이 있습니다." @@ -39701,11 +39700,6 @@ msgstr "내 전체 프로필을 볼 수 있는 사람" msgid "Who is typing" msgstr "입력 중인 사람" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "세상에, 다들 입력 중이네요" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/lt/messages.po b/fluxer_app/src/features/i18n/locales/lt/messages.po index 7d7c36ee1..44f28394a 100644 --- a/fluxer_app/src/features/i18n/locales/lt/messages.po +++ b/fluxer_app/src/features/i18n/locales/lt/messages.po @@ -44,15 +44,24 @@ msgstr "(jūs)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# narys} few {# nariai} other {# narių}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} ir {b} rašo..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} rašo..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} ir {c} rašo..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Bendruomenės {guildName} kanalai" @@ -2681,11 +2690,6 @@ msgstr "failo plėtinys, pvz., png" msgid "a forwarded message" msgstr "persiųsta žinutė" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Renkasi būrys klaviatūros karių..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Prisijungimo užklausa gauta iš šio įrenginio. Patvirtinkite tik tuo msgid "a sticker" msgstr "lipdukas" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Skamba klavišų simfonija..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Kategorija ištrinta" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Kategorija pilna" @@ -19091,11 +19090,6 @@ msgstr "Gali būti, kad jis buvo ištrintas. Pavadinimas grąžintas į buvusį. msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Gali būti, kad ji pasitraukė iš Atradimo arba išjungė naujų narių prisijungimą. Atnaujinkite puslapį ir jos daugiau nebematysite." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Čia vyksta tikra rašymo fiesta" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Nariai, neturintys leidimo „{readMessageHistoryPermissionLabel}“, gali matyti žinutes, išsiųstas po šios datos." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Nariai, pasirinkta" @@ -22124,6 +22118,11 @@ msgstr "Kelių veiksnių" msgid "Multi-line" msgstr "Kelios eilutės" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Keli žmonės rašo..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Nauja žinutė" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Naujos žinutės" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Atidaryti bendruomenę" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Atidaryti bendruomenės „{guildName}“ meniu" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Sąrankos raktas" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Keli žmonės rašo..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Praleisti šį žingsnį" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Pereiti prie turinio" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Ši kortelė neatitinka jūsų atsiskaitymo šalies, todėl negali būti naudojama šia lokalizuota valiuta. Naudokite vietinę kortelę arba grįžkite į {productName} ir pasirinkite kitą mokėjimo būdą." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Šioje kategorijoje jau yra didžiausias leistinas kanalų skaičius: {maxChannelsPerCategory}." @@ -39701,11 +39700,6 @@ msgstr "Kas gali matyti visą jūsų profilį" msgid "Who is typing" msgstr "Kas rašo" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Oho, čia rašymo apokalipsė" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/nl/messages.po b/fluxer_app/src/features/i18n/locales/nl/messages.po index 4f976c8f0..f058be089 100644 --- a/fluxer_app/src/features/i18n/locales/nl/messages.po +++ b/fluxer_app/src/features/i18n/locales/nl/messages.po @@ -44,15 +44,24 @@ msgstr "(jij)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# lid} other {# leden}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} en {b} zijn aan het typen..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} is aan het typen..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} en {c} zijn aan het typen..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Kanalen van {guildName}" @@ -2681,11 +2690,6 @@ msgstr "een bestandsextensie, bijv. png" msgid "a forwarded message" msgstr "een doorgestuurd bericht" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Een paar toetsenbordridders zijn zich aan het verzamelen..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Er is een aanmeldingsverzoek gedaan vanaf het volgende apparaat. Keur al msgid "a sticker" msgstr "een sticker" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Er klinkt een symfonie van tikkende toetsen..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Categorie verwijderd" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Categorie vol" @@ -19091,11 +19090,6 @@ msgstr "De emoji is mogelijk verwijderd. De naam is teruggezet naar de vorige na msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "De community is mogelijk niet meer opgenomen in Ontdekken of accepteert geen nieuwe leden meer. Vernieuw de pagina en je ziet deze niet meer." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Het is hier één groot typefeest" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Leden zonder de machtiging {readMessageHistoryPermissionLabel} kunnen berichten zien die na deze datum zijn verzonden." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Leden, geselecteerd" @@ -22124,6 +22118,11 @@ msgstr "Meervoudige verificatie" msgid "Multi-line" msgstr "Meerdere regels" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Meerdere mensen typen..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Nieuw bericht" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Nieuwe berichten" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Community openen" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Communitymenu openen voor {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Configuratiesleutel" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Verschillende mensen typen..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Deze stap overslaan" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Naar inhoud springen" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Deze kaart is niet uitgegeven in je factureringsland en kan daarom niet voor deze lokale valuta worden gebruikt. Gebruik een lokale kaart, of ga terug naar {productName} en kies een andere betaalmethode." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Deze categorie bevat al het maximum aantal van {maxChannelsPerCategory} kanalen." @@ -39701,11 +39700,6 @@ msgstr "Wie je volledige profiel kan zien" msgid "Who is typing" msgstr "Wie typt er" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Wow, wat een typfestijn" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/no/messages.po b/fluxer_app/src/features/i18n/locales/no/messages.po index 4722c1ec7..28c637b28 100644 --- a/fluxer_app/src/features/i18n/locales/no/messages.po +++ b/fluxer_app/src/features/i18n/locales/no/messages.po @@ -44,15 +44,24 @@ msgstr "(deg)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# medlem} other {# medlemmer}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} og {b} skriver …" -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} skriver …" -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} og {c} skriver …" @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Kanaler i {guildName}" @@ -2681,11 +2690,6 @@ msgstr "en filendelse, f.eks. png" msgid "a forwarded message" msgstr "en videresendt melding" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "En håndfull tastaturkrigere samler seg …" - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "En påloggingsforespørsel ble sendt fra følgende enhet. Godkjenn kun h msgid "a sticker" msgstr "et klistremerke" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "En symfoni av klaprende taster er i gang …" - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Kategori slettet" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Kategorien er full" @@ -19091,11 +19090,6 @@ msgstr "Den kan ha blitt slettet. Navnet ble tilbakestilt til det det var før." msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Det kan ha blitt fjernet fra Oppdag eller stengt for nye medlemmer. Oppdater siden, så vises det ikke lenger." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Her skrives det i vei" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Medlemmer uten tillatelsen {readMessageHistoryPermissionLabel} kan se meldinger sendt etter denne datoen." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Valgte medlemmer" @@ -22124,6 +22118,11 @@ msgstr "Flerfaktorautentisering" msgid "Multi-line" msgstr "Flere linjer" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Flere skriver..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Ny melding" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Nye meldinger" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Åpne fellesskap" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Åpne fellesskapsmeny for {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Oppsettsnøkkel" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Flere skriver …" @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Hopp over dette trinnet" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Hopp til innhold" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Dette kortet samsvarer ikke med faktureringslandet ditt, så det kan ikke brukes for denne lokale valutaen. Bruk et lokalt kort, eller gå tilbake til {productName} og velg en annen betalingsmåte." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Denne kategorien inneholder allerede maksimalt {maxChannelsPerCategory} kanaler." @@ -39701,11 +39700,6 @@ msgstr "Hvem kan se hele profilen din" msgid "Who is typing" msgstr "Hvem skriver" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Oi, her skrives det mye" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/pl/messages.po b/fluxer_app/src/features/i18n/locales/pl/messages.po index bae00d8ac..e2692db82 100644 --- a/fluxer_app/src/features/i18n/locales/pl/messages.po +++ b/fluxer_app/src/features/i18n/locales/pl/messages.po @@ -44,15 +44,24 @@ msgstr "(Ty)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# członek} few {# członków} many {# członków} other {# członków}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} i {b} piszą…" -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} pisze…" -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} i {c} piszą…" @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} kl./s" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Kanały {guildName}" @@ -2681,11 +2690,6 @@ msgstr "rozszerzenie pliku, np. png" msgid "a forwarded message" msgstr "przekazana wiadomość" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Grono klawiaturowych wojowników się zbiera..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Wysłano prośbę o zalogowanie z następującego urządzenia. Zatwierd msgid "a sticker" msgstr "naklejka" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Rozbrzmiewa symfonia stukających klawiszy…" - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Kategoria usunięta" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Kategoria pełna" @@ -19091,11 +19090,6 @@ msgstr "Być może emoji zostało usunięte. Nazwa została przywrócona do popr msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Być może społeczność opuściła sekcję Odkrywanie lub wyłączyła możliwość dołączania. Odśwież stronę, a już jej nie zobaczysz." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Ależ tu się dzieje! Wszyscy piszą" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Członkowie bez uprawnienia {readMessageHistoryPermissionLabel} mogą wyświetlać wiadomości wysłane po tej dacie." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Członkowie, wybrana karta" @@ -22124,6 +22118,11 @@ msgstr "Uwierzytelnianie wieloskładnikowe" msgid "Multi-line" msgstr "Wiele linii" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Wielu użytkowników pisze..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Nowa wiadomość" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Nowe wiadomości" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Otwórz społeczność" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Otwórz menu społeczności {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Klucz konfiguracji" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Kilka osób pisze…" @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Pomiń ten krok" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Przejdź do treści" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Ta karta nie została wydana w Twoim kraju rozliczeniowym, więc nie można jej użyć do płatności w tej lokalnej walucie. Użyj karty z tego kraju lub wróć do {productName} i wybierz inną metodę płatności." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Ta kategoria zawiera już maksymalną liczbę {maxChannelsPerCategory} kanałów." @@ -39701,11 +39700,6 @@ msgstr "Kto może zobaczyć Twój pełny profil" msgid "Who is typing" msgstr "Kto pisze" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Ojej, ale tu się dzieje" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/pt-BR/messages.po b/fluxer_app/src/features/i18n/locales/pt-BR/messages.po index fb5f7f662..5ae38d047 100644 --- a/fluxer_app/src/features/i18n/locales/pt-BR/messages.po +++ b/fluxer_app/src/features/i18n/locales/pt-BR/messages.po @@ -44,15 +44,24 @@ msgstr "(você)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, =0 {# membros} one {# membro} other {# membros}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} e {b} estão digitando..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} está digitando..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} e {c} estão digitando..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Canais de {guildName}" @@ -2681,11 +2690,6 @@ msgstr "uma extensão de arquivo, ex: png" msgid "a forwarded message" msgstr "uma mensagem encaminhada" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Uma galera está digitando..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Foi feita uma solicitação de login do dispositivo abaixo. Só aprove s msgid "a sticker" msgstr "uma figurinha" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Uma sinfonia de teclas sendo digitadas está em andamento..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Categoria excluída" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Categoria cheia" @@ -19091,11 +19090,6 @@ msgstr "Ele pode ter sido excluído. O nome foi revertido para o que era antes." msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Talvez ela tenha saído de Descobrir ou desativado novas entradas. Atualize a página para removê-la dos resultados." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Tá todo mundo digitando aqui" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Membros sem a permissão {readMessageHistoryPermissionLabel} podem ver as mensagens enviadas após esta data." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Membros, selecionados" @@ -22124,6 +22118,11 @@ msgstr "Multifator" msgid "Multi-line" msgstr "Múltiplas linhas" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Várias pessoas estão digitando..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Nova mensagem" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Novas mensagens" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Abrir comunidade" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Abrir menu da comunidade {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Chave de configuração" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Várias pessoas estão digitando..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Pular esta etapa" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Pular para o conteúdo" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Este cartão não corresponde ao seu país de cobrança, por isso não pode ser usado para esta moeda regional. Use um cartão local ou volte ao {productName} e escolha outra forma de pagamento." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Esta categoria já contém o número máximo de {maxChannelsPerCategory} canais." @@ -39701,11 +39700,6 @@ msgstr "Quem pode ver seu perfil completo" msgid "Who is typing" msgstr "Quem está digitando" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Eita, é um apocalipse de digitação" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/ro/messages.po b/fluxer_app/src/features/i18n/locales/ro/messages.po index f7ad8a931..03bacf7c2 100644 --- a/fluxer_app/src/features/i18n/locales/ro/messages.po +++ b/fluxer_app/src/features/i18n/locales/ro/messages.po @@ -44,15 +44,24 @@ msgstr "(tu)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# membru} few {# membri} other {# de membri}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} și {b} scriu..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} scrie..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} și {c} scriu..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Canalele din {guildName}" @@ -2681,11 +2690,6 @@ msgstr "o extensie de fișier, de ex. png" msgid "a forwarded message" msgstr "un mesaj redirecționat" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Câțiva războinici ai tastaturii se adună..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "O solicitare de conectare a fost făcută de pe următorul dispozitiv. A msgid "a sticker" msgstr "un sticker" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "A început o simfonie de taste..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Categorie ștearsă" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Categorie plină" @@ -19091,11 +19090,6 @@ msgstr "Este posibil să fi fost șters. Numele a revenit la cel inițial." msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Este posibil să fi părăsit Discovery sau să fi dezactivat înscrierile noi. Reîmprospătează pagina și nu o vei mai vedea." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "E o nebunie de tastat aici" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Membrii fără permisiunea {readMessageHistoryPermissionLabel} pot vedea mesajele trimise după această dată." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Membri, selectați" @@ -22124,6 +22118,11 @@ msgstr "Autentificare în doi pași" msgid "Multi-line" msgstr "Mai multe linii" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Mai multe persoane scriu..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Mesaj nou" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Mesaje noi" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Deschide comunitatea" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Deschide meniul comunității pentru {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Cheie de configurare" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Mai multe persoane scriu..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Omite acest pas" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Salt la conținut" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Acest card nu corespunde țării tale de facturare, deci nu poate fi utilizat pentru această monedă localizată. Folosește un card local sau revino la {productName} și alege altă metodă de plată." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Această categorie conține deja numărul maxim de {maxChannelsPerCategory} canale." @@ -39701,11 +39700,6 @@ msgstr "Cine îți poate vedea profilul complet" msgid "Who is typing" msgstr "Cine scrie" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Uau, e o apocalipsă a scrisului" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/ru/messages.po b/fluxer_app/src/features/i18n/locales/ru/messages.po index 71d491d0f..00b847ae5 100644 --- a/fluxer_app/src/features/i18n/locales/ru/messages.po +++ b/fluxer_app/src/features/i18n/locales/ru/messages.po @@ -44,15 +44,24 @@ msgstr "(вы)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# участник} few {# участника} many {# участников} other {# участника}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} и {b} печатают…" -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} печатает…" -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} и {c} печатают…" @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Каналы «{guildName}»" @@ -2681,11 +2690,6 @@ msgstr "расширение файла, например png" msgid "a forwarded message" msgstr "пересланное сообщение" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Несколько человек уже разминают пальцы..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Запрос на вход выполнен со следующего у msgid "a sticker" msgstr "стикер" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Симфония стучащих клавиш в самом разгаре..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Категория удалена" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Категория заполнена" @@ -19091,11 +19090,6 @@ msgstr "Возможно, оно было удалено. Название ве msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Возможно, оно покинуло каталог или отключило новые вступления. Обновите страницу, и вы больше его не увидите." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Тут все активно печатают" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Участники без разрешения «{readMessageHistoryPermissionLabel}» могут просматривать сообщения, отправленные после этой даты." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Участники, выбрано" @@ -22124,6 +22118,11 @@ msgstr "Многофакторная аутентификация" msgid "Multi-line" msgstr "Несколько строк" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Несколько человек печатают..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Новое сообщение" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Новые сообщения" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Открыть сообщество" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Открыть меню сообщества «{guildName}»" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Ключ настройки" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Несколько человек печатают..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Пропустить" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Перейти к содержимому" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Эта карта не соответствует вашей стране выставления счетов, поэтому ее нельзя использовать для данной локализованной валюты. Используйте местную карту или вернитесь в {productName} и выберите другой способ оплаты." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "В этой категории уже максимальное количество каналов: {maxChannelsPerCategory}." @@ -39701,11 +39700,6 @@ msgstr "Кто может видеть ваш полный профиль" msgid "Who is typing" msgstr "Кто печатает" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Ого, тут все печатают" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/sv-SE/messages.po b/fluxer_app/src/features/i18n/locales/sv-SE/messages.po index 2ab74e167..8ee9bda59 100644 --- a/fluxer_app/src/features/i18n/locales/sv-SE/messages.po +++ b/fluxer_app/src/features/i18n/locales/sv-SE/messages.po @@ -44,15 +44,24 @@ msgstr "(du)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# medlem} other {# medlemmar}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} och {b} skriver..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} skriver..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} och {c} skriver..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Kanaler i {guildName}" @@ -2681,11 +2690,6 @@ msgstr "ett filnamnstillägg, t.ex. png" msgid "a forwarded message" msgstr "ett vidarebefordrat meddelande" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Några tangentbordskrigare samlas..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "En inloggningsförfrågan gjordes från följande enhet. Godkänn bara o msgid "a sticker" msgstr "ett klistermärke" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Det smattrar om tangenterna …" - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Kategori borttagen" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Kategorin är full" @@ -19091,11 +19090,6 @@ msgstr "Den kan ha raderats. Namnet återställdes till vad det var tidigare." msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Communityn kan ha lämnat Upptäck eller slutat ta emot nya medlemmar. Uppdatera sidan så visas den inte längre." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Här skrivs det för fullt" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Medlemmar utan behörigheten {readMessageHistoryPermissionLabel} kan se meddelanden som skickats efter detta datum." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Medlemmar, valda" @@ -22124,6 +22118,11 @@ msgstr "Multifaktor" msgid "Multi-line" msgstr "Flera rader" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Flera personer skriver..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Nytt meddelande" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Nya meddelanden" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Öppna community" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Öppna community-meny för {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Konfigurationsnyckel" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Flera personer skriver..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Hoppa över det här steget" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Hoppa till innehåll" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Det här kortet matchar inte ditt faktureringsland, så det kan inte användas för den här lokala valutan. Använd ett lokalt kort, eller återgå till {productName} och välj en annan betalningsmetod." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Den här kategorin innehåller redan det maximala antalet kanaler: {maxChannelsPerCategory}." @@ -39701,11 +39700,6 @@ msgstr "Vem kan se hela din profil" msgid "Who is typing" msgstr "Vem skriver" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Oj, det är en skrivapokalyps" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/th/messages.po b/fluxer_app/src/features/i18n/locales/th/messages.po index 2d71cea6e..61f8b8443 100644 --- a/fluxer_app/src/features/i18n/locales/th/messages.po +++ b/fluxer_app/src/features/i18n/locales/th/messages.po @@ -44,15 +44,24 @@ msgstr "(คุณ)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {สมาชิก # คน} other {สมาชิก # คน}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} และ {b} กำลังพิมพ์..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} กำลังพิมพ์..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} และ {c} กำลังพิมพ์..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "ช่องของ {guildName}" @@ -2681,11 +2690,6 @@ msgstr "นามสกุลไฟล์ เช่น png" msgid "a forwarded message" msgstr "ข้อความที่ส่งต่อ" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "นักรบสายคีย์บอร์ดกำลังรวมตัว..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "มีการขอลงชื่อเข้าใช้จากอ msgid "a sticker" msgstr "สติกเกอร์" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "เสียงคีย์บอร์ดกำลังบรรเลงเป็นซิมโฟนี..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "ลบหมวดหมู่แล้ว" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "หมวดหมู่เต็ม" @@ -19091,11 +19090,6 @@ msgstr "อิโมจิอาจถูกลบไปแล้ว ชื่ msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "คอมมูนิตี้นี้อาจออกจาก Discovery หรือปิดรับสมาชิกใหม่ไปแล้ว รีเฟรชหน้านี้แล้วจะไม่เห็นอีก" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "พิมพ์กันรัวๆ เลย" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "สมาชิกที่ไม่มีสิทธิ์ {readMessageHistoryPermissionLabel} จะสามารถดูข้อความที่ส่งหลังจากวันที่นี้ได้" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "สมาชิกที่เลือก" @@ -22124,6 +22118,11 @@ msgstr "การยืนยันตัวตนหลายชั้น" msgid "Multi-line" msgstr "หลายบรรทัด" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "มีคนกำลังพิมพ์หลายคน..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "ข้อความใหม่" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "ข้อความใหม่" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "เปิดคอมมูนิตี้" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "เปิดเมนูคอมมูนิตี้สำหรับ {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "คีย์การตั้งค่า" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "หลายคนกำลังพิมพ์..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "ข้ามขั้นตอนนี้" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "ข้ามไปเนื้อหา" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "บัตรนี้ไม่ตรงกับประเทศที่คุณใช้เรียกเก็บเงิน จึงไม่สามารถใช้กับสกุลเงินในภูมิภาคนี้ได้ โปรดใช้บัตรท้องถิ่น หรือกลับไปที่ {productName} และเลือกวิธีการชำระเงินอื่น" #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "หมวดหมู่นี้มีช่องครบตามจำนวนสูงสุดที่ {maxChannelsPerCategory} ช่องแล้ว" @@ -39701,11 +39700,6 @@ msgstr "ใครดูโปรไฟล์ทั้งหมดของคุ msgid "Who is typing" msgstr "ใครกำลังพิมพ์" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "โอ้โห พิมพ์กันรัวๆ เลย" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/tr/messages.po b/fluxer_app/src/features/i18n/locales/tr/messages.po index b7c6a2b2d..23ae18966 100644 --- a/fluxer_app/src/features/i18n/locales/tr/messages.po +++ b/fluxer_app/src/features/i18n/locales/tr/messages.po @@ -44,15 +44,24 @@ msgstr "(siz)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# üye} other {# üye}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} ve {b} yazıyor..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} yazıyor..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} ve {c} yazıyor..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "{guildName} kanalları" @@ -2681,11 +2690,6 @@ msgstr "bir dosya uzantısı, örn. png" msgid "a forwarded message" msgstr "iletilen bir mesaj" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Birkaç klavye savaşçısı toplanıyor..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Şu cihazdan bir oturum açma isteği yapıldı. Yalnızca tanıyorsanı msgid "a sticker" msgstr "bir çıkartma" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Tıkırdayan tuşlardan bir senfoni yükseliyor..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Kategori silindi" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Kategori dolu" @@ -19091,11 +19090,6 @@ msgstr "Silinmiş olabilir. Adı önceki haline döndürüldü." msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Keşfet'ten kaldırılmış veya yeni katılımları kapatmış olabilir. Sayfayı yenilediğinizde bir daha görmezsiniz." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Burada tam bir yazma şöleni var" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "{readMessageHistoryPermissionLabel} izni olmayan üyeler bu tarihten sonra gönderilen mesajları görüntüleyebilir." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Üyeler, seçili" @@ -22124,6 +22118,11 @@ msgstr "Çok faktörlü" msgid "Multi-line" msgstr "Çok satırlı" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Birden fazla kişi yazıyor..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Yeni mesaj" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Yeni mesajlar" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Topluluğu aç" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "{guildName} için topluluk menüsünü aç" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Kurulum anahtarı" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Birkaç kişi yazıyor..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Bu adımı atla" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "İçeriğe atla" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Bu kart, fatura adresinizin bulunduğu ülkeyle eşleşmiyor, bu nedenle bu yerel para birimi için kullanılamaz. Yerel bir kart kullanın veya {productName} uygulamasına dönüp başka bir ödeme yöntemi seçin." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Bu kategori zaten maksimum {maxChannelsPerCategory} kanal içeriyor." @@ -39701,11 +39700,6 @@ msgstr "Profilinizin tamamını kimler görebilir" msgid "Who is typing" msgstr "Kimler yazıyor" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Vay canına, tam bir yazma çılgınlığı" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/uk/messages.po b/fluxer_app/src/features/i18n/locales/uk/messages.po index 771df713b..e8bc612e6 100644 --- a/fluxer_app/src/features/i18n/locales/uk/messages.po +++ b/fluxer_app/src/features/i18n/locales/uk/messages.po @@ -44,15 +44,24 @@ msgstr "(ви)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# учасник} few {# учасники} many {# учасників} other {# учасників}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} та {b} друкують..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} друкує..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} та {c} друкують..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Канали {guildName}" @@ -2681,11 +2690,6 @@ msgstr "розширення файлу, наприклад, png" msgid "a forwarded message" msgstr "переслане повідомлення" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Кілька клавіатурних воїнів збираються..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Запит на вхід надійшов з такого пристро msgid "a sticker" msgstr "стікер" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Симфонія клацання клавіш уже лунає..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Категорію видалено" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Категорія заповнена" @@ -19091,11 +19090,6 @@ msgstr "Можливо, його було видалено. Назву пове msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Можливо, спільноту вилучено з Discovery або вимкнено нові приєднання. Оновіть сторінку, і ви більше її не побачите." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Тут усі активно друкують" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Учасники без дозволу «{readMessageHistoryPermissionLabel}» можуть переглядати повідомлення, надіслані після цієї дати." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Учасники, вибрано" @@ -22124,6 +22118,11 @@ msgstr "Багатофакторна автентифікація" msgid "Multi-line" msgstr "Кілька рядків" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Кілька людей набирають текст..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Нове повідомлення" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Нові повідомлення" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Відкрити спільноту" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Відкрити меню спільноти для {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Ключ налаштування" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Кілька людей друкують..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Пропустити цей крок" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Перейти до вмісту" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Ця картка не відповідає вашій країні виставлення рахунків, тому її не можна використовувати для цієї локалізованої валюти. Скористайтеся місцевою карткою або поверніться до {productName} та виберіть інший спосіб оплати." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Ця категорія вже містить максимальну кількість каналів: {maxChannelsPerCategory}." @@ -39701,11 +39700,6 @@ msgstr "Хто може бачити ваш повний профіль" msgid "Who is typing" msgstr "Хто набирає" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Ого, тут усі друкують одночасно" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/vi/messages.po b/fluxer_app/src/features/i18n/locales/vi/messages.po index 6df1b2032..475b85f40 100644 --- a/fluxer_app/src/features/i18n/locales/vi/messages.po +++ b/fluxer_app/src/features/i18n/locales/vi/messages.po @@ -44,15 +44,24 @@ msgstr "(bạn)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# thành viên} other {# thành viên}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} và {b} đang nhập..." -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} đang nhập..." -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}, {b} và {c} đang nhập..." @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "Kênh của {guildName}" @@ -2681,11 +2690,6 @@ msgstr "một phần mở rộng tệp, ví dụ: png" msgid "a forwarded message" msgstr "một tin nhắn được chuyển tiếp" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "Một vài chiến thần bàn phím đang gõ..." - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "Đã có yêu cầu đăng nhập từ thiết bị sau. Chỉ chấp th msgid "a sticker" msgstr "một nhãn dán" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "Bản giao hưởng tiếng gõ phím đang vang lên..." - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "Đã xóa danh mục" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "Danh mục đã đầy" @@ -19091,11 +19090,6 @@ msgstr "Biểu tượng cảm xúc có thể đã bị xóa. Tên đã được msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "Có thể cộng đồng này đã rời khỏi Khám phá hoặc đã ngừng nhận thành viên mới. Hãy làm mới trang và bạn sẽ không thấy cộng đồng này nữa." -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "Một bữa tiệc gõ phím đang diễn ra sôi nổi" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "Thành viên không có quyền {readMessageHistoryPermissionLabel} có thể xem tin nhắn được gửi sau ngày này." #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "Thành viên, đã chọn" @@ -22124,6 +22118,11 @@ msgstr "Xác thực đa yếu tố" msgid "Multi-line" msgstr "Nhiều dòng" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "Nhiều người đang nhập..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "Tin nhắn mới" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "Tin nhắn mới" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "Mở cộng đồng" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "Mở menu cộng đồng cho {guildName}" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "Khóa thiết lập" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "Một vài người đang nhập..." @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "Bỏ qua bước này" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "Chuyển đến nội dung chính" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "Thẻ này không khớp với quốc gia thanh toán của bạn, vì vậy không thể dùng cho đơn vị tiền tệ được bản địa hóa này. Hãy dùng thẻ nội địa hoặc quay lại {productName} và chọn phương thức thanh toán khác." #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "Danh mục này đã chứa số kênh tối đa là {maxChannelsPerCategory}." @@ -39701,11 +39700,6 @@ msgstr "Ai có thể xem hồ sơ đầy đủ của bạn" msgid "Who is typing" msgstr "Ai đang nhập" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "Chà, nhiều người đang gõ tin nhắn quá" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/zh-CN/messages.po b/fluxer_app/src/features/i18n/locales/zh-CN/messages.po index 510bba5e4..30fb616a1 100644 --- a/fluxer_app/src/features/i18n/locales/zh-CN/messages.po +++ b/fluxer_app/src/features/i18n/locales/zh-CN/messages.po @@ -44,15 +44,24 @@ msgstr "(你)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, other {# 位成员}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} 和 {b} 正在输入…" -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} 正在输入…" -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}、{b} 和 {c} 正在输入…" @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "{guildName} 频道" @@ -2681,11 +2690,6 @@ msgstr "文件扩展名,例如 png" msgid "a forwarded message" msgstr "一条已转发消息" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "几位键盘高手正在集结…" - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "有设备请求登录你的账号。仅在你认识该设备时才批准 msgid "a sticker" msgstr "贴纸" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "一场键盘交响曲正在上演…" - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "类别已删除" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "类别已满" @@ -19091,11 +19090,6 @@ msgstr "表情可能已被删除。名称已恢复到之前的状态。" msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "它可能已退出社区发现,或关闭了新成员加入。刷新页面后,你就不会再看到它了。" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "大家都在打字中" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "没有 {readMessageHistoryPermissionLabel} 权限的成员可以查看此日期之后发送的消息。" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "成员(已选)" @@ -22124,6 +22118,11 @@ msgstr "多重身份验证" msgid "Multi-line" msgstr "多行" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "多人正在输入..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "新消息" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "新消息" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "打开社区" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "打开 {guildName} 的社区菜单" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "设置密钥" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "多人正在输入…" @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "跳过此步骤" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "跳到内容" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "此卡与你的账单国家/地区不符,因此无法用于此本地化货币。请使用本地银行卡,或返回 {productName} 并选择其他付款方式。" #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "此类别已达到 {maxChannelsPerCategory} 个频道的上限。" @@ -39701,11 +39700,6 @@ msgstr "谁可以查看你的完整个人资料" msgid "Who is typing" msgstr "谁正在输入" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "天呐,大家都在打字" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/i18n/locales/zh-TW/messages.po b/fluxer_app/src/features/i18n/locales/zh-TW/messages.po index d5e7655ba..3fb76ca09 100644 --- a/fluxer_app/src/features/i18n/locales/zh-TW/messages.po +++ b/fluxer_app/src/features/i18n/locales/zh-TW/messages.po @@ -44,15 +44,24 @@ msgstr "(您)" msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# 位成員} other {# 位成員}}" -#: src/features/channel/components/TypingUsers.tsx:66 +#. Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:41 +#: src/features/typing/rolling/RollingTypingText.tsx:19 +#: src/features/typing/rolling/RollingTypingText.tsx:54 msgid "{a} and {b} are typing..." msgstr "{a} 和 {b} 正在輸入…" -#: src/features/channel/components/TypingUsers.tsx:62 +#. Screen reader announcement in the composer when one person is typing. {a} is their display name. +#: src/features/channel/components/LegacyTypingUsers.tsx:37 +#: src/features/typing/rolling/RollingTypingText.tsx:15 +#: src/features/typing/rolling/RollingTypingText.tsx:50 msgid "{a} is typing..." msgstr "{a} 正在輸入…" -#: src/features/channel/components/TypingUsers.tsx:73 +#. Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names. +#: src/features/channel/components/LegacyTypingUsers.tsx:48 +#: src/features/typing/rolling/RollingTypingText.tsx:24 +#: src/features/typing/rolling/RollingTypingText.tsx:60 msgid "{a}, {b} and {c} are typing..." msgstr "{a}、{b} 和 {c} 正在輸入…" @@ -1531,7 +1540,7 @@ msgid "{fps} FPS" msgstr "{fps} FPS" #. Short label in the app layout channel list content. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/ChannelListContent.tsx:87 +#: src/features/app/components/layout/ChannelListContent.tsx:88 msgid "{guildName} channels" msgstr "{guildName} 頻道" @@ -2681,11 +2690,6 @@ msgstr "檔案副檔名,例如 png" msgid "a forwarded message" msgstr "轉傳的訊息" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:25 -msgid "A handful of keyboard warriors are assembling..." -msgstr "一群鍵盤高手正在集結中…" - #. Description text in the bot profile section. #: src/features/user/components/modals/tabs/applications_tab/application_detail/BotProfileSection.tsx:60 msgid "A helpful bot that does amazing things!" @@ -2753,11 +2757,6 @@ msgstr "以下裝置發出了登入請求。只有在您認得這台裝置時才 msgid "a sticker" msgstr "貼圖" -#. Description text in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:29 -msgid "A symphony of clacking keys is underway..." -msgstr "鍵盤敲擊聲此起彼落…" - #. Description for has:link. Sentence case, no trailing punctuation. #: src/features/search/utils/SearchUtils.ts:25 msgid "a URL typed in the message text" @@ -6894,7 +6893,7 @@ msgid "Category deleted" msgstr "類別已刪除" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:78 +#: src/features/app/components/layout/ChannelListContent.tsx:79 msgid "Category full" msgstr "類別已滿" @@ -19091,11 +19090,6 @@ msgstr "表情符號可能已被刪除。名稱已還原為先前的名稱。" msgid "It may have left discovery or turned off new joins. Refresh the page and you won't see it again." msgstr "這個社群可能已退出探索,或關閉了新成員加入。重新整理頁面後就不會再看到它。" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:33 -msgid "It's a full-blown typing fiesta in here" -msgstr "這裡現在打字打得正熱鬧" - #. Label in the channel and chat active now sidebar. #: src/features/channel/components/active_now/ActiveNowSidebar.tsx:17 msgid "It's quiet for now..." @@ -21127,7 +21121,7 @@ msgid "Members without the {readMessageHistoryPermissionLabel} permission can vi msgstr "沒有「{readMessageHistoryPermissionLabel}」權限的成員,可以查看此日期之後傳送的訊息。" #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:91 +#: src/features/app/components/layout/ChannelListContent.tsx:92 msgid "Members, selected" msgstr "已選取成員" @@ -22124,6 +22118,11 @@ msgstr "多重要素驗證" msgid "Multi-line" msgstr "多行" +#. Label in the composer typing row when the names of the people typing do not fit. +#: src/features/typing/rolling/RollingTypingText.tsx:29 +msgid "Multiple people are typing..." +msgstr "多人正在輸入..." + #. Discovery category dropdown option in the community Discovery settings tab. #: src/features/guild/components/modals/guild_tabs/GuildDiscoveryTab.tsx:80 msgid "Music" @@ -22790,7 +22789,7 @@ msgstr "新訊息" #. Settings search synonym. Used to match this term when the user types it in the settings search bar. #. Short label in the app layout channel list content. -#: src/features/app/components/layout/ChannelListContent.tsx:95 +#: src/features/app/components/layout/ChannelListContent.tsx:96 #: src/features/user/components/settings_utils/section_registry/AccessibilitySections.ts:147 msgid "New messages" msgstr "新訊息" @@ -24250,7 +24249,7 @@ msgid "Open community" msgstr "開啟社群" #. Short label in the app layout guild header. Preserve {guildName}; it is inserted by code. -#: src/features/app/components/layout/GuildHeader.tsx:38 +#: src/features/app/components/layout/GuildHeader.tsx:33 msgid "Open community menu for {guildName}" msgstr "開啟「{guildName}」社群選單" @@ -30718,7 +30717,7 @@ msgid "Setup key" msgstr "設定金鑰" #. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:21 +#: src/features/typing/utils/TypingTierText.ts:6 msgid "Several people are typing..." msgstr "多人正在輸入中…" @@ -31685,7 +31684,7 @@ msgid "Skip this step" msgstr "略過此步驟" #. Accessible skip-link label for keyboard users. -#: src/app/App.tsx:75 +#: src/app/App.tsx:76 msgid "Skip to content" msgstr "跳到內容" @@ -34454,7 +34453,7 @@ msgid "This card does not match your billing country, so it cannot be used for t msgstr "此卡片與您的帳單國家/地區不符,因此無法用於此本地化貨幣。請使用當地卡片,或返回 {productName} 並選擇其他付款方式。" #. Modal body shown when a category cannot accept another channel because the per-category limit is reached. Limit is interpolated. -#: src/features/app/components/layout/ChannelListContent.tsx:82 +#: src/features/app/components/layout/ChannelListContent.tsx:83 msgid "This category already contains the maximum of {maxChannelsPerCategory} channels." msgstr "此類別已包含最多 {maxChannelsPerCategory} 個頻道。" @@ -39701,11 +39700,6 @@ msgstr "誰能看見您的完整個人檔案" msgid "Who is typing" msgstr "誰正在輸入" -#. Label in the channel and chat typing users. -#: src/features/channel/components/TypingUsers.tsx:37 -msgid "Whoa, it's a typing apocalypse" -msgstr "哇,大家都在打字耶" - #. Label in the too many attachments modal. #. Label in the too many reactions modal. #: src/features/messaging/components/alerts/TooManyAttachmentsModal.tsx:9 diff --git a/fluxer_app/src/features/messaging/hooks/useMessageSubmission.test.tsx b/fluxer_app/src/features/messaging/hooks/useMessageSubmission.test.tsx index 70cece0e2..7f15c0655 100644 --- a/fluxer_app/src/features/messaging/hooks/useMessageSubmission.test.tsx +++ b/fluxer_app/src/features/messaging/hooks/useMessageSubmission.test.tsx @@ -3,6 +3,7 @@ import type {Channel} from '@app/features/channel/models/Channel'; import {type SendMessageFunction, useMessageSubmission} from '@app/features/messaging/hooks/useMessageSubmission'; +import {TypingUtils} from '@app/features/typing/utils/TypingUtils'; import {MessageFlags} from '@fluxer/constants/src/ChannelConstants'; import {act, createElement} from 'react'; import {createRoot, type Root} from 'react-dom/client'; @@ -51,7 +52,7 @@ vi.mock('@app/features/slowmode/components/alerts/SlowmodeRateLimitedModal', () SlowmodeRateLimitedModal: () => null, })); vi.mock('@app/features/ui/commands/ModalCommands', () => ({modal: (render: unknown) => render, push: vi.fn()})); -vi.mock('@app/features/typing/utils/TypingUtils', () => ({TypingUtils: {clear: vi.fn()}})); +vi.mock('@app/features/typing/utils/TypingUtils', () => ({TypingUtils: {handleOwnMessageSent: vi.fn()}})); vi.mock('@app/features/platform/utils/ComponentBus', () => ({ComponentBus: {dispatch: vi.fn()}})); vi.mock('@lingui/core/macro', () => ({msg: (value: unknown) => value})); vi.mock('@lingui/react/macro', () => ({useLingui: () => ({i18n: {_: () => ''}})})); @@ -61,16 +62,20 @@ const channel = {id: 'c'} as unknown as Channel; let host: HTMLDivElement; let root: Root; -function renderSendMessage(): SendMessageFunction { - let sendMessage: SendMessageFunction | null = null; +function renderSubmission(): ReturnType { + let submission: ReturnType | null = null; const Probe = () => { - sendMessage = useMessageSubmission({channel, referencedMessage: null, replyingMessage: null}).sendMessage; + submission = useMessageSubmission({channel, referencedMessage: null, replyingMessage: null}); return null; }; act(() => { root.render(createElement(Probe)); }); - return sendMessage!; + return submission!; +} + +function renderSendMessage(): SendMessageFunction { + return renderSubmission().sendMessage; } beforeEach(() => { @@ -124,6 +129,22 @@ describe('useMessageSubmission', () => { ); }); + it('tells typing that its own message was sent before creating the optimistic message', () => { + const handleOwnMessageSent = vi.mocked(TypingUtils.handleOwnMessageSent); + const {sendMessage, sendOptimisticMessage} = renderSubmission(); + + expect(sendMessage('hello', false)).toBe(true); + sendOptimisticMessage({content: 'a sticker caption'}, {hasAttachments: false}); + + expect(handleOwnMessageSent.mock.calls).toEqual([['c'], ['c']]); + expect(messageCommands.createOptimistic).toHaveBeenCalledTimes(2); + const typingOrder = handleOwnMessageSent.mock.invocationCallOrder; + const optimisticOrder = messageCommands.createOptimistic.mock.invocationCallOrder; + expect(typingOrder[0]!).toBeLessThan(optimisticOrder[0]!); + expect(optimisticOrder[0]!).toBeLessThan(typingOrder[1]!); + expect(typingOrder[1]!).toBeLessThan(optimisticOrder[1]!); + }); + it('sends the text after @silent with the silent flag', () => { const sendMessage = renderSendMessage(); expect(sendMessage('@silent hi', false)).toBe(true); diff --git a/fluxer_app/src/features/messaging/hooks/useMessageSubmission.tsx b/fluxer_app/src/features/messaging/hooks/useMessageSubmission.tsx index 8defb4d06..bd729c1bc 100644 --- a/fluxer_app/src/features/messaging/hooks/useMessageSubmission.tsx +++ b/fluxer_app/src/features/messaging/hooks/useMessageSubmission.tsx @@ -103,7 +103,7 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag const nonce = SnowflakeUtils.fromTimestamp(Date.now()); if (!MessageCommands.reserveSend(channel.id, nonce)) return false; const messageReference = MessageSubmitUtils.prepareMessageReference(channel.id, referencedMessage); - TypingUtils.clear(channel.id); + TypingUtils.handleOwnMessageSent(channel.id); DraftCommands.deleteDraft(channel.id); MessageCommands.stopReply(channel.id); const uploadingAttachments = MessageSubmitUtils.createUploadingAttachments( @@ -182,7 +182,7 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag if (isBlockedBySlowmode(channel)) return; const nonce = SnowflakeUtils.fromTimestamp(Date.now()); if (!MessageCommands.reserveSend(channel.id, nonce)) return; - TypingUtils.clear(channel.id); + TypingUtils.handleOwnMessageSent(channel.id); MessageCommands.stopReply(channel.id); const message = new Message({ id: nonce, diff --git a/fluxer_app/src/features/messaging/hooks/useTextareaDraftAndTyping.test.tsx b/fluxer_app/src/features/messaging/hooks/useTextareaDraftAndTyping.test.tsx new file mode 100644 index 000000000..5fa1f142d --- /dev/null +++ b/fluxer_app/src/features/messaging/hooks/useTextareaDraftAndTyping.test.tsx @@ -0,0 +1,306 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Endpoints} from '@app/features/app/constants/Endpoints'; +import {act, createElement, useRef, useState} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const doubles = vi.hoisted(() => ({ + post: vi.fn((_path: string) => Promise.resolve({ok: true})), +})); + +vi.mock('@app/features/messaging/commands/DraftCommands', () => ({createDraft: vi.fn(), deleteDraft: vi.fn()})); +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: doubles.post}})); +vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: 'me'}})); +vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: {showMyselfTyping: false}})); +vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}})); +vi.mock('@app/features/user/state/Users', () => ({default: {getUser: () => undefined}})); + +const {useTextareaDraftAndTyping} = await import('@app/features/messaging/hooks/useTextareaDraftAndTyping'); +const {default: TypingPolicy} = await import('@app/features/typing/state/TypingPolicy'); +const {TypingUtils: LegacyTypingUtils} = await import('@app/features/typing/legacy/LegacyTypingUtils'); +const {default: LegacyTypingIndicator} = await import('@app/features/typing/legacy/LegacyTypingIndicator'); +const {default: RollingTypingSender} = await import('@app/features/typing/rolling/RollingTypingSender'); +const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore'); + +interface ComposerProps { + channelId: string; + draft?: string | null; + isAutocompleteAttached?: boolean; + enabled?: boolean; + typingEnabled?: boolean; + isEditingMessageInComposer?: boolean; +} + +let host: HTMLDivElement; +let root: Root; +let setComposerValue: (value: string) => void = () => undefined; + +function Composer({ + channelId, + draft = null, + isAutocompleteAttached = false, + enabled = true, + typingEnabled, + isEditingMessageInComposer = false, +}: ComposerProps) { + const [value, setValue] = useState(''); + const previousValueRef = useRef(''); + setComposerValue = setValue; + useTextareaDraftAndTyping({ + channelId, + value, + setValue, + draft, + previousValueRef, + isAutocompleteAttached, + enabled, + typingEnabled, + isEditingMessageInComposer, + }); + return null; +} + +function render(props: ComposerProps): void { + act(() => { + root.render(createElement(Composer, props)); + }); +} + +function type(value: string): void { + act(() => { + setComposerValue(value); + }); +} + +function advance(ms: number): void { + act(() => { + vi.advanceTimersByTime(ms); + }); +} + +function recordLegacyCalls(): Array<[string, string]> { + const calls: Array<[string, string]> = []; + vi.spyOn(LegacyTypingUtils, 'typing').mockImplementation((channelId) => { + calls.push(['typing', channelId]); + }); + vi.spyOn(LegacyTypingUtils, 'clear').mockImplementation((channelId) => { + calls.push(['clear', channelId]); + }); + return calls; +} + +beforeEach(() => { + (globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers(); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => { + root.unmount(); + }); + vi.restoreAllMocks(); + TypingPolicy.applyPolicy('legacy'); + LegacyTypingIndicator.reset(); + RollingTypingSender.reset(); + RollingTypingStore.reset(); + document.body.replaceChildren(); + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('useTextareaDraftAndTyping', () => { + it('calls the legacy composer effect on exactly the head dependency changes under control', () => { + const calls = recordLegacyCalls(); + render({channelId: 'c1'}); + expect(calls).toEqual([['clear', 'c1']]); + + type('h'); + render({channelId: 'c1'}); + render({channelId: 'c1', isAutocompleteAttached: true}); + render({channelId: 'c1', isAutocompleteAttached: true, typingEnabled: false}); + render({channelId: 'c1', isAutocompleteAttached: true, typingEnabled: true}); + + expect(calls).toEqual([ + ['clear', 'c1'], + ['typing', 'c1'], + ['clear', 'c1'], + ['clear', 'c1'], + ['clear', 'c1'], + ]); + }); + + it('does not rerun the effect when editing mode toggles under control', () => { + const calls = recordLegacyCalls(); + render({channelId: 'c1'}); + type('hello'); + calls.length = 0; + + render({channelId: 'c1', isEditingMessageInComposer: true}); + render({channelId: 'c1', isEditingMessageInComposer: false}); + + expect(calls).toEqual([]); + }); + + it('clears the legacy state on unmount under control', () => { + const calls = recordLegacyCalls(); + render({channelId: 'c1'}); + type('hello'); + calls.length = 0; + + act(() => { + root.unmount(); + }); + root = createRoot(host); + + expect(calls).toEqual([['clear', 'c1']]); + }); + + it('clears the legacy state when the composer is disabled under control', () => { + const calls = recordLegacyCalls(); + render({channelId: 'c1'}); + type('hello'); + calls.length = 0; + + render({channelId: 'c1', enabled: false}); + + expect(calls).toEqual([['clear', 'c1']]); + }); + + it('arms no rolling timer under control', () => { + render({channelId: 'c1'}); + type('hello'); + + expect(LegacyTypingIndicator.isLocalTyping('c1', 'me')).toBe(true); + expect(RollingTypingStore.countTypists('c1')).toBe(0); + advance(1500); + expect(doubles.post).toHaveBeenCalledExactlyOnceWith(Endpoints.CHANNEL_TYPING('c1')); + expect(RollingTypingStore.countTypists('c1')).toBe(0); + }); + + it('sends typing once for a burst of keystrokes under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + render({channelId: 'c1'}); + + let draft = ''; + for (const character of 'hello there, how are you') { + draft += character; + type(draft); + advance(150); + } + advance(3000); + + expect(doubles.post).toHaveBeenCalledExactlyOnceWith(Endpoints.CHANNEL_TYPING('c1')); + }); + + it('never sends typing when entering mobile edit mode under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + render({channelId: 'c1'}); + + act(() => { + root.render(createElement(Composer, {channelId: 'c1', isEditingMessageInComposer: true})); + setComposerValue('the message being edited'); + }); + type('the message being edited again'); + advance(20000); + render({channelId: 'c1', isEditingMessageInComposer: false}); + render({channelId: 'c1', isEditingMessageInComposer: true}); + type('a later edit keystroke'); + advance(20000); + + expect(doubles.post).not.toHaveBeenCalled(); + expect(RollingTypingStore.isTyping('c1', 'me')).toBe(false); + }); + + it('sends nothing for a draft present at mount under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + + render({channelId: 'c1', draft: 'saved draft'}); + advance(0); + render({channelId: 'c1', draft: 'saved draft', isAutocompleteAttached: true}); + advance(20000); + + expect(doubles.post).not.toHaveBeenCalled(); + }); + + it('sends nothing for a draft restored after mount under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + render({channelId: 'c1'}); + advance(1000); + + render({channelId: 'c1', draft: 'synced from another device'}); + advance(0); + render({channelId: 'c1', draft: 'synced from another device', isAutocompleteAttached: true}); + advance(20000); + + expect(doubles.post).not.toHaveBeenCalled(); + }); + + it('does not cancel a pending send when the channel changes under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + render({channelId: 'c1'}); + type('hello'); + advance(500); + + render({channelId: 'c2'}); + advance(1000); + + expect(doubles.post).toHaveBeenCalledExactlyOnceWith(Endpoints.CHANNEL_TYPING('c1')); + advance(20000); + expect(doubles.post).toHaveBeenCalledTimes(1); + }); + + it('cancels a pending send when the composer becomes disabled under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + render({channelId: 'c1'}); + type('hello'); + advance(500); + + render({channelId: 'c1', enabled: false}); + advance(20000); + + expect(doubles.post).not.toHaveBeenCalled(); + expect(RollingTypingStore.isTyping('c1', 'me')).toBe(false); + }); + + it('starts no typing merely because the policy flipped on', () => { + render({channelId: 'c1'}); + type('hello'); + advance(1500); + expect(doubles.post).toHaveBeenCalledTimes(1); + + act(() => { + TypingPolicy.applyPolicy('rolling'); + }); + render({channelId: 'c1'}); + advance(20000); + + expect(doubles.post).toHaveBeenCalledTimes(1); + expect(RollingTypingStore.isTyping('c1', 'me')).toBe(false); + }); + + it('posts nothing for a spaces-only draft under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + render({channelId: 'c1'}); + + type(' '); + type(' '); + type('\t '); + advance(20000); + + expect(doubles.post).not.toHaveBeenCalled(); + expect(RollingTypingStore.isTyping('c1', 'me')).toBe(false); + }); +}); diff --git a/fluxer_app/src/features/messaging/hooks/useTextareaDraftAndTyping.ts b/fluxer_app/src/features/messaging/hooks/useTextareaDraftAndTyping.ts index de2c0602d..bf4c094b8 100644 --- a/fluxer_app/src/features/messaging/hooks/useTextareaDraftAndTyping.ts +++ b/fluxer_app/src/features/messaging/hooks/useTextareaDraftAndTyping.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import * as DraftCommands from '@app/features/messaging/commands/DraftCommands'; -import * as ReplaceCommandUtils from '@app/features/messaging/utils/ReplaceCommandUtils'; import type {MentionSegment, TextareaSegmentManager} from '@app/features/messaging/utils/TextareaSegmentManager'; import {TypingUtils} from '@app/features/typing/utils/TypingUtils'; import {useEffect, useRef} from 'react'; @@ -57,6 +56,8 @@ export const useTextareaDraftAndTyping = ({ isEditingMessageInComposer, }: UseTextareaDraftAndTypingOptions) => { const isRestoringDraftRef = useRef(false); + const isEditingMessageInComposerRef = useRef(isEditingMessageInComposer); + const typingPreviousValueRef = useRef<{channelId: string; value: string} | null>(null); const currentDraftRef = useRef(draft); const currentDraftSegmentsRef = useRef>(draftSegments ?? []); const pendingDraftRef = useRef<{ @@ -68,9 +69,12 @@ export const useTextareaDraftAndTyping = ({ currentDraftRef.current = draft; currentDraftSegmentsRef.current = draftSegments ?? []; }, [draft, draftSegments]); + useEffect(() => { + isEditingMessageInComposerRef.current = isEditingMessageInComposer; + }, [isEditingMessageInComposer]); useEffect(() => { return () => { - TypingUtils.clear(channelId); + TypingUtils.releaseComposer(channelId); }; }, [channelId]); useEffect(() => { @@ -163,20 +167,19 @@ export const useTextareaDraftAndTyping = ({ }; }, [channelId, isEditingMessageInComposer]); useEffect(() => { + const typingPreviousValue = typingPreviousValueRef.current; + typingPreviousValueRef.current = {channelId, value}; if (isRestoringDraftRef.current) { return; } - if (!enabled || !typingEnabled) { - TypingUtils.clear(channelId); - return; - } - const content = value.trim(); - const isInReplaceMode = ReplaceCommandUtils.isReplaceCommand(content); - const isSlashCommand = content.startsWith('/'); - if (content && !isAutocompleteAttached && !isInReplaceMode && !isSlashCommand) { - TypingUtils.typing(channelId); - } else { - TypingUtils.clear(channelId); - } + TypingUtils.handleComposerChange({ + channelId, + value, + previousValue: typingPreviousValue?.channelId === channelId ? typingPreviousValue.value : null, + isAutocompleteAttached, + enabled, + typingEnabled, + isEditingMessageInComposer: isEditingMessageInComposerRef.current, + }); }, [channelId, value, isAutocompleteAttached, enabled, typingEnabled]); }; diff --git a/fluxer_app/src/features/messaging/utils/SlashCommandUtils.ts b/fluxer_app/src/features/messaging/utils/SlashCommandUtils.ts index cef0ede75..dadbdecd0 100644 --- a/fluxer_app/src/features/messaging/utils/SlashCommandUtils.ts +++ b/fluxer_app/src/features/messaging/utils/SlashCommandUtils.ts @@ -29,6 +29,10 @@ export interface AutocompleteTrigger { matchedText: string; } +export function isEmojiReactionShorthand(value: string): boolean { + return EMOJI_REACTION_REGEX.test(value); +} + export function detectAutocompleteTrigger(textUpToCursor: string): AutocompleteTrigger | null { const emojiReactionMatch = textUpToCursor.match(EMOJI_REACTION_REGEX); if (emojiReactionMatch) { diff --git a/fluxer_app/src/features/typing/commands/TypingCommands.ts b/fluxer_app/src/features/typing/commands/TypingCommands.ts index 4520bcfc9..03564ff9d 100644 --- a/fluxer_app/src/features/typing/commands/TypingCommands.ts +++ b/fluxer_app/src/features/typing/commands/TypingCommands.ts @@ -1,56 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {Endpoints} from '@app/features/app/constants/Endpoints'; -import {http} from '@app/features/platform/transport/RestTransport'; -import {Logger} from '@app/features/platform/utils/AppLogger'; -import TypingIndicator from '@app/features/typing/state/TypingIndicator'; - -const logger = new Logger('Typing'); - -type TypingMutation = 'start' | 'stop'; - -async function postTypingIndicator(channelId: string): Promise { - await http.post(Endpoints.CHANNEL_TYPING(channelId)); -} - -function logTypingSendFailure(channelId: string, error: unknown): void { - logger.error(`Failed to send typing indicator to channel ${channelId}:`, error); -} - -function updateTypingState(mutation: TypingMutation, channelId: string, userId: string): void { - if (mutation === 'start') { - TypingIndicator.startRemoteTyping(channelId, userId); - return; - } - TypingIndicator.stopTyping(channelId, userId); -} - -export async function sendTyping(channelId: string): Promise { - try { - logger.debug(`Sending typing indicator to channel ${channelId}`); - await postTypingIndicator(channelId); - logger.debug(`Successfully sent typing indicator to channel ${channelId}`); - } catch (error) { - logTypingSendFailure(channelId, error); - } -} +import * as LegacyTypingCommands from '@app/features/typing/legacy/LegacyTypingCommands'; +import RollingTypingStore from '@app/features/typing/rolling/RollingTypingStore'; +import TypingPolicy from '@app/features/typing/state/TypingPolicy'; export function startTyping(channelId: string, userId: string): void { - logger.debug(`Starting typing indicator for user ${userId} in channel ${channelId}`); - updateTypingState('start', channelId, userId); -} - -export function stopTyping(channelId: string, userId: string): void { - logger.debug(`Stopping typing indicator for user ${userId} in channel ${channelId}`); - updateTypingState('stop', channelId, userId); -} - -export function startLocalTyping(channelId: string, userId: string): void { - logger.debug(`Starting local typing indicator for user ${userId} in channel ${channelId}`); - TypingIndicator.startLocalTyping(channelId, userId); -} - -export function stopLocalTyping(channelId: string, userId: string): void { - logger.debug(`Stopping local typing indicator for user ${userId} in channel ${channelId}`); - TypingIndicator.stopLocalTyping(channelId, userId); + if (TypingPolicy.active === 'rolling') { + RollingTypingStore.start(channelId, userId, 'gateway'); + return; + } + LegacyTypingCommands.startTyping(channelId, userId); } diff --git a/fluxer_app/src/features/typing/legacy/LegacyComposerTyping.ts b/fluxer_app/src/features/typing/legacy/LegacyComposerTyping.ts new file mode 100644 index 000000000..b5d3fc125 --- /dev/null +++ b/fluxer_app/src/features/typing/legacy/LegacyComposerTyping.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import * as ReplaceCommandUtils from '@app/features/messaging/utils/ReplaceCommandUtils'; +import {TypingUtils as LegacyTypingUtils} from '@app/features/typing/legacy/LegacyTypingUtils'; + +interface LegacyComposerTypingInput { + channelId: string; + value: string; + isAutocompleteAttached: boolean; + enabled: boolean; + typingEnabled: boolean; +} + +export function runLegacyComposerTyping({ + channelId, + value, + isAutocompleteAttached, + enabled, + typingEnabled, +}: LegacyComposerTypingInput): void { + if (!enabled || !typingEnabled) { + LegacyTypingUtils.clear(channelId); + return; + } + const content = value.trim(); + const isInReplaceMode = ReplaceCommandUtils.isReplaceCommand(content); + const isSlashCommand = content.startsWith('/'); + if (content && !isAutocompleteAttached && !isInReplaceMode && !isSlashCommand) { + LegacyTypingUtils.typing(channelId); + } else { + LegacyTypingUtils.clear(channelId); + } +} diff --git a/fluxer_app/src/features/typing/legacy/LegacyTypingCommands.ts b/fluxer_app/src/features/typing/legacy/LegacyTypingCommands.ts new file mode 100644 index 000000000..fd8bdbb9f --- /dev/null +++ b/fluxer_app/src/features/typing/legacy/LegacyTypingCommands.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Endpoints} from '@app/features/app/constants/Endpoints'; +import {http} from '@app/features/platform/transport/RestTransport'; +import {Logger} from '@app/features/platform/utils/AppLogger'; +import TypingIndicator from '@app/features/typing/legacy/LegacyTypingIndicator'; + +const logger = new Logger('Typing'); + +type TypingMutation = 'start' | 'stop'; + +async function postTypingIndicator(channelId: string): Promise { + await http.post(Endpoints.CHANNEL_TYPING(channelId)); +} + +function logTypingSendFailure(channelId: string, error: unknown): void { + logger.error(`Failed to send typing indicator to channel ${channelId}:`, error); +} + +function updateTypingState(mutation: TypingMutation, channelId: string, userId: string): void { + if (mutation === 'start') { + TypingIndicator.startRemoteTyping(channelId, userId); + return; + } + TypingIndicator.stopTyping(channelId, userId); +} + +export async function sendTyping(channelId: string): Promise { + try { + logger.debug(`Sending typing indicator to channel ${channelId}`); + await postTypingIndicator(channelId); + logger.debug(`Successfully sent typing indicator to channel ${channelId}`); + } catch (error) { + logTypingSendFailure(channelId, error); + } +} + +export function startTyping(channelId: string, userId: string): void { + logger.debug(`Starting typing indicator for user ${userId} in channel ${channelId}`); + updateTypingState('start', channelId, userId); +} + +export function stopTyping(channelId: string, userId: string): void { + logger.debug(`Stopping typing indicator for user ${userId} in channel ${channelId}`); + updateTypingState('stop', channelId, userId); +} + +export function startLocalTyping(channelId: string, userId: string): void { + logger.debug(`Starting local typing indicator for user ${userId} in channel ${channelId}`); + TypingIndicator.startLocalTyping(channelId, userId); +} + +export function stopLocalTyping(channelId: string, userId: string): void { + logger.debug(`Stopping local typing indicator for user ${userId} in channel ${channelId}`); + TypingIndicator.stopLocalTyping(channelId, userId); +} diff --git a/fluxer_app/src/features/typing/legacy/LegacyTypingIndicator.ts b/fluxer_app/src/features/typing/legacy/LegacyTypingIndicator.ts new file mode 100644 index 000000000..9e7231f75 --- /dev/null +++ b/fluxer_app/src/features/typing/legacy/LegacyTypingIndicator.ts @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {action, makeAutoObservable, observable} from 'mobx'; + +type TypingEntry = Readonly<{ + expiresAt: number; +}>; + +type TypingEntriesByChannel = Record>; +type TypingUsersCacheEntry = Readonly<{ + users: ReadonlyArray; + expiresAt: number | null; + version: number; +}>; + +export interface LocalTypingResetOwner { + reset(): void; +} + +export const TYPING_INDICATOR_TIMEOUT_MS = 10000; +const EMPTY_TYPING_USERS: ReadonlyArray = Object.freeze([]); + +class TypingIndicator { + remoteTypingUsersByChannel: TypingEntriesByChannel = {}; + localTypingUsersByChannel: TypingEntriesByChannel = {}; + private typingUsersCacheByChannel: Record = {}; + private typingUsersVersionByChannel = new Map(); + private expiryTimerId: NodeJS.Timeout | null = null; + private localTypingResetOwner: LocalTypingResetOwner | null = null; + + constructor() { + makeAutoObservable< + this, + 'typingUsersCacheByChannel' | 'typingUsersVersionByChannel' | 'expiryTimerId' | 'localTypingResetOwner' + >( + this, + { + expiryTimerId: false, + localTypingResetOwner: false, + typingUsersCacheByChannel: false, + typingUsersVersionByChannel: observable.shallow, + }, + {autoBind: true}, + ); + } + + getTypingUsers(channelId: string): ReadonlyArray { + const now = Date.now(); + const storedVersion = this.typingUsersVersionByChannel.get(channelId); + const version = storedVersion == null ? 0 : storedVersion; + const cached = this.typingUsersCacheByChannel[channelId]; + if (cached && cached.version === version && (cached.expiresAt === null || cached.expiresAt > now)) { + return cached.users; + } + const {expiresAt, users} = this.collectTypingUsers(channelId, now); + const typedUsers = users.length === 0 ? EMPTY_TYPING_USERS : Object.freeze(users); + this.typingUsersCacheByChannel[channelId] = { + expiresAt, + users: typedUsers, + version, + }; + return typedUsers; + } + + getCount(channelId: string): number { + return this.getTypingUsers(channelId).length; + } + + isTyping(channelId: string, userId: string): boolean { + return this.isRemoteTyping(channelId, userId) || this.isLocalTyping(channelId, userId); + } + + isMemberListTyping(channelId: string, userId: string, currentUserId: string | null | undefined): boolean { + if (currentUserId && userId === currentUserId) { + return this.isLocalTyping(channelId, userId); + } + return this.isTyping(channelId, userId); + } + + isLocalTyping(channelId: string, userId: string): boolean { + return this.hasActiveTypingEntry(this.localTypingUsersByChannel, channelId, userId, Date.now()); + } + + isRemoteTyping(channelId: string, userId: string): boolean { + return this.hasActiveTypingEntry(this.remoteTypingUsersByChannel, channelId, userId, Date.now()); + } + + private collectTypingUsers(channelId: string, now: number): {users: Array; expiresAt: number | null} { + const users: Array = []; + const seen = new Set(); + let expiresAt: number | null = null; + const collect = (entries: Record | undefined) => { + if (!entries) return; + for (const [userId, entry] of Object.entries(entries)) { + if (entry.expiresAt <= now) { + continue; + } + if (!seen.has(userId)) { + seen.add(userId); + users.push(userId); + } + expiresAt = expiresAt === null ? entry.expiresAt : Math.min(expiresAt, entry.expiresAt); + } + }; + collect(this.remoteTypingUsersByChannel[channelId]); + collect(this.localTypingUsersByChannel[channelId]); + return {expiresAt, users}; + } + + private hasActiveTypingEntry( + entriesByChannel: TypingEntriesByChannel, + channelId: string, + userId: string, + now: number, + ): boolean { + const entry = entriesByChannel[channelId]?.[userId]; + return entry !== undefined && entry.expiresAt > now; + } + + private upsertTypingEntry(entriesByChannel: TypingEntriesByChannel, channelId: string, userId: string): void { + const now = Date.now(); + if (!entriesByChannel[channelId]) { + entriesByChannel[channelId] = {}; + } + const channelUsers = entriesByChannel[channelId]; + const existingEntry = channelUsers[userId]; + const wasVisible = existingEntry !== undefined && existingEntry.expiresAt > now; + channelUsers[userId] = { + expiresAt: now + TYPING_INDICATOR_TIMEOUT_MS, + }; + if (!wasVisible) { + this.invalidateTypingUsers(channelId, now); + } + this.scheduleNextExpiryTimer(now); + } + + private removeTypingEntry(entriesByChannel: TypingEntriesByChannel, channelId: string, userId: string): void { + const channelUsers = entriesByChannel[channelId]; + if (!channelUsers?.[userId]) { + return; + } + delete channelUsers[userId]; + if (Object.keys(channelUsers).length === 0) { + delete entriesByChannel[channelId]; + } + const now = Date.now(); + this.invalidateTypingUsers(channelId, now); + this.scheduleNextExpiryTimer(now); + } + + private removeExpiredTypingEntries(now: number): void { + this.removeExpiredTypingEntriesFrom(this.remoteTypingUsersByChannel, now); + this.removeExpiredTypingEntriesFrom(this.localTypingUsersByChannel, now); + } + + private removeExpiredTypingEntriesFrom(entriesByChannel: TypingEntriesByChannel, now: number): void { + for (const [channelId, channelUsers] of Object.entries(entriesByChannel)) { + let removed = false; + for (const [userId, entry] of Object.entries(channelUsers)) { + if (entry.expiresAt > now) { + continue; + } + delete channelUsers[userId]; + removed = true; + } + if (Object.keys(channelUsers).length === 0) { + delete entriesByChannel[channelId]; + } + if (removed) { + this.invalidateTypingUsers(channelId, now); + } + } + } + + private getNextExpiresAt(now: number): number | null { + let nextExpiresAt: number | null = null; + const collect = (entriesByChannel: TypingEntriesByChannel) => { + for (const channelUsers of Object.values(entriesByChannel)) { + for (const entry of Object.values(channelUsers)) { + if (entry.expiresAt <= now) { + continue; + } + nextExpiresAt = nextExpiresAt === null ? entry.expiresAt : Math.min(nextExpiresAt, entry.expiresAt); + } + } + }; + collect(this.remoteTypingUsersByChannel); + collect(this.localTypingUsersByChannel); + return nextExpiresAt; + } + + private scheduleNextExpiryTimer(now: number): void { + this.clearExpiryTimer(); + const nextExpiresAt = this.getNextExpiresAt(now); + if (nextExpiresAt === null) { + return; + } + this.expiryTimerId = setTimeout( + () => { + this.expiryTimerId = null; + this.pruneExpiredTypingUsers(); + }, + Math.max(0, nextExpiresAt - now), + ); + } + + private clearExpiryTimer(): void { + if (!this.expiryTimerId) { + return; + } + clearTimeout(this.expiryTimerId); + this.expiryTimerId = null; + } + + private hasActiveTypingUsers(channelId: string, now: number): boolean { + const hasActiveEntry = (entries: Record | undefined) => + entries !== undefined && Object.values(entries).some((entry) => entry.expiresAt > now); + return ( + hasActiveEntry(this.remoteTypingUsersByChannel[channelId]) || + hasActiveEntry(this.localTypingUsersByChannel[channelId]) + ); + } + + private invalidateTypingUsers(channelId: string, now: number): void { + delete this.typingUsersCacheByChannel[channelId]; + if (!this.hasActiveTypingUsers(channelId, now)) { + this.typingUsersVersionByChannel.delete(channelId); + return; + } + const currentVersion = this.typingUsersVersionByChannel.get(channelId); + const nextVersion = currentVersion == null ? 1 : currentVersion + 1; + this.typingUsersVersionByChannel.set(channelId, nextVersion); + } + + @action + pruneExpiredTypingUsers(now = Date.now()): void { + this.removeExpiredTypingEntries(now); + this.scheduleNextExpiryTimer(now); + } + + @action + reset(): void { + const resetOwner = this.localTypingResetOwner; + if (resetOwner != null) { + resetOwner.reset(); + } + this.clearExpiryTimer(); + this.remoteTypingUsersByChannel = {}; + this.localTypingUsersByChannel = {}; + this.typingUsersCacheByChannel = {}; + this.typingUsersVersionByChannel.clear(); + } + + registerLocalTypingResetOwner(owner: LocalTypingResetOwner): void { + if (this.localTypingResetOwner != null) { + throw new Error('Local typing reset owner is already registered'); + } + this.localTypingResetOwner = owner; + } + + @action + startTyping(channelId: string, userId: string): void { + this.startRemoteTyping(channelId, userId); + } + + @action + startRemoteTyping(channelId: string, userId: string): void { + this.upsertTypingEntry(this.remoteTypingUsersByChannel, channelId, userId); + } + + @action + stopTyping(channelId: string, userId: string): void { + this.stopLocalTyping(channelId, userId); + this.stopRemoteTyping(channelId, userId); + } + + @action + startLocalTyping(channelId: string, userId: string): void { + this.upsertTypingEntry(this.localTypingUsersByChannel, channelId, userId); + } + + @action + stopLocalTyping(channelId: string, userId: string): void { + this.removeTypingEntry(this.localTypingUsersByChannel, channelId, userId); + } + + @action + stopRemoteTyping(channelId: string, userId: string): void { + this.removeTypingEntry(this.remoteTypingUsersByChannel, channelId, userId); + } + + @action + stopTypingOnMessageCreate(message: Message): void { + this.stopTyping(message.channel_id, message.author.id); + } +} + +export default new TypingIndicator(); diff --git a/fluxer_app/src/features/typing/legacy/LegacyTypingSourceParity.test.ts b/fluxer_app/src/features/typing/legacy/LegacyTypingSourceParity.test.ts new file mode 100644 index 000000000..9d8d6ee3e --- /dev/null +++ b/fluxer_app/src/features/typing/legacy/LegacyTypingSourceParity.test.ts @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createHash} from 'node:crypto'; +import {readdirSync, readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {describe, expect, it} from 'vitest'; + +const APP_SRC = fileURLToPath(new URL('../../../', import.meta.url)); + +const LEGACY_SPECIFIER_PAIRS: ReadonlyArray = [ + ['@app/features/typing/legacy/LegacyTypingCommands', '@app/features/typing/commands/TypingCommands'], + ['@app/features/typing/legacy/LegacyTypingIndicator', '@app/features/typing/state/TypingIndicator'], + ['@app/features/typing/legacy/LegacyTypingUtils', '@app/features/typing/utils/TypingUtils'], +]; + +const HEAD_DIGESTS = { + typingIndicator: 'b1e497ae31f83beff6d3c4cc42f2c103c599541c93aee9db9ba3abed9f316afb', + typingUtils: '6bec349ce19186301bbe976acb82e0e4c1544e10cc848d5490d21d1379cb21e8', + typingCommands: '12268312bfadf9d4721c72c05b660f964d3e038d424dcd3a5ee08f86ae58c5fc', + composerTypingEffectBody: '3a4d3d2625a3bc32cfade4309d18b751685833047fb1d5e7d73ca655257b065a', + localTypingStateMachine: 'cf56293c27bcb7bbb809efc28ff1070234a7a96e198c81f3c91f43169b1457a0', + localTypingStateMachineTest: '90caf6ee8ac780f4b63031056c7e4eab3aeff6dcb759d1f5ba4b76fe08e56558', +} as const; + +const HEAD_COMPOSER_TYPING_EFFECT_BODY = `\t\tif (!enabled || !typingEnabled) { +\t\t\tTypingUtils.clear(channelId); +\t\t\treturn; +\t\t} +\t\tconst content = value.trim(); +\t\tconst isInReplaceMode = ReplaceCommandUtils.isReplaceCommand(content); +\t\tconst isSlashCommand = content.startsWith('/'); +\t\tif (content && !isAutocompleteAttached && !isInReplaceMode && !isSlashCommand) { +\t\t\tTypingUtils.typing(channelId); +\t\t} else { +\t\t\tTypingUtils.clear(channelId); +\t\t}`; + +const HEAD_TYPING_USERS_STYLESHEET = [ + '/* SPDX-License-Identifier: AGPL-3.0-or-later */', + '', + '.typing {', + '\tmargin-right: 0;', + '\tdisplay: flex;', + '\talign-items: center;', + '\tjustify-content: center;', + '\tcolor: var(--text-primary-muted);', + '}', + '', + '.username {', + '\tfont-weight: 600;', + '}', + '', + '.composerStatus {', + '\tdisplay: inline-flex;', + '\talign-items: center;', + '\theight: var(--typing-pill-height, 1.125rem);', + '\tmax-width: 100%;', + '\tmin-width: 0;', + '\tcolor: var(--text-primary-muted);', + '\tpointer-events: auto;', + '}', + '', +].join('\n'); + +const TYPING_FACADE_SPECIFIERS = new Set([ + '@app/features/channel/components/TypingUsers', + '@app/features/typing/commands/TypingCommands', + '@app/features/typing/state/TypingIndicator', + '@app/features/typing/state/TypingPolicy', + '@app/features/typing/utils/TypingUtils', +]); + +const IMPORT_DECLARATION = /^import [^;]+? from '([^']+)';\n/gm; +const COMPOSER_TYPING_SIGNATURE_END = '}: LegacyComposerTypingInput): void {\n'; + +function readSource(path: string): string { + return readFileSync(join(APP_SRC, path), 'utf8'); +} + +function sha256(text: string): string { + return createHash('sha256').update(text).digest('hex'); +} + +function sortImportDeclarations(source: string): string { + const declarations = [...source.matchAll(IMPORT_DECLARATION)]; + if (declarations.length === 0) { + return source; + } + const first = declarations[0]!; + const last = declarations[declarations.length - 1]!; + const blockStart = first.index; + const blockEnd = last.index + last[0].length; + expect(source.slice(blockStart, blockEnd)).toBe(declarations.map((declaration) => declaration[0]).join('')); + const sorted = [...declarations].sort((left, right) => { + if (left[1]! === right[1]!) return 0; + return left[1]! < right[1]! ? -1 : 1; + }); + return source.slice(0, blockStart) + sorted.map((declaration) => declaration[0]).join('') + source.slice(blockEnd); +} + +function restoreHeadSource(source: string): string { + let restored = source; + for (const [relocated, head] of LEGACY_SPECIFIER_PAIRS) { + restored = restored.replaceAll(`'${relocated}'`, `'${head}'`); + } + return sortImportDeclarations(restored); +} + +function importSpecifiers(source: string): Array { + return [...source.matchAll(/ from '([^']+)'/g)].map((match) => match[1]!); +} + +function extractComposerTypingEffectBody(source: string): string { + const signatureEnd = source.indexOf(COMPOSER_TYPING_SIGNATURE_END); + expect(signatureEnd).toBeGreaterThan(-1); + const body = source.slice(signatureEnd + COMPOSER_TYPING_SIGNATURE_END.length, source.lastIndexOf('\n}\n')); + return body + .split('\n') + .map((line) => `\t${line}`) + .join('\n') + .replaceAll('LegacyTypingUtils.', 'TypingUtils.'); +} + +describe('legacy typing source parity', () => { + it('keeps the relocated typing store identical to the recorded head source', () => { + const source = readSource('features/typing/legacy/LegacyTypingIndicator.ts'); + + expect(sha256(source)).toBe(HEAD_DIGESTS.typingIndicator); + }); + + it('keeps the relocated typing utils identical to the recorded head source apart from its legacy import paths', () => { + const source = readSource('features/typing/legacy/LegacyTypingUtils.ts'); + + expect(importSpecifiers(source)).toEqual( + expect.arrayContaining([ + '@app/features/typing/legacy/LegacyTypingCommands', + '@app/features/typing/legacy/LegacyTypingIndicator', + '@app/features/typing/state/LocalTypingStateMachine', + '@app/features/auth/state/Authentication', + ]), + ); + expect(sha256(restoreHeadSource(source))).toBe(HEAD_DIGESTS.typingUtils); + }); + + it('keeps the relocated typing commands identical to the recorded head source apart from its legacy import path', () => { + const source = readSource('features/typing/legacy/LegacyTypingCommands.ts'); + + expect(importSpecifiers(source)).toContain('@app/features/typing/legacy/LegacyTypingIndicator'); + expect(sha256(restoreHeadSource(source))).toBe(HEAD_DIGESTS.typingCommands); + }); + + it('keeps the extracted composer typing effect identical to the recorded head effect body', () => { + const source = readSource('features/typing/legacy/LegacyComposerTyping.ts'); + + expect(sha256(HEAD_COMPOSER_TYPING_EFFECT_BODY)).toBe(HEAD_DIGESTS.composerTypingEffectBody); + expect(extractComposerTypingEffectBody(source)).toBe(HEAD_COMPOSER_TYPING_EFFECT_BODY); + }); + + it('keeps the local typing state machine and its test frozen in place', () => { + expect(sha256(readSource('features/typing/state/LocalTypingStateMachine.ts'))).toBe( + HEAD_DIGESTS.localTypingStateMachine, + ); + expect(sha256(readSource('features/typing/state/LocalTypingStateMachine.test.ts'))).toBe( + HEAD_DIGESTS.localTypingStateMachineTest, + ); + }); + + it('keeps the typing users stylesheet frozen in place', () => { + expect(readSource('features/channel/components/TypingUsers.module.css')).toBe(HEAD_TYPING_USERS_STYLESHEET); + }); + + it('never imports a typing facade from a legacy module', () => { + const legacyModules = [ + ...readdirSync(join(APP_SRC, 'features/typing/legacy')) + .filter((name) => !name.includes('.test.')) + .map((name) => `features/typing/legacy/${name}`), + 'features/channel/components/LegacyTypingUsers.tsx', + ]; + expect(legacyModules).toHaveLength(5); + + const facadeImports = legacyModules.flatMap((path) => + importSpecifiers(readSource(path)) + .filter((specifier) => TYPING_FACADE_SPECIFIERS.has(specifier)) + .map((specifier) => `${path} imports ${specifier}`), + ); + + expect(facadeImports).toEqual([]); + }); +}); diff --git a/fluxer_app/src/features/typing/legacy/LegacyTypingUtils.ts b/fluxer_app/src/features/typing/legacy/LegacyTypingUtils.ts new file mode 100644 index 000000000..7c02a067d --- /dev/null +++ b/fluxer_app/src/features/typing/legacy/LegacyTypingUtils.ts @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Authentication from '@app/features/auth/state/Authentication'; +import * as TypingCommands from '@app/features/typing/legacy/LegacyTypingCommands'; +import TypingIndicator from '@app/features/typing/legacy/LegacyTypingIndicator'; +import { + createLocalTypingSnapshot, + type LocalTypingMachineEvent, + type LocalTypingModel, + type LocalTypingSnapshot, + selectLocalTypingModel, + transitionLocalTypingSnapshot, +} from '@app/features/typing/state/LocalTypingStateMachine'; + +class TypingManager { + private snapshot: LocalTypingSnapshot = createLocalTypingSnapshot(); + private remoteSendTimerId: NodeJS.Timeout | null = null; + private localIdleTimerId: NodeJS.Timeout | null = null; + private scheduledRemoteSendAt: number | null = null; + private scheduledRemoteChannelId: string | null = null; + private scheduledRemotePendingVersion: number | null = null; + private scheduledLocalIdleAt: number | null = null; + private scheduledLocalIdleChannelId: string | null = null; + private remoteSendTimerGeneration = 0; + private localIdleTimerGeneration = 0; + + typing(channelId: string): void { + const currentUserId = Authentication.currentUserId; + if (!currentUserId) { + return; + } + this.transition( + { + type: 'localTyping.started', + channelId, + now: Date.now(), + }, + currentUserId, + ); + } + + clear(channelId: string): void { + const currentUserId = Authentication.currentUserId; + this.transition( + { + type: 'localTyping.stopped', + channelId, + }, + currentUserId, + ); + } + + reset(): void { + this.clearRemoteSendTimer(); + this.clearLocalIdleTimer(); + this.snapshot = createLocalTypingSnapshot(); + } + + private transition(event: LocalTypingMachineEvent, currentUserId: string | null): void { + const previousModel = selectLocalTypingModel(this.snapshot); + this.snapshot = transitionLocalTypingSnapshot(this.snapshot, event); + const nextModel = selectLocalTypingModel(this.snapshot); + if (currentUserId != null) { + this.applyLocalTypingMutation(previousModel, nextModel, currentUserId, event); + } + this.applyRemoteSendSchedule(nextModel); + this.applyLocalIdleSchedule(nextModel); + } + + private applyLocalTypingMutation( + previousModel: LocalTypingModel, + nextModel: LocalTypingModel, + userId: string, + event: LocalTypingMachineEvent, + ): void { + if (previousModel.localTyping && previousModel.channelId && previousModel.channelId !== nextModel.channelId) { + TypingCommands.stopLocalTyping(previousModel.channelId, userId); + } + if (previousModel.localTyping && !nextModel.localTyping && previousModel.channelId) { + TypingCommands.stopLocalTyping(previousModel.channelId, userId); + return; + } + if (event.type === 'localTyping.started' && nextModel.localTyping && nextModel.channelId) { + TypingCommands.startLocalTyping(nextModel.channelId, userId); + } + } + + private applyRemoteSendSchedule(nextModel: LocalTypingModel): void { + if ( + nextModel.remoteSendAt === this.scheduledRemoteSendAt && + nextModel.channelId === this.scheduledRemoteChannelId && + nextModel.remotePendingVersion === this.scheduledRemotePendingVersion + ) { + return; + } + this.clearRemoteSendTimer(); + if (nextModel.remoteSendAt == null || !nextModel.channelId) { + return; + } + const channelId = nextModel.channelId; + const pendingVersion = nextModel.remotePendingVersion; + const remoteSendAt = nextModel.remoteSendAt; + const timerGeneration = this.remoteSendTimerGeneration; + this.scheduledRemoteSendAt = remoteSendAt; + this.scheduledRemoteChannelId = channelId; + this.scheduledRemotePendingVersion = pendingVersion; + this.remoteSendTimerId = setTimeout( + () => this.sendTyping(channelId, pendingVersion, timerGeneration), + Math.max(0, remoteSendAt - Date.now()), + ); + } + + private applyLocalIdleSchedule(nextModel: LocalTypingModel): void { + if ( + nextModel.localIdleAt === this.scheduledLocalIdleAt && + nextModel.channelId === this.scheduledLocalIdleChannelId + ) { + return; + } + this.clearLocalIdleTimer(); + if (nextModel.localIdleAt == null || !nextModel.channelId) { + return; + } + const channelId = nextModel.channelId; + const localIdleAt = nextModel.localIdleAt; + const timerGeneration = this.localIdleTimerGeneration; + this.scheduledLocalIdleAt = localIdleAt; + this.scheduledLocalIdleChannelId = channelId; + this.localIdleTimerId = setTimeout( + () => this.expireLocalTyping(channelId, timerGeneration), + Math.max(0, localIdleAt - Date.now()), + ); + } + + private sendTyping(channelId: string, pendingVersion: number, timerGeneration: number): void { + if (timerGeneration !== this.remoteSendTimerGeneration) { + return; + } + this.remoteSendTimerId = null; + this.scheduledRemoteSendAt = null; + this.scheduledRemoteChannelId = null; + this.scheduledRemotePendingVersion = null; + const currentUserId = Authentication.currentUserId; + if (!currentUserId) { + this.clear(channelId); + return; + } + TypingCommands.sendTyping(channelId); + TypingCommands.startLocalTyping(channelId, currentUserId); + this.transition( + { + type: 'localTyping.remoteSent', + channelId, + now: Date.now(), + pendingVersion, + }, + currentUserId, + ); + } + + private expireLocalTyping(channelId: string, timerGeneration: number): void { + if (timerGeneration !== this.localIdleTimerGeneration) { + return; + } + this.localIdleTimerId = null; + this.scheduledLocalIdleAt = null; + this.scheduledLocalIdleChannelId = null; + const currentUserId = Authentication.currentUserId; + if (!currentUserId) { + this.clear(channelId); + return; + } + this.transition( + { + type: 'localTyping.idleElapsed', + channelId, + now: Date.now(), + }, + currentUserId, + ); + } + + private clearRemoteSendTimer(): void { + this.remoteSendTimerGeneration += 1; + if (this.remoteSendTimerId != null) { + clearTimeout(this.remoteSendTimerId); + this.remoteSendTimerId = null; + } + this.scheduledRemoteSendAt = null; + this.scheduledRemoteChannelId = null; + this.scheduledRemotePendingVersion = null; + } + + private clearLocalIdleTimer(): void { + this.localIdleTimerGeneration += 1; + if (this.localIdleTimerId != null) { + clearTimeout(this.localIdleTimerId); + this.localIdleTimerId = null; + } + this.scheduledLocalIdleAt = null; + this.scheduledLocalIdleChannelId = null; + } +} + +export const TypingUtils = new TypingManager(); +TypingIndicator.registerLocalTypingResetOwner(TypingUtils); diff --git a/fluxer_app/src/features/typing/rolling/RollingComposerTypingGate.test.ts b/fluxer_app/src/features/typing/rolling/RollingComposerTypingGate.test.ts new file mode 100644 index 000000000..bae5a4d8f --- /dev/null +++ b/fluxer_app/src/features/typing/rolling/RollingComposerTypingGate.test.ts @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {decideComposerTyping} from '@app/features/typing/rolling/RollingComposerTypingGate'; +import {describe, expect, it} from 'vitest'; + +type ComposerTypingChange = Parameters[0]; + +function decide(change: Partial): ReturnType { + return decideComposerTyping({ + previousValue: 'hell', + value: 'hello', + isRestoringDraft: false, + isEditingMessageInComposer: false, + enabled: true, + ...change, + }); +} + +describe('decideComposerTyping', () => { + it('starts typing when the value changes to non-empty text', () => { + expect(decide({})).toBe('start'); + expect(decide({previousValue: '', value: 'h'})).toBe('start'); + expect(decide({previousValue: 'hello', value: 'hell'})).toBe('start'); + }); + + it('stops typing for a whitespace-only draft', () => { + expect(decide({previousValue: '', value: ' '})).toBe('stop'); + expect(decide({previousValue: 'hi', value: ' \n\t'})).toBe('stop'); + }); + + it('stops typing when the value becomes exactly empty', () => { + expect(decide({previousValue: 'h', value: ''})).toBe('stop'); + }); + + it('does nothing when the value is unchanged', () => { + expect(decide({previousValue: 'hello', value: 'hello'})).toBe('none'); + expect(decide({previousValue: '', value: ''})).toBe('none'); + }); + + it('does nothing on the first run after mount', () => { + expect(decide({previousValue: null, value: 'saved draft'})).toBe('none'); + }); + + it('does nothing when the channel changed since the previous value', () => { + expect(decide({previousValue: null, value: ''})).toBe('none'); + expect(decide({previousValue: null, value: 'draft in the next channel'})).toBe('none'); + }); + + it('does nothing while a draft is being restored', () => { + expect(decide({isRestoringDraft: true})).toBe('none'); + expect(decide({isRestoringDraft: true, value: ''})).toBe('none'); + }); + + it('does nothing while editing a message in the composer', () => { + expect(decide({isEditingMessageInComposer: true})).toBe('none'); + expect(decide({isEditingMessageInComposer: true, value: ''})).toBe('none'); + }); + + it('does nothing while the composer is disabled', () => { + expect(decide({enabled: false})).toBe('none'); + expect(decide({enabled: false, value: ''})).toBe('none'); + }); + + it('suppresses a leading slash', () => { + expect(decide({previousValue: '', value: '/'})).toBe('none'); + expect(decide({previousValue: '/gif', value: '/gif cats'})).toBe('none'); + }); + + it('suppresses a bare plus', () => { + expect(decide({previousValue: '', value: '+'})).toBe('none'); + expect(decide({previousValue: '+', value: '+1'})).toBe('start'); + }); + + it('suppresses the plus colon emoji shorthand', () => { + expect(decide({previousValue: '+', value: '+:'})).toBe('none'); + expect(decide({previousValue: '+:thumbsup', value: '+:thumbsup:'})).toBe('none'); + expect(decide({previousValue: '+:thumbs', value: '+:thumbs-up'})).toBe('none'); + }); + + it('suppresses the reaction shorthand with leading whitespace', () => { + expect(decide({previousValue: ' +:', value: ' +:smile'})).toBe('none'); + }); + + it('suppresses a replace command', () => { + expect(decide({previousValue: 's/teh/th', value: 's/teh/the/'})).toBe('none'); + expect(decide({previousValue: ' s/teh/th', value: ' s/teh/the '})).toBe('none'); + }); + + it('starts typing while autocomplete is open', () => { + expect(decide({previousValue: '@al', value: '@ali'})).toBe('start'); + expect(decide({previousValue: 'nice :smi', value: 'nice :smil'})).toBe('start'); + expect(decide({previousValue: '#gen', value: '#gene'})).toBe('start'); + }); +}); diff --git a/fluxer_app/src/features/typing/rolling/RollingComposerTypingGate.ts b/fluxer_app/src/features/typing/rolling/RollingComposerTypingGate.ts new file mode 100644 index 000000000..cd7075c2c --- /dev/null +++ b/fluxer_app/src/features/typing/rolling/RollingComposerTypingGate.ts @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import * as ReplaceCommandUtils from '@app/features/messaging/utils/ReplaceCommandUtils'; +import {isEmojiReactionShorthand} from '@app/features/messaging/utils/SlashCommandUtils'; + +const REACTION_SHORTHAND_REGEX = /^\+(?!\w+):?(?!:)(\w+)?:?$/; + +interface ComposerTypingChange { + previousValue: string | null; + value: string; + isRestoringDraft: boolean; + isEditingMessageInComposer: boolean; + enabled: boolean; +} + +export function decideComposerTyping({ + previousValue, + value, + isRestoringDraft, + isEditingMessageInComposer, + enabled, +}: ComposerTypingChange): 'start' | 'stop' | 'none' { + if (!enabled || isEditingMessageInComposer || isRestoringDraft || previousValue === null || value === previousValue) { + return 'none'; + } + const content = value.trim(); + if (content === '') { + return 'stop'; + } + if ( + REACTION_SHORTHAND_REGEX.test(value) || + isEmojiReactionShorthand(value) || + value.startsWith('/') || + ReplaceCommandUtils.isReplaceCommand(content) + ) { + return 'none'; + } + return 'start'; +} diff --git a/fluxer_app/src/features/typing/rolling/RollingTypingSender.test.ts b/fluxer_app/src/features/typing/rolling/RollingTypingSender.test.ts new file mode 100644 index 000000000..8b80f1cb2 --- /dev/null +++ b/fluxer_app/src/features/typing/rolling/RollingTypingSender.test.ts @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Endpoints} from '@app/features/app/constants/Endpoints'; +import {runInAction} from 'mobx'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const doubles = await vi.hoisted(async () => { + const {observable} = await import('mobx'); + return { + auth: observable({currentUserId: 'me' as string | null}), + post: vi.fn((_path: string) => Promise.resolve({ok: true})), + logError: vi.fn(), + }; +}); + +vi.mock('@app/features/platform/utils/AppLogger', () => ({ + Logger: class { + debug = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + error = doubles.logError; + }, +})); +vi.mock('@app/features/platform/transport/RestTransport', () => ({http: {post: doubles.post}})); +vi.mock('@app/features/auth/state/Authentication', () => ({default: doubles.auth})); +vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: {showMyselfTyping: false}})); +vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}})); +vi.mock('@app/features/user/state/Users', () => ({default: {getUser: () => undefined}})); + +const {default: RollingTypingSender} = await import('@app/features/typing/rolling/RollingTypingSender'); +const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore'); + +const START_TIME = 1_000_000; +const CHANNEL = 'channel'; +const OTHER_CHANNEL = 'other-channel'; + +let postTimes: Array = []; + +function elapsed(): number { + return Date.now() - START_TIME; +} + +function typeContinuously(durationMs: number): void { + const startedAt = Date.now(); + while (Date.now() - startedAt < durationMs) { + RollingTypingSender.startTyping(CHANNEL); + vi.advanceTimersByTime(100); + } +} + +function advanceTo(elapsedMs: number): void { + vi.advanceTimersByTime(elapsedMs - elapsed()); +} + +function startOtherTypists(count: number): void { + for (let index = 0; index < count; index += 1) { + RollingTypingStore.start(CHANNEL, `typist-${index}`, 'gateway'); + } +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(START_TIME); + postTimes = []; + doubles.post.mockImplementation((_path: string) => { + postTimes.push(elapsed()); + return Promise.resolve({ok: true}); + }); +}); + +afterEach(() => { + RollingTypingSender.reset(); + RollingTypingStore.reset(); + runInAction(() => { + doubles.auth.currentUserId = 'me'; + }); + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('RollingTypingSender', () => { + it('posts once 1500 ms after the first keystroke and not before', () => { + RollingTypingSender.startTyping(CHANNEL); + + vi.advanceTimersByTime(1499); + expect(doubles.post).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(doubles.post).toHaveBeenCalledExactlyOnceWith(Endpoints.CHANNEL_TYPING(CHANNEL)); + + vi.advanceTimersByTime(20000); + expect(doubles.post).toHaveBeenCalledTimes(1); + }); + + it('does not push a pending send back on further keystrokes', () => { + RollingTypingSender.startTyping(CHANNEL); + advanceTo(500); + RollingTypingSender.startTyping(CHANNEL); + advanceTo(1400); + RollingTypingSender.startTyping(CHANNEL); + + advanceTo(1500); + + expect(postTimes).toEqual([1500]); + }); + + it('writes the self entry at schedule time', () => { + RollingTypingSender.startTyping(CHANNEL); + + expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(true); + expect(RollingTypingStore.isConfirmedTyping(CHANNEL, 'me')).toBe(false); + expect(doubles.post).not.toHaveBeenCalled(); + }); + + it('writes nothing on a throttled keystroke', () => { + RollingTypingSender.startTyping(CHANNEL); + RollingTypingStore.remove(CHANNEL, 'me'); + const timersBefore = vi.getTimerCount(); + + advanceTo(700); + RollingTypingSender.startTyping(CHANNEL); + + expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false); + expect(vi.getTimerCount()).toBe(timersBefore); + }); + + it('sends every 8000 ms while typing continuously', () => { + typeContinuously(20000); + vi.advanceTimersByTime(20000); + + expect(postTimes).toEqual([1500, 9500, 17500]); + }); + + it('posts the second time about 9500 ms after the first keystroke', () => { + typeContinuously(12000); + + expect(postTimes[1]).toBe(9500); + expect(postTimes[1]! - postTimes[0]!).toBe(8000); + }); + + it('posts immediately when typing resumes 16000 ms after the last schedule', () => { + RollingTypingSender.startTyping(CHANNEL); + advanceTo(16000); + + RollingTypingSender.startTyping(CHANNEL); + vi.advanceTimersByTime(0); + + expect(postTimes).toEqual([1500, 16000]); + }); + + it('skips the post when six entries including self are typing', () => { + startOtherTypists(5); + RollingTypingSender.startTyping(CHANNEL); + + vi.advanceTimersByTime(1500); + + expect(RollingTypingStore.countTypists(CHANNEL)).toBe(6); + expect(doubles.post).not.toHaveBeenCalled(); + }); + + it('posts with five entries including self typing', () => { + startOtherTypists(4); + RollingTypingSender.startTyping(CHANNEL); + + vi.advanceTimersByTime(1500); + + expect(RollingTypingStore.countTypists(CHANNEL)).toBe(5); + expect(doubles.post).toHaveBeenCalledTimes(1); + }); + + it('advances the throttle clock when the post is skipped', () => { + startOtherTypists(5); + RollingTypingSender.startTyping(CHANNEL); + advanceTo(1500); + expect(doubles.post).not.toHaveBeenCalled(); + + advanceTo(5000); + for (let index = 0; index < 5; index += 1) { + RollingTypingStore.remove(CHANNEL, `typist-${index}`); + } + RollingTypingSender.startTyping(CHANNEL); + advanceTo(7999); + expect(doubles.post).not.toHaveBeenCalled(); + + advanceTo(8000); + RollingTypingSender.startTyping(CHANNEL); + advanceTo(9500); + expect(postTimes).toEqual([9500]); + }); + + it('does not retry a failed post', async () => { + doubles.post.mockImplementationOnce(() => Promise.reject(new Error('Too many requests'))); + RollingTypingSender.startTyping(CHANNEL); + + await vi.advanceTimersByTimeAsync(1500); + expect(doubles.post).toHaveBeenCalledTimes(1); + expect(doubles.logError).toHaveBeenCalledExactlyOnceWith( + `Failed to send typing indicator to channel ${CHANNEL}:`, + expect.any(Error), + ); + + await vi.advanceTimersByTimeAsync(30000); + expect(doubles.post).toHaveBeenCalledTimes(1); + }); + + it('keeps the throttle after a failed post', async () => { + doubles.post.mockImplementationOnce(() => Promise.reject(new Error('Network down'))); + RollingTypingSender.startTyping(CHANNEL); + await vi.advanceTimersByTimeAsync(1500); + + advanceTo(3000); + RollingTypingSender.startTyping(CHANNEL); + advanceTo(7999); + expect(doubles.post).toHaveBeenCalledTimes(1); + + RollingTypingSender.startTyping(CHANNEL); + advanceTo(8000); + RollingTypingSender.startTyping(CHANNEL); + advanceTo(9500); + expect(doubles.post).toHaveBeenCalledTimes(2); + }); + + it('drops the slot and cancels a pending send on a keystroke in another channel', () => { + RollingTypingSender.startTyping(CHANNEL); + advanceTo(500); + + RollingTypingSender.startTyping(OTHER_CHANNEL); + vi.advanceTimersByTime(20000); + + expect(doubles.post).toHaveBeenCalledExactlyOnceWith(Endpoints.CHANNEL_TYPING(OTHER_CHANNEL)); + expect(postTimes).toEqual([2000]); + }); + + it('leaves the self entry in the previous channel when typing moves on', () => { + RollingTypingSender.startTyping(CHANNEL); + advanceTo(500); + + RollingTypingSender.startTyping(OTHER_CHANNEL); + + expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(true); + expect(RollingTypingStore.isTyping(OTHER_CHANNEL, 'me')).toBe(true); + advanceTo(10000); + expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false); + expect(RollingTypingStore.isTyping(OTHER_CHANNEL, 'me')).toBe(true); + }); + + it('keeps a pending send alive across a plain channel switch', () => { + RollingTypingSender.startTyping(CHANNEL); + advanceTo(400); + + RollingTypingSender.stopTyping(OTHER_CHANNEL); + advanceTo(1500); + + expect(doubles.post).toHaveBeenCalledExactlyOnceWith(Endpoints.CHANNEL_TYPING(CHANNEL)); + }); + + it('cancels only a pending send and removes the self entry when the field is emptied', () => { + RollingTypingSender.startTyping(CHANNEL); + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + advanceTo(500); + + RollingTypingSender.stopTyping(CHANNEL); + + expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false); + expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true); + expect(vi.getTimerCount()).toBe(1); + vi.advanceTimersByTime(5000); + expect(doubles.post).not.toHaveBeenCalled(); + + RollingTypingSender.startTyping(CHANNEL); + vi.advanceTimersByTime(1500); + expect(postTimes).toEqual([7000]); + }); + + it('leaves the self entry and the throttle alone when emptied after the post fired', () => { + RollingTypingSender.startTyping(CHANNEL); + advanceTo(1500); + expect(doubles.post).toHaveBeenCalledTimes(1); + + RollingTypingSender.stopTyping(CHANNEL); + expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(true); + + advanceTo(3000); + RollingTypingSender.startTyping(CHANNEL); + advanceTo(7999); + expect(doubles.post).toHaveBeenCalledTimes(1); + }); + + it('drops the slot and the self entry on own message sent', () => { + RollingTypingSender.startTyping(CHANNEL); + advanceTo(500); + + RollingTypingSender.handleOwnMessageSent(CHANNEL); + + expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false); + expect(vi.getTimerCount()).toBe(0); + vi.advanceTimersByTime(20000); + expect(doubles.post).not.toHaveBeenCalled(); + }); + + it('schedules a fresh 1500 ms send for a quick follow-up after own message sent', () => { + RollingTypingSender.startTyping(CHANNEL); + advanceTo(2000); + RollingTypingSender.handleOwnMessageSent(CHANNEL); + + advanceTo(2100); + RollingTypingSender.startTyping(CHANNEL); + advanceTo(3599); + expect(postTimes).toEqual([1500]); + + advanceTo(3600); + expect(postTimes).toEqual([1500, 3600]); + }); + + it('bails inside the timer when the signed in account changed', () => { + RollingTypingSender.startTyping(CHANNEL); + advanceTo(1000); + + runInAction(() => { + doubles.auth.currentUserId = 'someone-else'; + }); + advanceTo(5000); + + expect(doubles.post).not.toHaveBeenCalled(); + }); + + it('keeps the slot when the store is reset on reconnect', () => { + RollingTypingSender.startTyping(CHANNEL); + advanceTo(500); + + RollingTypingStore.reset(); + advanceTo(1500); + + expect(doubles.post).toHaveBeenCalledTimes(1); + advanceTo(3000); + RollingTypingSender.startTyping(CHANNEL); + advanceTo(7999); + expect(doubles.post).toHaveBeenCalledTimes(1); + }); + + it('sends no trailing post after a burst ends', () => { + typeContinuously(3000); + + vi.advanceTimersByTime(60000); + + expect(postTimes).toEqual([1500]); + }); + + it('releases the pending timer on reset', () => { + RollingTypingSender.startTyping(CHANNEL); + const timersBefore = vi.getTimerCount(); + + RollingTypingSender.reset(); + + expect(vi.getTimerCount()).toBe(timersBefore - 1); + vi.advanceTimersByTime(5000); + expect(doubles.post).not.toHaveBeenCalled(); + }); + + it('does nothing without a current user', () => { + runInAction(() => { + doubles.auth.currentUserId = null; + }); + + RollingTypingSender.startTyping(CHANNEL); + + expect(vi.getTimerCount()).toBe(0); + expect(RollingTypingStore.countTypists(CHANNEL)).toBe(0); + vi.advanceTimersByTime(5000); + expect(doubles.post).not.toHaveBeenCalled(); + }); +}); diff --git a/fluxer_app/src/features/typing/rolling/RollingTypingSender.ts b/fluxer_app/src/features/typing/rolling/RollingTypingSender.ts new file mode 100644 index 000000000..dc1b60ec2 --- /dev/null +++ b/fluxer_app/src/features/typing/rolling/RollingTypingSender.ts @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Endpoints} from '@app/features/app/constants/Endpoints'; +import Authentication from '@app/features/auth/state/Authentication'; +import {http} from '@app/features/platform/transport/RestTransport'; +import {Logger} from '@app/features/platform/utils/AppLogger'; +import RollingTypingStore from '@app/features/typing/rolling/RollingTypingStore'; +import { + planTypingSend, + TYPING_ROLLING_SKIP_ABOVE_TRACKED, + type TypingSendSlot, +} from '@app/features/typing/rolling/TypingSendThrottle'; + +const logger = new Logger('Typing'); + +class RollingTypingSender { + private slot: TypingSendSlot | null = null; + + startTyping(channelId: string): void { + const userId = Authentication.currentUserId; + if (userId == null) { + return; + } + const now = Date.now(); + const plan = planTypingSend(this.slot, channelId, userId, now); + if (plan.dropSlot) { + this.dropSlot(); + } + if (plan.action === 'throttled') { + return; + } + const slot: TypingSendSlot = {channelId, userId, timeout: null, prevSend: now}; + slot.timeout = setTimeout(() => this.fire(slot), plan.delayMs); + this.slot = slot; + RollingTypingStore.start(channelId, userId, 'local'); + } + + stopTyping(channelId: string): void { + const slot = this.slot; + if (slot === null || slot.channelId !== channelId || slot.timeout === null) { + return; + } + clearTimeout(slot.timeout); + this.slot = null; + RollingTypingStore.remove(channelId, slot.userId); + } + + handleOwnMessageSent(channelId: string): void { + this.dropSlot(); + const userId = Authentication.currentUserId; + if (userId != null) { + RollingTypingStore.remove(channelId, userId); + } + } + + reset(): void { + this.dropSlot(); + } + + private dropSlot(): void { + const slot = this.slot; + if (slot === null) { + return; + } + if (slot.timeout !== null) { + clearTimeout(slot.timeout); + } + this.slot = null; + } + + private fire(captured: TypingSendSlot): void { + const slot = this.slot; + if (slot !== captured || Authentication.currentUserId !== captured.userId || captured.timeout === null) { + return; + } + captured.timeout = null; + if (RollingTypingStore.countTypists(captured.channelId) > TYPING_ROLLING_SKIP_ABOVE_TRACKED) { + return; + } + void this.postTyping(captured.channelId); + } + + private async postTyping(channelId: string): Promise { + try { + await http.post(Endpoints.CHANNEL_TYPING(channelId)); + } catch (error) { + logger.error(`Failed to send typing indicator to channel ${channelId}:`, error); + } + } +} + +export default new RollingTypingSender(); diff --git a/fluxer_app/src/features/typing/rolling/RollingTypingStore.test.ts b/fluxer_app/src/features/typing/rolling/RollingTypingStore.test.ts new file mode 100644 index 000000000..9fb143c46 --- /dev/null +++ b/fluxer_app/src/features/typing/rolling/RollingTypingStore.test.ts @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {autorun, runInAction} from 'mobx'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const doubles = await vi.hoisted(async () => { + const {observable} = await import('mobx'); + return { + auth: observable({currentUserId: 'me' as string | null}), + developerOptions: observable({showMyselfTyping: false}), + blockedUserIds: observable.set(), + users: observable.map(), + }; +}); + +vi.mock('@app/features/auth/state/Authentication', () => ({default: doubles.auth})); +vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: doubles.developerOptions})); +vi.mock('@app/features/relationship/state/Relationships', () => ({ + default: {isBlocked: (userId: string) => doubles.blockedUserIds.has(userId)}, +})); +vi.mock('@app/features/user/state/Users', () => ({ + default: {getUser: (userId: string) => doubles.users.get(userId)}, +})); + +const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore'); + +const CHANNEL = 'channel'; + +beforeEach(() => { + vi.useFakeTimers(); + runInAction(() => { + doubles.auth.currentUserId = 'me'; + doubles.developerOptions.showMyselfTyping = false; + doubles.blockedUserIds.clear(); + doubles.users.clear(); + for (const id of ['me', 'alice', 'bob', 'carol']) { + doubles.users.set(id, {id}); + } + }); +}); + +afterEach(() => { + RollingTypingStore.reset(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe('RollingTypingStore', () => { + it('arms a 10000 ms expiry for a new typist', () => { + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + expect(vi.getTimerCount()).toBe(1); + + vi.advanceTimersByTime(9999); + expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true); + + vi.advanceTimersByTime(1); + expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it('restarts the window on a repeated typing start', () => { + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + vi.advanceTimersByTime(6000); + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + expect(vi.getTimerCount()).toBe(1); + + vi.advanceTimersByTime(9999); + expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true); + + vi.advanceTimersByTime(1); + expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(false); + }); + + it('keeps insertion order when a typist refreshes', () => { + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + RollingTypingStore.start(CHANNEL, 'bob', 'gateway'); + RollingTypingStore.start(CHANNEL, 'carol', 'gateway'); + vi.advanceTimersByTime(3000); + + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + + expect(RollingTypingStore.getTypingUserIds(CHANNEL)).toEqual(['alice', 'bob', 'carol']); + }); + + it('does not notify observers when an existing entry is refreshed', () => { + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + const runs = vi.fn(); + const dispose = autorun(() => { + runs( + RollingTypingStore.getTypingUserIds(CHANNEL), + RollingTypingStore.countTypists(CHANNEL), + RollingTypingStore.isTyping(CHANNEL, 'alice'), + RollingTypingStore.isConfirmedTyping(CHANNEL, 'alice'), + ); + }); + expect(runs).toHaveBeenCalledTimes(1); + + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + RollingTypingStore.start(CHANNEL, 'alice', 'local'); + vi.advanceTimersByTime(9000); + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + + expect(runs).toHaveBeenCalledTimes(1); + expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true); + dispose(); + }); + + it('marks an entry confirmed only from a gateway typing start', () => { + RollingTypingStore.start(CHANNEL, 'me', 'local'); + expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(true); + expect(RollingTypingStore.isConfirmedTyping(CHANNEL, 'me')).toBe(false); + + RollingTypingStore.start(CHANNEL, 'me', 'gateway'); + + expect(RollingTypingStore.isConfirmedTyping(CHANNEL, 'me')).toBe(true); + }); + + it('never lowers a confirmed entry on a local write', () => { + RollingTypingStore.start(CHANNEL, 'me', 'gateway'); + RollingTypingStore.start(CHANNEL, 'me', 'local'); + + expect(RollingTypingStore.isConfirmedTyping(CHANNEL, 'me')).toBe(true); + }); + + it('removes the author on message create for any author', () => { + RollingTypingStore.start(CHANNEL, 'me', 'local'); + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + + RollingTypingStore.remove(CHANNEL, 'alice'); + RollingTypingStore.remove(CHANNEL, 'me'); + + expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(false); + expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it('wipes every entry and clears every timer on reset', () => { + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + RollingTypingStore.start(CHANNEL, 'me', 'local'); + RollingTypingStore.start('other-channel', 'bob', 'gateway'); + expect(RollingTypingStore.getTypingUserIds(CHANNEL)).toEqual(['alice']); + expect(vi.getTimerCount()).toBe(3); + + RollingTypingStore.reset(); + + expect(vi.getTimerCount()).toBe(0); + expect(RollingTypingStore.countTypists(CHANNEL)).toBe(0); + expect(RollingTypingStore.countTypists('other-channel')).toBe(0); + expect(RollingTypingStore.getTypingUserIds(CHANNEL)).toEqual([]); + }); + + it('does not let a timer from before a reset expire a fresh entry', () => { + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + vi.advanceTimersByTime(6000); + RollingTypingStore.reset(); + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + + vi.advanceTimersByTime(4000); + expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true); + + vi.advanceTimersByTime(5999); + expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true); + + vi.advanceTimersByTime(1); + expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(false); + }); + + it('ignores an expiry callback whose handle has been replaced', () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + const staleExpiry = setTimeoutSpy.mock.calls[0]![0] as () => void; + + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + staleExpiry(); + + expect(RollingTypingStore.isTyping(CHANNEL, 'alice')).toBe(true); + expect(vi.getTimerCount()).toBe(1); + }); + + it('counts raw entries including self and blocked users', () => { + runInAction(() => { + doubles.blockedUserIds.add('bob'); + doubles.users.delete('carol'); + }); + RollingTypingStore.start(CHANNEL, 'me', 'local'); + RollingTypingStore.start(CHANNEL, 'bob', 'gateway'); + RollingTypingStore.start(CHANNEL, 'carol', 'gateway'); + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + + expect(RollingTypingStore.countTypists(CHANNEL)).toBe(4); + expect(RollingTypingStore.getTypingUserIds(CHANNEL)).toEqual(['alice']); + }); + + it('keeps no cap on tracked typists', () => { + runInAction(() => { + for (let index = 0; index < 50; index += 1) { + doubles.users.set(`typist-${index}`, {id: `typist-${index}`}); + } + }); + for (let index = 0; index < 50; index += 1) { + RollingTypingStore.start(CHANNEL, `typist-${index}`, 'gateway'); + } + + expect(RollingTypingStore.countTypists(CHANNEL)).toBe(50); + expect(RollingTypingStore.getTypingUserIds(CHANNEL)).toHaveLength(50); + }); + + it('drops the channel map when its last typist leaves', () => { + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + RollingTypingStore.start(CHANNEL, 'bob', 'gateway'); + + RollingTypingStore.remove(CHANNEL, 'alice'); + expect(RollingTypingStore['entries'].has(CHANNEL)).toBe(true); + + RollingTypingStore.remove(CHANNEL, 'bob'); + expect(RollingTypingStore['entries'].has(CHANNEL)).toBe(false); + + RollingTypingStore.start(CHANNEL, 'carol', 'gateway'); + vi.advanceTimersByTime(10000); + expect(RollingTypingStore['entries'].has(CHANNEL)).toBe(false); + }); + + it('notifies a member row reader only for its own user', () => { + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + const aliceRow = vi.fn(); + const bobRow = vi.fn(); + const disposeAliceRow = autorun(() => aliceRow(RollingTypingStore.isTyping(CHANNEL, 'alice'))); + const disposeBobRow = autorun(() => bobRow(RollingTypingStore.isTyping(CHANNEL, 'bob'))); + + RollingTypingStore.start(CHANNEL, 'carol', 'gateway'); + RollingTypingStore.start(CHANNEL, 'me', 'local'); + RollingTypingStore.start(CHANNEL, 'me', 'gateway'); + RollingTypingStore.remove(CHANNEL, 'carol'); + expect(aliceRow).toHaveBeenCalledTimes(1); + expect(bobRow).toHaveBeenCalledTimes(1); + + RollingTypingStore.start(CHANNEL, 'bob', 'gateway'); + expect(bobRow).toHaveBeenCalledTimes(2); + expect(bobRow).toHaveBeenLastCalledWith(true); + expect(aliceRow).toHaveBeenCalledTimes(1); + + RollingTypingStore.remove(CHANNEL, 'bob'); + expect(bobRow).toHaveBeenCalledTimes(3); + expect(bobRow).toHaveBeenLastCalledWith(false); + expect(aliceRow).toHaveBeenCalledTimes(1); + + disposeAliceRow(); + disposeBobRow(); + }); +}); diff --git a/fluxer_app/src/features/typing/rolling/RollingTypingStore.ts b/fluxer_app/src/features/typing/rolling/RollingTypingStore.ts new file mode 100644 index 000000000..551d86eb6 --- /dev/null +++ b/fluxer_app/src/features/typing/rolling/RollingTypingStore.ts @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Authentication from '@app/features/auth/state/Authentication'; +import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions'; +import Relationships from '@app/features/relationship/state/Relationships'; +import {TYPING_ROLLING_EXPIRY_MS} from '@app/features/typing/rolling/TypingSendThrottle'; +import Users from '@app/features/user/state/Users'; +import {action, computed, type IComputedValue, makeObservable, type ObservableMap, observable} from 'mobx'; + +type RollingTypingOrigin = 'local' | 'gateway'; + +type RollingTypingEntry = Readonly<{confirmed: boolean}>; + +const LOCAL_ENTRY: RollingTypingEntry = Object.freeze({confirmed: false}); +const CONFIRMED_ENTRY: RollingTypingEntry = Object.freeze({confirmed: true}); +const EMPTY_TYPING_USER_IDS: ReadonlyArray = Object.freeze([]); + +function areTypingUserIdsEqual(left: ReadonlyArray, right: ReadonlyArray): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + +function timerKey(channelId: string, userId: string): string { + return `${channelId}:${userId}`; +} + +class RollingTypingStore { + private readonly entries = observable.map>(undefined, { + deep: false, + }); + private readonly timers = new Map(); + private readonly typingUserIdsByChannel = new Map>>(); + + constructor() { + makeObservable(this, {start: action, remove: action, reset: action, expire: action}); + } + + start(channelId: string, userId: string, origin: RollingTypingOrigin): void { + const key = timerKey(channelId, userId); + const previousTimer = this.timers.get(key); + if (previousTimer !== undefined) { + clearTimeout(previousTimer); + } + const timer: NodeJS.Timeout = setTimeout(() => this.expire(channelId, userId, timer), TYPING_ROLLING_EXPIRY_MS); + this.timers.set(key, timer); + const confirmed = origin === 'gateway'; + const existingEntries = this.entries.get(channelId); + const existing = existingEntries?.get(userId); + if (existing !== undefined && (existing.confirmed || !confirmed)) { + return; + } + const channelEntries = existingEntries ?? observable.map(undefined, {deep: false}); + if (existingEntries === undefined) { + this.entries.set(channelId, channelEntries); + } + channelEntries.set(userId, confirmed ? CONFIRMED_ENTRY : LOCAL_ENTRY); + } + + remove(channelId: string, userId: string): void { + const key = timerKey(channelId, userId); + const timer = this.timers.get(key); + if (timer !== undefined) { + clearTimeout(timer); + this.timers.delete(key); + } + this.deleteEntry(channelId, userId); + } + + reset(): void { + for (const timer of this.timers.values()) { + clearTimeout(timer); + } + this.timers.clear(); + this.entries.clear(); + this.typingUserIdsByChannel.clear(); + } + + countTypists(channelId: string): number { + return this.entries.get(channelId)?.size ?? 0; + } + + getTypingUserIds(channelId: string): ReadonlyArray { + let typingUserIds = this.typingUserIdsByChannel.get(channelId); + if (typingUserIds === undefined) { + typingUserIds = computed(() => this.collectTypingUserIds(channelId), {equals: areTypingUserIdsEqual}); + this.typingUserIdsByChannel.set(channelId, typingUserIds); + } + return typingUserIds.get(); + } + + isTyping(channelId: string, userId: string): boolean { + return this.entries.get(channelId)?.has(userId) ?? false; + } + + isConfirmedTyping(channelId: string, userId: string): boolean { + return this.entries.get(channelId)?.get(userId)?.confirmed ?? false; + } + + private expire(channelId: string, userId: string, timer: NodeJS.Timeout): void { + const key = timerKey(channelId, userId); + if (this.timers.get(key) !== timer) { + return; + } + this.timers.delete(key); + this.deleteEntry(channelId, userId); + } + + private deleteEntry(channelId: string, userId: string): void { + const channelEntries = this.entries.get(channelId); + if (channelEntries === undefined || !channelEntries.delete(userId)) { + return; + } + if (channelEntries.size === 0) { + this.entries.delete(channelId); + } + } + + private collectTypingUserIds(channelId: string): ReadonlyArray { + const channelEntries = this.entries.get(channelId); + if (channelEntries === undefined) { + return EMPTY_TYPING_USER_IDS; + } + const currentUserId = Authentication.currentUserId; + const showSelf = DeveloperOptions.showMyselfTyping; + const typingUserIds: Array = []; + for (const userId of channelEntries.keys()) { + if (!showSelf && userId === currentUserId) continue; + if (Relationships.isBlocked(userId)) continue; + if (Users.getUser(userId) === undefined) continue; + typingUserIds.push(userId); + } + return typingUserIds.length === 0 ? EMPTY_TYPING_USER_IDS : typingUserIds; + } +} + +export default new RollingTypingStore(); diff --git a/fluxer_app/src/features/typing/rolling/RollingTypingText.test.tsx b/fluxer_app/src/features/typing/rolling/RollingTypingText.test.tsx new file mode 100644 index 000000000..e071810dc --- /dev/null +++ b/fluxer_app/src/features/typing/rolling/RollingTypingText.test.tsx @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import type {Channel} from '@app/features/channel/models/Channel'; +import type {User} from '@app/features/user/models/User'; +import type {I18n} from '@lingui/core'; +import {createElement, Fragment, type ReactNode} from 'react'; +import {renderToStaticMarkup} from 'react-dom/server'; +import {afterEach, describe, expect, it, vi} from 'vitest'; + +const doubles = vi.hoisted(() => ({ + getNickname: vi.fn((user: {id: string}, guildId: string | null) => `${user.id} in ${guildId ?? 'no guild'}`), +})); + +vi.mock('@lingui/core/macro', () => ({msg: (descriptor: {message: string}) => descriptor})); +vi.mock('@lingui/react/macro', async () => { + const {createElement: create, Fragment: ReactFragment} = await import('react'); + return {Trans: ({children}: {children?: ReactNode}) => create(ReactFragment, null, children)}; +}); +vi.mock('@app/features/user/utils/NicknameUtils', () => ({getNickname: doubles.getNickname})); +vi.mock('@app/features/member/state/GuildMembers', () => ({ + default: {getMember: () => ({getColorString: () => 'rgb(1, 2, 3)'})}, +})); + +const {getRollingTypingAnnouncement, getRollingTypingText} = await import( + '@app/features/typing/rolling/RollingTypingText' +); + +const APP_SRC = fileURLToPath(new URL('../../../', import.meta.url)); +const GUILD_CHANNEL = {id: 'channel', guildId: 'guild'} as unknown as Channel; +const DM_CHANNEL = {id: 'dm'} as unknown as Channel; + +function i18nFor(locale: string): I18n { + return { + locale, + _: (descriptor: {message: string}, values: Record = {}) => + descriptor.message.replace(/\{(\w+)\}/g, (_placeholder, name: string) => values[name] ?? ''), + } as unknown as I18n; +} + +const EN_US = i18nFor('en-US'); +const DE = i18nFor('de'); + +function typists(count: number): Array { + return Array.from({length: count}, (_, index) => ({id: `typist-${index + 1}`}) as unknown as User); +} + +function markupOf(node: ReactNode): string { + return renderToStaticMarkup(createElement(Fragment, null, node)); +} + +function textOf(node: ReactNode): string { + return markupOf(node).replace(/<[^>]+>/g, ''); +} + +function transMessages(source: string): Array { + return [...source.matchAll(/([\s\S]*?)<\/Trans>/g)].map((match) => match[1]!.replace(/\s+/g, ' ').trim()); +} + +function catalogueIds(locale: string): Set { + const catalogue = readFileSync(join(APP_SRC, `features/i18n/locales/${locale}/messages.po`), 'utf8'); + return new Set([...catalogue.matchAll(/^msgid "(.*)"$/gm)].map((match) => match[1]!)); +} + +afterEach(() => { + doubles.getNickname.mockClear(); +}); + +describe('getRollingTypingText', () => { + it('names a single typist', () => { + const node = getRollingTypingText(EN_US, typists(1), GUILD_CHANNEL, false); + + expect(textOf(node)).toBe('typist-1 in guild is typing...'); + expect(markupOf(node)).toContain('data-flx="channel.typing-users.get-typing-text.username"'); + expect(markupOf(node)).toContain('color:rgb(1, 2, 3)'); + }); + + it('names two typists', () => { + expect(textOf(getRollingTypingText(EN_US, typists(2), GUILD_CHANNEL, false))).toBe( + 'typist-1 in guild and typist-2 in guild are typing...', + ); + }); + + it('names three typists without an Oxford comma', () => { + expect(textOf(getRollingTypingText(EN_US, typists(3), GUILD_CHANNEL, false))).toBe( + 'typist-1 in guild, typist-2 in guild and typist-3 in guild are typing...', + ); + }); + + it('collapses four typists to several people', () => { + expect(getRollingTypingText(EN_US, typists(4), GUILD_CHANNEL, false)).toBe('Several people are typing...'); + expect(getRollingTypingText(DE, typists(4), GUILD_CHANNEL, false)).toBe('Several people are typing...'); + expect(doubles.getNickname).not.toHaveBeenCalled(); + }); + + it('collapses twenty typists to several people', () => { + expect(getRollingTypingText(DE, typists(20), GUILD_CHANNEL, false)).toBe('Several people are typing...'); + expect(getRollingTypingText(EN_US, typists(20), GUILD_CHANNEL, false)).toBe("Whoa, it's a typing apocalypse"); + }); + + it('shows multiple people for one to three typists when overflowing', () => { + for (const count of [1, 2, 3]) { + expect(getRollingTypingText(EN_US, typists(count), GUILD_CHANNEL, true)).toBe('Multiple people are typing...'); + } + }); + + it('never shows the overflow string for four or more typists', () => { + expect(getRollingTypingText(EN_US, typists(4), GUILD_CHANNEL, true)).toBe('Several people are typing...'); + expect(getRollingTypingText(DE, typists(12), GUILD_CHANNEL, true)).toBe('Several people are typing...'); + expect(getRollingTypingText(EN_US, typists(12), GUILD_CHANNEL, true)).toBe( + 'A symphony of clacking keys is underway...', + ); + }); +}); + +describe('getRollingTypingAnnouncement', () => { + it('announces the full count-based string for three typists', () => { + expect(getRollingTypingAnnouncement(EN_US, typists(1), GUILD_CHANNEL)).toBe('typist-1 in guild is typing...'); + expect(getRollingTypingAnnouncement(EN_US, typists(2), GUILD_CHANNEL)).toBe( + 'typist-1 in guild and typist-2 in guild are typing...', + ); + expect(getRollingTypingAnnouncement(EN_US, typists(3), GUILD_CHANNEL)).toBe( + 'typist-1 in guild, typist-2 in guild and typist-3 in guild are typing...', + ); + }); + + it('announces several people for four or more typists', () => { + expect(getRollingTypingAnnouncement(EN_US, typists(4), GUILD_CHANNEL)).toBe('Several people are typing...'); + expect(getRollingTypingAnnouncement(DE, typists(4), GUILD_CHANNEL)).toBe('Several people are typing...'); + expect(getRollingTypingAnnouncement(DE, typists(16), GUILD_CHANNEL)).toBe('Several people are typing...'); + expect(getRollingTypingAnnouncement(EN_US, typists(16), GUILD_CHANNEL)).toBe( + "It's a full-blown typing fiesta in here", + ); + }); + + it('announces nothing when nobody is typing', () => { + expect(getRollingTypingAnnouncement(EN_US, [], GUILD_CHANNEL)).toBe(''); + expect(getRollingTypingText(EN_US, [], GUILD_CHANNEL, false)).toBeNull(); + }); +}); + +describe('rolling typing catalogue', () => { + it('reuses the head catalogue message for each named form', () => { + const rollingSource = readFileSync(join(APP_SRC, 'features/typing/rolling/RollingTypingText.tsx'), 'utf8'); + const legacySource = readFileSync(join(APP_SRC, 'features/channel/components/LegacyTypingUsers.tsx'), 'utf8'); + const namedForms = ['{a} is typing...', '{a} and {b} are typing...', '{a}, {b} and {c} are typing...']; + const declaredMessages = [...rollingSource.matchAll(/message: '([^']*)'/g)].map((match) => match[1]); + + expect(transMessages(rollingSource)).toEqual(namedForms); + expect(transMessages(legacySource)).toEqual(namedForms); + expect(declaredMessages).toEqual([...namedForms, 'Multiple people are typing...']); + const sourceCatalogue = catalogueIds('en-US'); + for (const message of [...namedForms, 'Several people are typing...']) { + expect(sourceCatalogue.has(message), message).toBe(true); + } + }); + + it('resolves guild nicknames for names', () => { + const [typist] = typists(1); + + expect(textOf(getRollingTypingText(EN_US, [typist!], GUILD_CHANNEL, false))).toBe('typist-1 in guild is typing...'); + expect(getRollingTypingAnnouncement(EN_US, [typist!], DM_CHANNEL)).toBe('typist-1 in no guild is typing...'); + expect(doubles.getNickname.mock.calls).toEqual([ + [typist, 'guild'], + [typist, null], + ]); + }); +}); diff --git a/fluxer_app/src/features/typing/rolling/RollingTypingText.tsx b/fluxer_app/src/features/typing/rolling/RollingTypingText.tsx new file mode 100644 index 000000000..db38b18e3 --- /dev/null +++ b/fluxer_app/src/features/typing/rolling/RollingTypingText.tsx @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import styles from '@app/features/channel/components/TypingUsers.module.css'; +import type {Channel} from '@app/features/channel/models/Channel'; +import GuildMembers from '@app/features/member/state/GuildMembers'; +import {TYPING_ROLLING_MAX_NAMES} from '@app/features/typing/rolling/TypingSendThrottle'; +import {getTypingTierText} from '@app/features/typing/utils/TypingTierText'; +import type {User} from '@app/features/user/models/User'; +import * as NicknameUtils from '@app/features/user/utils/NicknameUtils'; +import type {I18n} from '@lingui/core'; +import {msg} from '@lingui/core/macro'; +import {Trans} from '@lingui/react/macro'; +import type {ReactNode} from 'react'; + +const ONE_TYPIST_DESCRIPTOR = msg({ + message: '{a} is typing...', + comment: 'Screen reader announcement in the composer when one person is typing. {a} is their display name.', +}); +const TWO_TYPISTS_DESCRIPTOR = msg({ + message: '{a} and {b} are typing...', + comment: + 'Screen reader announcement in the composer when two people are typing. {a} and {b} are their display names.', +}); +const THREE_TYPISTS_DESCRIPTOR = msg({ + message: '{a}, {b} and {c} are typing...', + comment: + 'Screen reader announcement in the composer when three people are typing. {a}, {b} and {c} are their display names.', +}); +const MULTIPLE_PEOPLE_ARE_TYPING_DESCRIPTOR = msg({ + message: 'Multiple people are typing...', + comment: 'Label in the composer typing row when the names of the people typing do not fit.', +}); + +function getTypistName(user: User, channel: Channel): string { + return NicknameUtils.getNickname(user, channel.guildId ?? null); +} + +function getTypingNames(typingUsers: ReadonlyArray, channel: Channel): ReactNode { + const [a, b, c] = typingUsers.slice(0, TYPING_ROLLING_MAX_NAMES).map((user) => ( + + {getTypistName(user, channel)} + + )); + if (typingUsers.length === 1) { + return {a} is typing...; + } + if (typingUsers.length === 2) { + return ( + + {a} and {b} are typing... + + ); + } + return ( + + {a}, {b} and {c} are typing... + + ); +} + +export function getRollingTypingText( + i18n: I18n, + typingUsers: ReadonlyArray, + channel: Channel, + overflowing: boolean, +): ReactNode { + if (typingUsers.length === 0) { + return null; + } + if (typingUsers.length > TYPING_ROLLING_MAX_NAMES) { + return getTypingTierText(i18n, typingUsers.length); + } + if (overflowing) { + return i18n._(MULTIPLE_PEOPLE_ARE_TYPING_DESCRIPTOR); + } + return getTypingNames(typingUsers, channel); +} + +export function getRollingTypingAnnouncement(i18n: I18n, typingUsers: ReadonlyArray, channel: Channel): string { + if (typingUsers.length === 0) { + return ''; + } + if (typingUsers.length > TYPING_ROLLING_MAX_NAMES) { + return getTypingTierText(i18n, typingUsers.length); + } + const [a, b, c] = typingUsers.map((user) => getTypistName(user, channel)); + if (typingUsers.length === 1) { + return i18n._(ONE_TYPIST_DESCRIPTOR, {a}); + } + if (typingUsers.length === 2) { + return i18n._(TWO_TYPISTS_DESCRIPTOR, {a, b}); + } + return i18n._(THREE_TYPISTS_DESCRIPTOR, {a, b, c}); +} diff --git a/fluxer_app/src/features/typing/rolling/TypingSendThrottle.test.ts b/fluxer_app/src/features/typing/rolling/TypingSendThrottle.test.ts new file mode 100644 index 000000000..92eac90ba --- /dev/null +++ b/fluxer_app/src/features/typing/rolling/TypingSendThrottle.test.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {planTypingSend, type TypingSendSlot} from '@app/features/typing/rolling/TypingSendThrottle'; +import {describe, expect, it} from 'vitest'; + +const PREVIOUS_SEND = 100000; +const PENDING_TIMEOUT = {} as NodeJS.Timeout; + +function slot(overrides: Partial = {}): TypingSendSlot { + return {channelId: 'channel', userId: 'me', timeout: null, prevSend: PREVIOUS_SEND, ...overrides}; +} + +describe('planTypingSend', () => { + it('schedules the first ever send after 1500 ms', () => { + expect(planTypingSend(null, 'channel', 'me', PREVIOUS_SEND)).toEqual({ + dropSlot: false, + action: 'schedule', + delayMs: 1500, + }); + }); + + it('throttles while a send is pending', () => { + expect(planTypingSend(slot({timeout: PENDING_TIMEOUT, prevSend: 0}), 'channel', 'me', PREVIOUS_SEND)).toEqual({ + dropSlot: false, + action: 'throttled', + }); + }); + + it('throttles when the previous schedule is less than 8000 ms old', () => { + expect(planTypingSend(slot(), 'channel', 'me', PREVIOUS_SEND + 7999)).toEqual({ + dropSlot: false, + action: 'throttled', + }); + }); + + it('schedules exactly 8000 ms after the previous schedule', () => { + expect(planTypingSend(slot(), 'channel', 'me', PREVIOUS_SEND + 8000)).toEqual({ + dropSlot: false, + action: 'schedule', + delayMs: 1500, + }); + }); + + it('schedules after 1500 ms when the previous schedule is less than 16000 ms old', () => { + expect(planTypingSend(slot(), 'channel', 'me', PREVIOUS_SEND + 15999)).toEqual({ + dropSlot: false, + action: 'schedule', + delayMs: 1500, + }); + }); + + it('schedules after 0 ms when the previous schedule is exactly 16000 ms old', () => { + expect(planTypingSend(slot(), 'channel', 'me', PREVIOUS_SEND + 16000)).toEqual({ + dropSlot: false, + action: 'schedule', + delayMs: 0, + }); + }); + + it('drops a slot for another channel and then schedules after 1500 ms', () => { + expect( + planTypingSend(slot({channelId: 'other', timeout: PENDING_TIMEOUT}), 'channel', 'me', PREVIOUS_SEND + 1), + ).toEqual({dropSlot: true, action: 'schedule', delayMs: 1500}); + }); + + it('drops a slot for another account before checking the throttle', () => { + expect( + planTypingSend(slot({userId: 'previous', timeout: PENDING_TIMEOUT}), 'channel', 'me', PREVIOUS_SEND + 1), + ).toEqual({dropSlot: true, action: 'schedule', delayMs: 1500}); + }); +}); diff --git a/fluxer_app/src/features/typing/rolling/TypingSendThrottle.ts b/fluxer_app/src/features/typing/rolling/TypingSendThrottle.ts new file mode 100644 index 000000000..2e9da94ee --- /dev/null +++ b/fluxer_app/src/features/typing/rolling/TypingSendThrottle.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const TYPING_ROLLING_EXPIRY_MS = 10000; +export const TYPING_ROLLING_SEND_DELAY_MS = 1500; +export const TYPING_ROLLING_THROTTLE_MS = 8000; +export const TYPING_ROLLING_IMMEDIATE_SEND_IDLE_MS = 16000; +export const TYPING_ROLLING_SKIP_ABOVE_TRACKED = 5; +export const TYPING_ROLLING_MAX_NAMES = 3; +export const TYPING_ROLLING_OVERFLOW_SLACK_PX = 48; + +export type TypingSendSlot = { + channelId: string; + userId: string; + timeout: NodeJS.Timeout | null; + prevSend: number; +}; + +type TypingSendPlan = {dropSlot: boolean} & ({action: 'throttled'} | {action: 'schedule'; delayMs: number}); + +export function planTypingSend( + slot: TypingSendSlot | null, + channelId: string, + userId: string, + now: number, +): TypingSendPlan { + if (slot === null) { + return {dropSlot: false, action: 'schedule', delayMs: TYPING_ROLLING_SEND_DELAY_MS}; + } + if (slot.channelId !== channelId || slot.userId !== userId) { + return {dropSlot: true, action: 'schedule', delayMs: TYPING_ROLLING_SEND_DELAY_MS}; + } + if (slot.timeout !== null || slot.prevSend + TYPING_ROLLING_THROTTLE_MS > now) { + return {dropSlot: false, action: 'throttled'}; + } + const idleLongEnough = slot.prevSend <= now - TYPING_ROLLING_IMMEDIATE_SEND_IDLE_MS; + return {dropSlot: false, action: 'schedule', delayMs: idleLongEnough ? 0 : TYPING_ROLLING_SEND_DELAY_MS}; +} diff --git a/fluxer_app/src/features/typing/state/TypingIndicator.ts b/fluxer_app/src/features/typing/state/TypingIndicator.ts index 9e7231f75..4d6e9a8fc 100644 --- a/fluxer_app/src/features/typing/state/TypingIndicator.ts +++ b/fluxer_app/src/features/typing/state/TypingIndicator.ts @@ -1,298 +1,50 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import LegacyTypingIndicator from '@app/features/typing/legacy/LegacyTypingIndicator'; +import RollingTypingStore from '@app/features/typing/rolling/RollingTypingStore'; +import TypingPolicy from '@app/features/typing/state/TypingPolicy'; import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; -import {action, makeAutoObservable, observable} from 'mobx'; - -type TypingEntry = Readonly<{ - expiresAt: number; -}>; - -type TypingEntriesByChannel = Record>; -type TypingUsersCacheEntry = Readonly<{ - users: ReadonlyArray; - expiresAt: number | null; - version: number; -}>; - -export interface LocalTypingResetOwner { - reset(): void; -} - -export const TYPING_INDICATOR_TIMEOUT_MS = 10000; -const EMPTY_TYPING_USERS: ReadonlyArray = Object.freeze([]); class TypingIndicator { - remoteTypingUsersByChannel: TypingEntriesByChannel = {}; - localTypingUsersByChannel: TypingEntriesByChannel = {}; - private typingUsersCacheByChannel: Record = {}; - private typingUsersVersionByChannel = new Map(); - private expiryTimerId: NodeJS.Timeout | null = null; - private localTypingResetOwner: LocalTypingResetOwner | null = null; - - constructor() { - makeAutoObservable< - this, - 'typingUsersCacheByChannel' | 'typingUsersVersionByChannel' | 'expiryTimerId' | 'localTypingResetOwner' - >( - this, - { - expiryTimerId: false, - localTypingResetOwner: false, - typingUsersCacheByChannel: false, - typingUsersVersionByChannel: observable.shallow, - }, - {autoBind: true}, - ); - } - - getTypingUsers(channelId: string): ReadonlyArray { - const now = Date.now(); - const storedVersion = this.typingUsersVersionByChannel.get(channelId); - const version = storedVersion == null ? 0 : storedVersion; - const cached = this.typingUsersCacheByChannel[channelId]; - if (cached && cached.version === version && (cached.expiresAt === null || cached.expiresAt > now)) { - return cached.users; + startRemoteTyping(channelId: string, userId: string): void { + if (TypingPolicy.active === 'rolling') { + RollingTypingStore.start(channelId, userId, 'gateway'); + return; } - const {expiresAt, users} = this.collectTypingUsers(channelId, now); - const typedUsers = users.length === 0 ? EMPTY_TYPING_USERS : Object.freeze(users); - this.typingUsersCacheByChannel[channelId] = { - expiresAt, - users: typedUsers, - version, - }; - return typedUsers; + LegacyTypingIndicator.startRemoteTyping(channelId, userId); } - getCount(channelId: string): number { - return this.getTypingUsers(channelId).length; + stopTypingOnMessageCreate(message: Message): void { + if (TypingPolicy.active === 'rolling') { + RollingTypingStore.remove(message.channel_id, message.author.id); + return; + } + LegacyTypingIndicator.stopTypingOnMessageCreate(message); + } + + reset(): void { + if (TypingPolicy.active === 'rolling') { + RollingTypingStore.reset(); + return; + } + LegacyTypingIndicator.reset(); } isTyping(channelId: string, userId: string): boolean { - return this.isRemoteTyping(channelId, userId) || this.isLocalTyping(channelId, userId); + if (TypingPolicy.active === 'rolling') { + return RollingTypingStore.isTyping(channelId, userId); + } + return LegacyTypingIndicator.isTyping(channelId, userId); } isMemberListTyping(channelId: string, userId: string, currentUserId: string | null | undefined): boolean { + if (TypingPolicy.active !== 'rolling') { + return LegacyTypingIndicator.isMemberListTyping(channelId, userId, currentUserId); + } if (currentUserId && userId === currentUserId) { - return this.isLocalTyping(channelId, userId); + return RollingTypingStore.isConfirmedTyping(channelId, userId); } - return this.isTyping(channelId, userId); - } - - isLocalTyping(channelId: string, userId: string): boolean { - return this.hasActiveTypingEntry(this.localTypingUsersByChannel, channelId, userId, Date.now()); - } - - isRemoteTyping(channelId: string, userId: string): boolean { - return this.hasActiveTypingEntry(this.remoteTypingUsersByChannel, channelId, userId, Date.now()); - } - - private collectTypingUsers(channelId: string, now: number): {users: Array; expiresAt: number | null} { - const users: Array = []; - const seen = new Set(); - let expiresAt: number | null = null; - const collect = (entries: Record | undefined) => { - if (!entries) return; - for (const [userId, entry] of Object.entries(entries)) { - if (entry.expiresAt <= now) { - continue; - } - if (!seen.has(userId)) { - seen.add(userId); - users.push(userId); - } - expiresAt = expiresAt === null ? entry.expiresAt : Math.min(expiresAt, entry.expiresAt); - } - }; - collect(this.remoteTypingUsersByChannel[channelId]); - collect(this.localTypingUsersByChannel[channelId]); - return {expiresAt, users}; - } - - private hasActiveTypingEntry( - entriesByChannel: TypingEntriesByChannel, - channelId: string, - userId: string, - now: number, - ): boolean { - const entry = entriesByChannel[channelId]?.[userId]; - return entry !== undefined && entry.expiresAt > now; - } - - private upsertTypingEntry(entriesByChannel: TypingEntriesByChannel, channelId: string, userId: string): void { - const now = Date.now(); - if (!entriesByChannel[channelId]) { - entriesByChannel[channelId] = {}; - } - const channelUsers = entriesByChannel[channelId]; - const existingEntry = channelUsers[userId]; - const wasVisible = existingEntry !== undefined && existingEntry.expiresAt > now; - channelUsers[userId] = { - expiresAt: now + TYPING_INDICATOR_TIMEOUT_MS, - }; - if (!wasVisible) { - this.invalidateTypingUsers(channelId, now); - } - this.scheduleNextExpiryTimer(now); - } - - private removeTypingEntry(entriesByChannel: TypingEntriesByChannel, channelId: string, userId: string): void { - const channelUsers = entriesByChannel[channelId]; - if (!channelUsers?.[userId]) { - return; - } - delete channelUsers[userId]; - if (Object.keys(channelUsers).length === 0) { - delete entriesByChannel[channelId]; - } - const now = Date.now(); - this.invalidateTypingUsers(channelId, now); - this.scheduleNextExpiryTimer(now); - } - - private removeExpiredTypingEntries(now: number): void { - this.removeExpiredTypingEntriesFrom(this.remoteTypingUsersByChannel, now); - this.removeExpiredTypingEntriesFrom(this.localTypingUsersByChannel, now); - } - - private removeExpiredTypingEntriesFrom(entriesByChannel: TypingEntriesByChannel, now: number): void { - for (const [channelId, channelUsers] of Object.entries(entriesByChannel)) { - let removed = false; - for (const [userId, entry] of Object.entries(channelUsers)) { - if (entry.expiresAt > now) { - continue; - } - delete channelUsers[userId]; - removed = true; - } - if (Object.keys(channelUsers).length === 0) { - delete entriesByChannel[channelId]; - } - if (removed) { - this.invalidateTypingUsers(channelId, now); - } - } - } - - private getNextExpiresAt(now: number): number | null { - let nextExpiresAt: number | null = null; - const collect = (entriesByChannel: TypingEntriesByChannel) => { - for (const channelUsers of Object.values(entriesByChannel)) { - for (const entry of Object.values(channelUsers)) { - if (entry.expiresAt <= now) { - continue; - } - nextExpiresAt = nextExpiresAt === null ? entry.expiresAt : Math.min(nextExpiresAt, entry.expiresAt); - } - } - }; - collect(this.remoteTypingUsersByChannel); - collect(this.localTypingUsersByChannel); - return nextExpiresAt; - } - - private scheduleNextExpiryTimer(now: number): void { - this.clearExpiryTimer(); - const nextExpiresAt = this.getNextExpiresAt(now); - if (nextExpiresAt === null) { - return; - } - this.expiryTimerId = setTimeout( - () => { - this.expiryTimerId = null; - this.pruneExpiredTypingUsers(); - }, - Math.max(0, nextExpiresAt - now), - ); - } - - private clearExpiryTimer(): void { - if (!this.expiryTimerId) { - return; - } - clearTimeout(this.expiryTimerId); - this.expiryTimerId = null; - } - - private hasActiveTypingUsers(channelId: string, now: number): boolean { - const hasActiveEntry = (entries: Record | undefined) => - entries !== undefined && Object.values(entries).some((entry) => entry.expiresAt > now); - return ( - hasActiveEntry(this.remoteTypingUsersByChannel[channelId]) || - hasActiveEntry(this.localTypingUsersByChannel[channelId]) - ); - } - - private invalidateTypingUsers(channelId: string, now: number): void { - delete this.typingUsersCacheByChannel[channelId]; - if (!this.hasActiveTypingUsers(channelId, now)) { - this.typingUsersVersionByChannel.delete(channelId); - return; - } - const currentVersion = this.typingUsersVersionByChannel.get(channelId); - const nextVersion = currentVersion == null ? 1 : currentVersion + 1; - this.typingUsersVersionByChannel.set(channelId, nextVersion); - } - - @action - pruneExpiredTypingUsers(now = Date.now()): void { - this.removeExpiredTypingEntries(now); - this.scheduleNextExpiryTimer(now); - } - - @action - reset(): void { - const resetOwner = this.localTypingResetOwner; - if (resetOwner != null) { - resetOwner.reset(); - } - this.clearExpiryTimer(); - this.remoteTypingUsersByChannel = {}; - this.localTypingUsersByChannel = {}; - this.typingUsersCacheByChannel = {}; - this.typingUsersVersionByChannel.clear(); - } - - registerLocalTypingResetOwner(owner: LocalTypingResetOwner): void { - if (this.localTypingResetOwner != null) { - throw new Error('Local typing reset owner is already registered'); - } - this.localTypingResetOwner = owner; - } - - @action - startTyping(channelId: string, userId: string): void { - this.startRemoteTyping(channelId, userId); - } - - @action - startRemoteTyping(channelId: string, userId: string): void { - this.upsertTypingEntry(this.remoteTypingUsersByChannel, channelId, userId); - } - - @action - stopTyping(channelId: string, userId: string): void { - this.stopLocalTyping(channelId, userId); - this.stopRemoteTyping(channelId, userId); - } - - @action - startLocalTyping(channelId: string, userId: string): void { - this.upsertTypingEntry(this.localTypingUsersByChannel, channelId, userId); - } - - @action - stopLocalTyping(channelId: string, userId: string): void { - this.removeTypingEntry(this.localTypingUsersByChannel, channelId, userId); - } - - @action - stopRemoteTyping(channelId: string, userId: string): void { - this.removeTypingEntry(this.remoteTypingUsersByChannel, channelId, userId); - } - - @action - stopTypingOnMessageCreate(message: Message): void { - this.stopTyping(message.channel_id, message.author.id); + return RollingTypingStore.isTyping(channelId, userId); } } diff --git a/fluxer_app/src/features/typing/state/TypingIndicatorFacade.test.ts b/fluxer_app/src/features/typing/state/TypingIndicatorFacade.test.ts new file mode 100644 index 000000000..2b87451d2 --- /dev/null +++ b/fluxer_app/src/features/typing/state/TypingIndicatorFacade.test.ts @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {configure} from 'mobx'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +configure({safeDescriptors: false}); + +const doubles = vi.hoisted(() => ({ + post: vi.fn((_path: string) => Promise.resolve({ok: true})), +})); + +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: doubles.post}})); +vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: 'me'}})); +vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: {showMyselfTyping: false}})); +vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}})); +vi.mock('@app/features/user/state/Users', () => ({default: {getUser: () => undefined}})); + +const {default: TypingIndicator} = await import('@app/features/typing/state/TypingIndicator'); +const {default: TypingPolicy} = await import('@app/features/typing/state/TypingPolicy'); +const {default: LegacyTypingIndicator} = await import('@app/features/typing/legacy/LegacyTypingIndicator'); +const {default: RollingTypingSender} = await import('@app/features/typing/rolling/RollingTypingSender'); +const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore'); + +const CHANNEL = 'channel'; + +function messageFrom(authorId: string): Message { + return {channel_id: CHANNEL, author: {id: authorId}} as unknown as Message; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + TypingPolicy.applyPolicy('legacy'); + LegacyTypingIndicator.reset(); + RollingTypingSender.reset(); + RollingTypingStore.reset(); + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('TypingIndicator facade', () => { + it('forwards every exposed store method to the legacy singleton with identical arguments under control', () => { + const startRemoteTyping = vi.spyOn(LegacyTypingIndicator, 'startRemoteTyping'); + const stopTypingOnMessageCreate = vi.spyOn(LegacyTypingIndicator, 'stopTypingOnMessageCreate'); + const reset = vi.spyOn(LegacyTypingIndicator, 'reset'); + const isTyping = vi.spyOn(LegacyTypingIndicator, 'isTyping'); + const isMemberListTyping = vi.spyOn(LegacyTypingIndicator, 'isMemberListTyping').mockReturnValue(false); + const message = messageFrom('alice'); + + TypingIndicator.startRemoteTyping(CHANNEL, 'alice'); + TypingIndicator.stopTypingOnMessageCreate(message); + TypingIndicator.isTyping(CHANNEL, 'bob'); + TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me'); + TypingIndicator.isMemberListTyping(CHANNEL, 'bob', null); + TypingIndicator.reset(); + + expect(startRemoteTyping).toHaveBeenCalledExactlyOnceWith(CHANNEL, 'alice'); + expect(stopTypingOnMessageCreate).toHaveBeenCalledExactlyOnceWith(message); + expect(isTyping).toHaveBeenCalledExactlyOnceWith(CHANNEL, 'bob'); + expect(isMemberListTyping.mock.calls).toEqual([ + [CHANNEL, 'me', 'me'], + [CHANNEL, 'bob', null], + ]); + expect(reset).toHaveBeenCalledExactlyOnceWith(); + }); + + it('returns the legacy results for typing and member list readers under control', () => { + vi.spyOn(LegacyTypingIndicator, 'isTyping').mockReturnValueOnce(true).mockReturnValueOnce(false); + vi.spyOn(LegacyTypingIndicator, 'isMemberListTyping').mockReturnValueOnce(false).mockReturnValueOnce(true); + + expect(TypingIndicator.isTyping(CHANNEL, 'alice')).toBe(true); + expect(TypingIndicator.isTyping(CHANNEL, 'alice')).toBe(false); + expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(false); + expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(true); + }); + + it('arms no rolling timer under control', () => { + TypingIndicator.startRemoteTyping(CHANNEL, 'alice'); + + expect(LegacyTypingIndicator.isTyping(CHANNEL, 'alice')).toBe(true); + expect(RollingTypingStore.countTypists(CHANNEL)).toBe(0); + expect(vi.getTimerCount()).toBe(1); + + TypingIndicator.reset(); + + expect(vi.getTimerCount()).toBe(0); + }); + + it('reports the member list self row from local typing under control', () => { + TypingIndicator.startRemoteTyping(CHANNEL, 'me'); + expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(false); + expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'alice')).toBe(true); + + LegacyTypingIndicator.startLocalTyping(CHANNEL, 'me'); + + expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(true); + }); + + it('routes gateway typing starts to the rolling store under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + const legacyStartRemoteTyping = vi.spyOn(LegacyTypingIndicator, 'startRemoteTyping'); + + TypingIndicator.startRemoteTyping(CHANNEL, 'alice'); + + expect(legacyStartRemoteTyping).not.toHaveBeenCalled(); + expect(RollingTypingStore.isConfirmedTyping(CHANNEL, 'alice')).toBe(true); + expect(TypingIndicator.isTyping(CHANNEL, 'alice')).toBe(true); + expect(TypingIndicator.isMemberListTyping(CHANNEL, 'alice', 'me')).toBe(true); + expect(LegacyTypingIndicator.isTyping(CHANNEL, 'alice')).toBe(false); + }); + + it('reports self in the member list only after the server echo under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + + RollingTypingSender.startTyping(CHANNEL); + expect(TypingIndicator.isTyping(CHANNEL, 'me')).toBe(true); + expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(false); + + vi.advanceTimersByTime(1500); + expect(doubles.post).toHaveBeenCalledTimes(1); + expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(false); + + TypingIndicator.startRemoteTyping(CHANNEL, 'me'); + + expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(true); + }); + + it('keeps the self row dark when the post was skipped under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + for (const userId of ['a', 'b', 'c', 'd', 'e']) { + TypingIndicator.startRemoteTyping(CHANNEL, userId); + } + + RollingTypingSender.startTyping(CHANNEL); + vi.advanceTimersByTime(1500); + + expect(doubles.post).not.toHaveBeenCalled(); + expect(TypingIndicator.isMemberListTyping(CHANNEL, 'me', 'me')).toBe(false); + expect(TypingIndicator.isMemberListTyping(CHANNEL, 'a', 'me')).toBe(true); + }); + + it('keeps the rolling send slot when the gateway resets under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + TypingIndicator.startRemoteTyping(CHANNEL, 'alice'); + RollingTypingSender.startTyping(CHANNEL); + vi.advanceTimersByTime(500); + + TypingIndicator.reset(); + + expect(TypingIndicator.isTyping(CHANNEL, 'alice')).toBe(false); + expect(TypingIndicator.isTyping(CHANNEL, 'me')).toBe(false); + vi.advanceTimersByTime(1000); + expect(doubles.post).toHaveBeenCalledTimes(1); + }); + + it('clears any author on message create under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + TypingIndicator.startRemoteTyping(CHANNEL, 'alice'); + RollingTypingSender.startTyping(CHANNEL); + + TypingIndicator.stopTypingOnMessageCreate(messageFrom('alice')); + TypingIndicator.stopTypingOnMessageCreate(messageFrom('me')); + + expect(TypingIndicator.isTyping(CHANNEL, 'alice')).toBe(false); + expect(TypingIndicator.isTyping(CHANNEL, 'me')).toBe(false); + vi.advanceTimersByTime(1500); + expect(doubles.post).toHaveBeenCalledTimes(1); + }); +}); diff --git a/fluxer_app/src/features/typing/state/TypingIndicatorReworkRollout.test.ts b/fluxer_app/src/features/typing/state/TypingIndicatorReworkRollout.test.ts new file mode 100644 index 000000000..439674cb5 --- /dev/null +++ b/fluxer_app/src/features/typing/state/TypingIndicatorReworkRollout.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + type ExperimentAssignmentsResponse, + INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE, + readTypingIndicatorReworkAssignment, +} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas'; +import { + INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT, + type TypingIndicatorReworkAssignmentResponse, +} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas'; +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 {TypingIndicatorReworkRollout} = await import('@app/features/typing/state/TypingIndicatorReworkRollout'); + +const TARGETED_ASSIGNMENT: TypingIndicatorReworkAssignmentResponse = { + enabled: true, + config_version: 4, + user_targeted: true, + source: 'canary', +}; + +function responseWith(assignment: TypingIndicatorReworkAssignmentResponse | undefined): ExperimentAssignmentsResponse { + return { + poll_interval_seconds: 300, + poll_jitter_percent: 15, + assignments: assignment === undefined ? {} : {typing_indicator_rework: assignment}, + }; +} + +function publish(assignment: TypingIndicatorReworkAssignmentResponse | undefined): void { + runInAction(() => { + ExperimentAssignments.response = responseWith(assignment); + }); +} + +afterEach(() => { + runInAction(() => { + ExperimentAssignments.response = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE; + }); +}); + +describe('TypingIndicatorReworkRollout', () => { + it('reads the inert assignment before any response has landed', () => { + expect(TypingIndicatorReworkRollout.assignment).toEqual(INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT); + expect(TypingIndicatorReworkRollout.usesRollingTyping).toBe(false); + }); + + it('treats a missing experiment key as off', () => { + publish(undefined); + + expect(readTypingIndicatorReworkAssignment(responseWith(undefined))).toBe(INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT); + expect(TypingIndicatorReworkRollout.assignment).toEqual(INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT); + expect(TypingIndicatorReworkRollout.usesRollingTyping).toBe(false); + }); + + it('uses rolling typing for a targeted account', () => { + publish(TARGETED_ASSIGNMENT); + + expect(TypingIndicatorReworkRollout.assignment).toEqual(TARGETED_ASSIGNMENT); + expect(TypingIndicatorReworkRollout.usesRollingTyping).toBe(true); + }); + + it('keeps legacy typing for an account outside the rollout', () => { + publish({...TARGETED_ASSIGNMENT, user_targeted: false, source: null}); + + expect(TypingIndicatorReworkRollout.usesRollingTyping).toBe(false); + }); + + it('keeps legacy typing while the rollout is disabled instance-wide', () => { + publish({...TARGETED_ASSIGNMENT, enabled: false}); + + expect(TypingIndicatorReworkRollout.usesRollingTyping).toBe(false); + }); + + it('keeps legacy typing for an excluded account', () => { + publish({enabled: true, config_version: 4, user_targeted: false, source: null}); + + expect(TypingIndicatorReworkRollout.assignment.enabled).toBe(true); + expect(TypingIndicatorReworkRollout.usesRollingTyping).toBe(false); + }); + + it('follows a later poll that turns the rollout off', () => { + publish(TARGETED_ASSIGNMENT); + expect(TypingIndicatorReworkRollout.usesRollingTyping).toBe(true); + + publish({...TARGETED_ASSIGNMENT, enabled: false, user_targeted: false, source: null}); + + expect(TypingIndicatorReworkRollout.usesRollingTyping).toBe(false); + }); +}); diff --git a/fluxer_app/src/features/typing/state/TypingIndicatorReworkRollout.ts b/fluxer_app/src/features/typing/state/TypingIndicatorReworkRollout.ts new file mode 100644 index 000000000..357a379f5 --- /dev/null +++ b/fluxer_app/src/features/typing/state/TypingIndicatorReworkRollout.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments'; +import {readTypingIndicatorReworkAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas'; +import { + isTypingIndicatorReworkTargeted, + type TypingIndicatorReworkAssignmentResponse, +} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas'; + +class TypingIndicatorReworkRolloutSelector { + get assignment(): TypingIndicatorReworkAssignmentResponse { + return readTypingIndicatorReworkAssignment(ExperimentAssignments.response); + } + + get usesRollingTyping(): boolean { + return isTypingIndicatorReworkTargeted(this.assignment); + } +} + +export const TypingIndicatorReworkRollout = new TypingIndicatorReworkRolloutSelector(); + +export default TypingIndicatorReworkRollout; diff --git a/fluxer_app/src/features/typing/state/TypingPolicy.test.ts b/fluxer_app/src/features/typing/state/TypingPolicy.test.ts new file mode 100644 index 000000000..fb9f77fd3 --- /dev/null +++ b/fluxer_app/src/features/typing/state/TypingPolicy.test.ts @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas'; +import type {TypingIndicatorReworkAssignmentResponse} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas'; +import {runInAction} from 'mobx'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const doubles = vi.hoisted(() => ({ + post: vi.fn((_path: string) => Promise.resolve({ok: true})), +})); + +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: doubles.post}})); +vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: 'me'}})); +vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: {showMyselfTyping: false}})); +vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}})); +vi.mock('@app/features/user/state/Users', () => ({default: {getUser: () => undefined}})); + +const {ExperimentAssignments} = await import('@app/features/experiment/state/ExperimentAssignments'); +const {default: TypingPolicy} = await import('@app/features/typing/state/TypingPolicy'); +const {default: LegacyTypingIndicator} = await import('@app/features/typing/legacy/LegacyTypingIndicator'); +const {TypingUtils: LegacyTypingUtils} = await import('@app/features/typing/legacy/LegacyTypingUtils'); +const {default: RollingTypingSender} = await import('@app/features/typing/rolling/RollingTypingSender'); +const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore'); + +const START_TIME = 2_000_000; +const CHANNEL = 'channel'; + +const TARGETED_ASSIGNMENT: TypingIndicatorReworkAssignmentResponse = { + enabled: true, + config_version: 7, + user_targeted: true, + source: 'canary', +}; +const DISABLED_ASSIGNMENT: TypingIndicatorReworkAssignmentResponse = { + enabled: false, + config_version: 8, + user_targeted: false, + source: null, +}; + +function publish(assignment: TypingIndicatorReworkAssignmentResponse | undefined): void { + runInAction(() => { + ExperimentAssignments.response = { + poll_interval_seconds: 300, + poll_jitter_percent: 15, + assignments: assignment === undefined ? {} : {typing_indicator_rework: assignment}, + }; + }); +} + +function advanceTo(elapsedMs: number): void { + vi.advanceTimersByTime(elapsedMs - (Date.now() - START_TIME)); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(START_TIME); +}); + +afterEach(() => { + TypingPolicy.stop(); + runInAction(() => { + ExperimentAssignments.response = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE; + }); + LegacyTypingIndicator.reset(); + RollingTypingSender.reset(); + RollingTypingStore.reset(); + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('TypingPolicy', () => { + it('starts on the legacy policy before any assignment has landed', () => { + TypingPolicy.start(); + + expect(TypingPolicy.active).toBe('legacy'); + }); + + it('starts on the legacy policy before the policy is started', () => { + publish(TARGETED_ASSIGNMENT); + + expect(TypingPolicy.active).toBe('legacy'); + }); + + it('stays on the legacy policy when the experiment key is missing', () => { + TypingPolicy.start(); + + publish(undefined); + + expect(TypingPolicy.active).toBe('legacy'); + }); + + it('moves to the rolling policy when the first poll after ready targets the account', () => { + TypingPolicy.start(); + + publish(TARGETED_ASSIGNMENT); + + expect(TypingPolicy.active).toBe('rolling'); + }); + + it('moves back to the legacy policy when a later poll turns the rollout off', () => { + TypingPolicy.start(); + publish(TARGETED_ASSIGNMENT); + + publish(DISABLED_ASSIGNMENT); + + expect(TypingPolicy.active).toBe('legacy'); + }); + + it('never resets the legacy store while the assignment stays untargeted', () => { + TypingPolicy.start(); + LegacyTypingIndicator.startRemoteTyping(CHANNEL, 'alice'); + + publish({...TARGETED_ASSIGNMENT, user_targeted: false, source: null}); + publish(DISABLED_ASSIGNMENT); + publish(undefined); + + expect(LegacyTypingIndicator.isTyping(CHANNEL, 'alice')).toBe(true); + }); + + it('releases the legacy send and idle timers when flipping to rolling', () => { + TypingPolicy.start(); + LegacyTypingUtils.typing(CHANNEL); + expect(vi.getTimerCount()).toBe(3); + + publish(TARGETED_ASSIGNMENT); + + expect(vi.getTimerCount()).toBe(0); + vi.advanceTimersByTime(30000); + expect(doubles.post).not.toHaveBeenCalled(); + }); + + it('releases the legacy cooldown when flipping to rolling', () => { + TypingPolicy.start(); + LegacyTypingUtils.typing(CHANNEL); + advanceTo(1500); + expect(doubles.post).toHaveBeenCalledTimes(1); + + publish(TARGETED_ASSIGNMENT); + publish(DISABLED_ASSIGNMENT); + advanceTo(1600); + LegacyTypingUtils.typing(CHANNEL); + advanceTo(3100); + + expect(doubles.post).toHaveBeenCalledTimes(2); + }); + + it('releases the rolling send slot and every entry timer when flipping to legacy', () => { + TypingPolicy.start(); + publish(TARGETED_ASSIGNMENT); + RollingTypingSender.startTyping(CHANNEL); + RollingTypingStore.start(CHANNEL, 'alice', 'gateway'); + expect(vi.getTimerCount()).toBe(3); + + publish(DISABLED_ASSIGNMENT); + + expect(vi.getTimerCount()).toBe(0); + vi.advanceTimersByTime(30000); + expect(doubles.post).not.toHaveBeenCalled(); + }); + + it('leaves no timers armed straight after a flip with pending work on both arms', () => { + TypingPolicy.start(); + LegacyTypingUtils.typing(CHANNEL); + LegacyTypingIndicator.startRemoteTyping(CHANNEL, 'alice'); + RollingTypingSender.startTyping(CHANNEL); + RollingTypingStore.start(CHANNEL, 'bob', 'gateway'); + expect(vi.getTimerCount()).toBeGreaterThanOrEqual(5); + + publish(TARGETED_ASSIGNMENT); + + expect(vi.getTimerCount()).toBe(0); + }); + + it('never posts twice from a single pending send across a flip', () => { + TypingPolicy.start(); + publish(TARGETED_ASSIGNMENT); + RollingTypingSender.startTyping(CHANNEL); + advanceTo(500); + publish(DISABLED_ASSIGNMENT); + advanceTo(700); + publish(TARGETED_ASSIGNMENT); + advanceTo(800); + RollingTypingSender.startTyping(CHANNEL); + advanceTo(6000); + expect(doubles.post).toHaveBeenCalledTimes(1); + + publish(DISABLED_ASSIGNMENT); + LegacyTypingUtils.typing(CHANNEL); + advanceTo(6500); + publish(TARGETED_ASSIGNMENT); + advanceTo(6700); + publish(DISABLED_ASSIGNMENT); + LegacyTypingUtils.typing(CHANNEL); + advanceTo(12000); + expect(doubles.post).toHaveBeenCalledTimes(2); + }); + + it('starts the rolling sender with a 1500 ms first send after a flip', () => { + TypingPolicy.start(); + LegacyTypingUtils.typing(CHANNEL); + advanceTo(1500); + expect(doubles.post).toHaveBeenCalledTimes(1); + + advanceTo(2000); + publish(TARGETED_ASSIGNMENT); + RollingTypingSender.startTyping(CHANNEL); + advanceTo(3499); + expect(doubles.post).toHaveBeenCalledTimes(1); + + advanceTo(3500); + expect(doubles.post).toHaveBeenCalledTimes(2); + }); + + it('clears remote typists from both stores on every flip', () => { + TypingPolicy.start(); + LegacyTypingIndicator.startRemoteTyping(CHANNEL, 'alice'); + RollingTypingStore.start(CHANNEL, 'bob', 'gateway'); + + publish(TARGETED_ASSIGNMENT); + expect(LegacyTypingIndicator.isTyping(CHANNEL, 'alice')).toBe(false); + expect(RollingTypingStore.isTyping(CHANNEL, 'bob')).toBe(false); + + LegacyTypingIndicator.startRemoteTyping(CHANNEL, 'alice'); + RollingTypingStore.start(CHANNEL, 'bob', 'gateway'); + publish(DISABLED_ASSIGNMENT); + expect(LegacyTypingIndicator.isTyping(CHANNEL, 'alice')).toBe(false); + expect(RollingTypingStore.isTyping(CHANNEL, 'bob')).toBe(false); + }); + + it('ignores a poll that repeats the current assignment', () => { + TypingPolicy.start(); + publish(TARGETED_ASSIGNMENT); + RollingTypingStore.start(CHANNEL, 'bob', 'gateway'); + RollingTypingSender.startTyping(CHANNEL); + + publish({...TARGETED_ASSIGNMENT}); + publish({...TARGETED_ASSIGNMENT, config_version: 9}); + + expect(TypingPolicy.active).toBe('rolling'); + expect(RollingTypingStore.isTyping(CHANNEL, 'bob')).toBe(true); + vi.advanceTimersByTime(1500); + expect(doubles.post).toHaveBeenCalledTimes(1); + }); + + it('returns to legacy when experiment assignments are reset on logout', () => { + TypingPolicy.start(); + publish(TARGETED_ASSIGNMENT); + RollingTypingSender.startTyping(CHANNEL); + + ExperimentAssignments.reset(); + + expect(TypingPolicy.active).toBe('legacy'); + expect(vi.getTimerCount()).toBe(0); + }); + + it('returns to legacy and releases every timer when stopped', () => { + TypingPolicy.start(); + publish(TARGETED_ASSIGNMENT); + RollingTypingSender.startTyping(CHANNEL); + RollingTypingStore.start(CHANNEL, 'bob', 'gateway'); + + TypingPolicy.stop(); + + expect(TypingPolicy.active).toBe('legacy'); + expect(vi.getTimerCount()).toBe(0); + publish(DISABLED_ASSIGNMENT); + publish(TARGETED_ASSIGNMENT); + expect(TypingPolicy.active).toBe('legacy'); + }); + + it('starts twice without installing a second reaction', () => { + TypingPolicy.start(); + TypingPolicy.start(); + TypingPolicy.stop(); + + publish(TARGETED_ASSIGNMENT); + + expect(TypingPolicy.active).toBe('legacy'); + }); +}); diff --git a/fluxer_app/src/features/typing/state/TypingPolicy.ts b/fluxer_app/src/features/typing/state/TypingPolicy.ts new file mode 100644 index 000000000..f4a13a8e8 --- /dev/null +++ b/fluxer_app/src/features/typing/state/TypingPolicy.ts @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import LegacyTypingIndicator from '@app/features/typing/legacy/LegacyTypingIndicator'; +import RollingTypingSender from '@app/features/typing/rolling/RollingTypingSender'; +import RollingTypingStore from '@app/features/typing/rolling/RollingTypingStore'; +import TypingIndicatorReworkRollout from '@app/features/typing/state/TypingIndicatorReworkRollout'; +import {action, type IReactionDisposer, makeObservable, observable, reaction} from 'mobx'; + +type TypingPolicyName = 'legacy' | 'rolling'; + +class TypingPolicy { + active: TypingPolicyName = 'legacy'; + private disposer: IReactionDisposer | null = null; + + constructor() { + makeObservable(this, {active: observable, applyPolicy: action}); + } + + start(): void { + if (this.disposer !== null) { + return; + } + this.disposer = reaction( + () => TypingIndicatorReworkRollout.usesRollingTyping, + (usesRollingTyping) => this.applyPolicy(usesRollingTyping ? 'rolling' : 'legacy'), + {fireImmediately: true}, + ); + } + + stop(): void { + const disposer = this.disposer; + this.disposer = null; + disposer?.(); + this.applyPolicy('legacy'); + } + + applyPolicy(next: TypingPolicyName): void { + if (this.active === next) { + return; + } + LegacyTypingIndicator.reset(); + RollingTypingSender.reset(); + RollingTypingStore.reset(); + this.active = next; + } +} + +export default new TypingPolicy(); diff --git a/fluxer_app/src/features/typing/utils/TypingTierText.test.ts b/fluxer_app/src/features/typing/utils/TypingTierText.test.ts new file mode 100644 index 000000000..c8999639d --- /dev/null +++ b/fluxer_app/src/features/typing/utils/TypingTierText.test.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {I18n} from '@lingui/core'; +import {describe, expect, it, vi} from 'vitest'; + +vi.mock('@lingui/core/macro', () => ({msg: (descriptor: {message: string}) => descriptor})); + +const {getTypingTierText} = await import('@app/features/typing/utils/TypingTierText'); + +function i18nFor(locale: string): I18n { + return {locale, _: (descriptor: {message: string}) => `[${locale}] ${descriptor.message}`} as unknown as I18n; +} + +describe('getTypingTierText', () => { + it('uses the translatable several people line for exactly four typists in every locale', () => { + for (const locale of ['en-US', 'en-GB', 'de', 'ja']) { + expect(getTypingTierText(i18nFor(locale), 4)).toBe(`[${locale}] Several people are typing...`); + } + }); + + it.each([ + ['en-US', 5, 'A handful of keyboard warriors are assembling...'], + ['en-US', 9, 'A handful of keyboard warriors are assembling...'], + ['en-GB', 10, 'A symphony of clacking keys is underway...'], + ['en-US', 14, 'A symphony of clacking keys is underway...'], + ['en-GB', 15, "It's a full-blown typing fiesta in here"], + ['en-US', 19, "It's a full-blown typing fiesta in here"], + ['en-GB', 20, "Whoa, it's a typing apocalypse"], + ['en-US', 250, "Whoa, it's a typing apocalypse"], + ])('keeps the untranslated joke line in %s for %i typists', (locale, count, line) => { + expect(getTypingTierText(i18nFor(locale), count)).toBe(line); + }); + + it.each([ + 'ar', + 'de', + 'es-419', + 'fr', + 'ja', + 'pt-BR', + 'sv-SE', + 'zh-TW', + ])('collapses every crowd to the several people line in %s', (locale) => { + for (const count of [5, 9, 10, 14, 15, 19, 20, 250]) { + expect(getTypingTierText(i18nFor(locale), count)).toBe(`[${locale}] Several people are typing...`); + } + }); + + it('treats a bare language code as a non-English locale', () => { + expect(getTypingTierText(i18nFor('en'), 12)).toBe('[en] Several people are typing...'); + }); +}); diff --git a/fluxer_app/src/features/typing/utils/TypingTierText.ts b/fluxer_app/src/features/typing/utils/TypingTierText.ts new file mode 100644 index 000000000..92aa519f5 --- /dev/null +++ b/fluxer_app/src/features/typing/utils/TypingTierText.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {I18n} from '@lingui/core'; +import {msg} from '@lingui/core/macro'; + +const SEVERAL_PEOPLE_ARE_TYPING_DESCRIPTOR = msg({ + message: 'Several people are typing...', + comment: 'Label in the channel and chat typing users.', +}); + +const TIERED_TYPING_TEXT_LOCALES: ReadonlySet = new Set(['en-US', 'en-GB']); + +export function getTypingTierText(i18n: I18n, typingUserCount: number): string { + if (typingUserCount <= 4 || !TIERED_TYPING_TEXT_LOCALES.has(i18n.locale)) { + return i18n._(SEVERAL_PEOPLE_ARE_TYPING_DESCRIPTOR); + } + if (typingUserCount < 10) { + return 'A handful of keyboard warriors are assembling...'; + } + if (typingUserCount < 15) { + return 'A symphony of clacking keys is underway...'; + } + if (typingUserCount < 20) { + return "It's a full-blown typing fiesta in here"; + } + return "Whoa, it's a typing apocalypse"; +} diff --git a/fluxer_app/src/features/typing/utils/TypingUtils.ts b/fluxer_app/src/features/typing/utils/TypingUtils.ts index b82fa8c65..3fd60c4e8 100644 --- a/fluxer_app/src/features/typing/utils/TypingUtils.ts +++ b/fluxer_app/src/features/typing/utils/TypingUtils.ts @@ -1,206 +1,63 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import Authentication from '@app/features/auth/state/Authentication'; -import * as TypingCommands from '@app/features/typing/commands/TypingCommands'; -import { - createLocalTypingSnapshot, - type LocalTypingMachineEvent, - type LocalTypingModel, - type LocalTypingSnapshot, - selectLocalTypingModel, - transitionLocalTypingSnapshot, -} from '@app/features/typing/state/LocalTypingStateMachine'; -import TypingIndicator from '@app/features/typing/state/TypingIndicator'; +import {runLegacyComposerTyping} from '@app/features/typing/legacy/LegacyComposerTyping'; +import {TypingUtils as LegacyTypingUtils} from '@app/features/typing/legacy/LegacyTypingUtils'; +import {decideComposerTyping} from '@app/features/typing/rolling/RollingComposerTypingGate'; +import RollingTypingSender from '@app/features/typing/rolling/RollingTypingSender'; +import TypingPolicy from '@app/features/typing/state/TypingPolicy'; -class TypingManager { - private snapshot: LocalTypingSnapshot = createLocalTypingSnapshot(); - private remoteSendTimerId: NodeJS.Timeout | null = null; - private localIdleTimerId: NodeJS.Timeout | null = null; - private scheduledRemoteSendAt: number | null = null; - private scheduledRemoteChannelId: string | null = null; - private scheduledRemotePendingVersion: number | null = null; - private scheduledLocalIdleAt: number | null = null; - private scheduledLocalIdleChannelId: string | null = null; - private remoteSendTimerGeneration = 0; - private localIdleTimerGeneration = 0; +interface ComposerTypingInput { + channelId: string; + value: string; + previousValue: string | null; + isAutocompleteAttached: boolean; + enabled: boolean; + typingEnabled: boolean; + isEditingMessageInComposer: boolean; +} - typing(channelId: string): void { - const currentUserId = Authentication.currentUserId; - if (!currentUserId) { +class TypingUtilsFacade { + handleComposerChange(input: ComposerTypingInput): void { + if (TypingPolicy.active !== 'rolling') { + runLegacyComposerTyping(input); return; } - this.transition( - { - type: 'localTyping.started', - channelId, - now: Date.now(), - }, - currentUserId, - ); + const decision = decideComposerTyping({ + previousValue: input.previousValue, + value: input.value, + isRestoringDraft: false, + isEditingMessageInComposer: input.isEditingMessageInComposer, + enabled: input.enabled && input.typingEnabled, + }); + if (decision === 'start') { + RollingTypingSender.startTyping(input.channelId); + } else if (decision === 'stop') { + RollingTypingSender.stopTyping(input.channelId); + } + } + + releaseComposer(channelId: string): void { + if (TypingPolicy.active === 'rolling') { + return; + } + LegacyTypingUtils.clear(channelId); } clear(channelId: string): void { - const currentUserId = Authentication.currentUserId; - this.transition( - { - type: 'localTyping.stopped', - channelId, - }, - currentUserId, - ); - } - - reset(): void { - this.clearRemoteSendTimer(); - this.clearLocalIdleTimer(); - this.snapshot = createLocalTypingSnapshot(); - } - - private transition(event: LocalTypingMachineEvent, currentUserId: string | null): void { - const previousModel = selectLocalTypingModel(this.snapshot); - this.snapshot = transitionLocalTypingSnapshot(this.snapshot, event); - const nextModel = selectLocalTypingModel(this.snapshot); - if (currentUserId != null) { - this.applyLocalTypingMutation(previousModel, nextModel, currentUserId, event); - } - this.applyRemoteSendSchedule(nextModel); - this.applyLocalIdleSchedule(nextModel); - } - - private applyLocalTypingMutation( - previousModel: LocalTypingModel, - nextModel: LocalTypingModel, - userId: string, - event: LocalTypingMachineEvent, - ): void { - if (previousModel.localTyping && previousModel.channelId && previousModel.channelId !== nextModel.channelId) { - TypingCommands.stopLocalTyping(previousModel.channelId, userId); - } - if (previousModel.localTyping && !nextModel.localTyping && previousModel.channelId) { - TypingCommands.stopLocalTyping(previousModel.channelId, userId); + if (TypingPolicy.active === 'rolling') { + RollingTypingSender.stopTyping(channelId); return; } - if (event.type === 'localTyping.started' && nextModel.localTyping && nextModel.channelId) { - TypingCommands.startLocalTyping(nextModel.channelId, userId); - } + LegacyTypingUtils.clear(channelId); } - private applyRemoteSendSchedule(nextModel: LocalTypingModel): void { - if ( - nextModel.remoteSendAt === this.scheduledRemoteSendAt && - nextModel.channelId === this.scheduledRemoteChannelId && - nextModel.remotePendingVersion === this.scheduledRemotePendingVersion - ) { + handleOwnMessageSent(channelId: string): void { + if (TypingPolicy.active === 'rolling') { + RollingTypingSender.handleOwnMessageSent(channelId); return; } - this.clearRemoteSendTimer(); - if (nextModel.remoteSendAt == null || !nextModel.channelId) { - return; - } - const channelId = nextModel.channelId; - const pendingVersion = nextModel.remotePendingVersion; - const remoteSendAt = nextModel.remoteSendAt; - const timerGeneration = this.remoteSendTimerGeneration; - this.scheduledRemoteSendAt = remoteSendAt; - this.scheduledRemoteChannelId = channelId; - this.scheduledRemotePendingVersion = pendingVersion; - this.remoteSendTimerId = setTimeout( - () => this.sendTyping(channelId, pendingVersion, timerGeneration), - Math.max(0, remoteSendAt - Date.now()), - ); - } - - private applyLocalIdleSchedule(nextModel: LocalTypingModel): void { - if ( - nextModel.localIdleAt === this.scheduledLocalIdleAt && - nextModel.channelId === this.scheduledLocalIdleChannelId - ) { - return; - } - this.clearLocalIdleTimer(); - if (nextModel.localIdleAt == null || !nextModel.channelId) { - return; - } - const channelId = nextModel.channelId; - const localIdleAt = nextModel.localIdleAt; - const timerGeneration = this.localIdleTimerGeneration; - this.scheduledLocalIdleAt = localIdleAt; - this.scheduledLocalIdleChannelId = channelId; - this.localIdleTimerId = setTimeout( - () => this.expireLocalTyping(channelId, timerGeneration), - Math.max(0, localIdleAt - Date.now()), - ); - } - - private sendTyping(channelId: string, pendingVersion: number, timerGeneration: number): void { - if (timerGeneration !== this.remoteSendTimerGeneration) { - return; - } - this.remoteSendTimerId = null; - this.scheduledRemoteSendAt = null; - this.scheduledRemoteChannelId = null; - this.scheduledRemotePendingVersion = null; - const currentUserId = Authentication.currentUserId; - if (!currentUserId) { - this.clear(channelId); - return; - } - TypingCommands.sendTyping(channelId); - TypingCommands.startLocalTyping(channelId, currentUserId); - this.transition( - { - type: 'localTyping.remoteSent', - channelId, - now: Date.now(), - pendingVersion, - }, - currentUserId, - ); - } - - private expireLocalTyping(channelId: string, timerGeneration: number): void { - if (timerGeneration !== this.localIdleTimerGeneration) { - return; - } - this.localIdleTimerId = null; - this.scheduledLocalIdleAt = null; - this.scheduledLocalIdleChannelId = null; - const currentUserId = Authentication.currentUserId; - if (!currentUserId) { - this.clear(channelId); - return; - } - this.transition( - { - type: 'localTyping.idleElapsed', - channelId, - now: Date.now(), - }, - currentUserId, - ); - } - - private clearRemoteSendTimer(): void { - this.remoteSendTimerGeneration += 1; - if (this.remoteSendTimerId != null) { - clearTimeout(this.remoteSendTimerId); - this.remoteSendTimerId = null; - } - this.scheduledRemoteSendAt = null; - this.scheduledRemoteChannelId = null; - this.scheduledRemotePendingVersion = null; - } - - private clearLocalIdleTimer(): void { - this.localIdleTimerGeneration += 1; - if (this.localIdleTimerId != null) { - clearTimeout(this.localIdleTimerId); - this.localIdleTimerId = null; - } - this.scheduledLocalIdleAt = null; - this.scheduledLocalIdleChannelId = null; + LegacyTypingUtils.clear(channelId); } } -export const TypingUtils = new TypingManager(); -TypingIndicator.registerLocalTypingResetOwner(TypingUtils); +export const TypingUtils = new TypingUtilsFacade(); diff --git a/fluxer_app/src/features/typing/utils/TypingUtilsFacade.test.ts b/fluxer_app/src/features/typing/utils/TypingUtilsFacade.test.ts new file mode 100644 index 000000000..831f59d7c --- /dev/null +++ b/fluxer_app/src/features/typing/utils/TypingUtilsFacade.test.ts @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const doubles = vi.hoisted(() => ({ + post: vi.fn((_path: string) => Promise.resolve({ok: true})), +})); + +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: doubles.post}})); +vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: 'me'}})); +vi.mock('@app/features/devtools/state/DeveloperOptions', () => ({default: {showMyselfTyping: false}})); +vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}})); +vi.mock('@app/features/user/state/Users', () => ({default: {getUser: () => undefined}})); + +const {TypingUtils} = await import('@app/features/typing/utils/TypingUtils'); +const {default: TypingPolicy} = await import('@app/features/typing/state/TypingPolicy'); +const {TypingUtils: LegacyTypingUtils} = await import('@app/features/typing/legacy/LegacyTypingUtils'); +const {default: LegacyTypingIndicator} = await import('@app/features/typing/legacy/LegacyTypingIndicator'); +const {default: RollingTypingSender} = await import('@app/features/typing/rolling/RollingTypingSender'); +const {default: RollingTypingStore} = await import('@app/features/typing/rolling/RollingTypingStore'); + +type ComposerTypingInput = Parameters[0]; + +const CHANNEL = 'channel'; + +function composerChange(change: Partial): ComposerTypingInput { + return { + channelId: CHANNEL, + value: 'hello', + previousValue: 'hell', + isAutocompleteAttached: false, + enabled: true, + typingEnabled: true, + isEditingMessageInComposer: false, + ...change, + }; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + TypingPolicy.applyPolicy('legacy'); + LegacyTypingIndicator.reset(); + RollingTypingSender.reset(); + RollingTypingStore.reset(); + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('TypingUtils facade', () => { + it('maps clear, release composer and own message sent to the legacy clear under control', () => { + const legacyClear = vi.spyOn(LegacyTypingUtils, 'clear'); + + TypingUtils.clear(CHANNEL); + TypingUtils.releaseComposer('previous-channel'); + TypingUtils.handleOwnMessageSent(CHANNEL); + + expect(legacyClear.mock.calls).toEqual([[CHANNEL], ['previous-channel'], [CHANNEL]]); + }); + + it('runs the legacy composer effect with the head inputs under control', () => { + const legacyTyping = vi.spyOn(LegacyTypingUtils, 'typing').mockImplementation(() => undefined); + const legacyClear = vi.spyOn(LegacyTypingUtils, 'clear').mockImplementation(() => undefined); + const cases: Array<[Partial, 'typing' | 'clear']> = [ + [{value: 'hello'}, 'typing'], + [{value: ' hello '}, 'typing'], + [{value: 'hello', previousValue: 'hello'}, 'typing'], + [{value: 'hello', previousValue: null}, 'typing'], + [{value: 'hello', isEditingMessageInComposer: true}, 'typing'], + [{value: '+:smile'}, 'typing'], + [{value: ''}, 'clear'], + [{value: ' '}, 'clear'], + [{value: 'hello', isAutocompleteAttached: true}, 'clear'], + [{value: '/gif cats'}, 'clear'], + [{value: ' /gif'}, 'clear'], + [{value: 's/teh/the/'}, 'clear'], + [{value: 'hello', enabled: false}, 'clear'], + [{value: 'hello', typingEnabled: false}, 'clear'], + ]; + + for (const [change, expected] of cases) { + legacyTyping.mockClear(); + legacyClear.mockClear(); + + TypingUtils.handleComposerChange(composerChange(change)); + + const called = expected === 'typing' ? legacyTyping : legacyClear; + const skipped = expected === 'typing' ? legacyClear : legacyTyping; + expect(called, JSON.stringify(change)).toHaveBeenCalledExactlyOnceWith(CHANNEL); + expect(skipped, JSON.stringify(change)).not.toHaveBeenCalled(); + } + }); + + it('treats release composer as a no-op under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + const legacyClear = vi.spyOn(LegacyTypingUtils, 'clear'); + TypingUtils.handleComposerChange(composerChange({})); + vi.advanceTimersByTime(400); + + TypingUtils.releaseComposer(CHANNEL); + vi.advanceTimersByTime(1100); + + expect(doubles.post).toHaveBeenCalledTimes(1); + expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(true); + expect(legacyClear).not.toHaveBeenCalled(); + }); + + it('cancels a pending send for clear under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + TypingUtils.handleComposerChange(composerChange({})); + vi.advanceTimersByTime(400); + + TypingUtils.clear(CHANNEL); + vi.advanceTimersByTime(20000); + + expect(doubles.post).not.toHaveBeenCalled(); + expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false); + }); + + it('drops the send slot on own message sent under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + TypingUtils.handleComposerChange(composerChange({})); + vi.advanceTimersByTime(1500); + expect(doubles.post).toHaveBeenCalledTimes(1); + + TypingUtils.handleOwnMessageSent(CHANNEL); + expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false); + vi.advanceTimersByTime(100); + TypingUtils.handleComposerChange(composerChange({previousValue: '', value: 'n'})); + vi.advanceTimersByTime(1500); + + expect(doubles.post).toHaveBeenCalledTimes(2); + }); + + it('starts typing while autocomplete is open under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + + TypingUtils.handleComposerChange( + composerChange({previousValue: '@al', value: '@ali', isAutocompleteAttached: true}), + ); + vi.advanceTimersByTime(1500); + + expect(doubles.post).toHaveBeenCalledTimes(1); + }); + + it('posts nothing for a spaces-only draft under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + + TypingUtils.handleComposerChange(composerChange({previousValue: '', value: ' '})); + TypingUtils.handleComposerChange(composerChange({previousValue: ' ', value: ' '})); + vi.advanceTimersByTime(20000); + + expect(doubles.post).not.toHaveBeenCalled(); + expect(RollingTypingStore.isTyping(CHANNEL, 'me')).toBe(false); + }); + + it('treats a composer with typing turned off as disabled under treatment', () => { + TypingPolicy.applyPolicy('rolling'); + + TypingUtils.handleComposerChange(composerChange({typingEnabled: false})); + vi.advanceTimersByTime(20000); + + expect(doubles.post).not.toHaveBeenCalled(); + }); +}); diff --git a/fluxer_docs/src/content/docs/admin-api/instance.mdx b/fluxer_docs/src/content/docs/admin-api/instance.mdx index 241b569ef..afef3b0ec 100644 --- a/fluxer_docs/src/content/docs/admin-api/instance.mdx +++ b/fluxer_docs/src/content/docs/admin-api/instance.mdx @@ -42,6 +42,7 @@ Missing settings use the defaults documented below. Invalid stored configuration | blocked_message_groups | [blocked message groups configuration](#blocked-message-groups-configuration-object) object | Blocked message block rendering rollout | | expression_info_card | [expression info card configuration](#expression-info-card-configuration-object) object | Emoji and sticker info card rollout | | guild_header_collapse | [guild header collapse configuration](#guild-header-collapse-configuration-object) object | Collapsing guild header rollout | +| typing_indicator_rework | [typing indicator rework configuration](#typing-indicator-rework-configuration-object) object | Reworked typing indicator 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 | @@ -268,6 +269,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. +## Typing indicator rework configuration object + +The instance rollout of how a client sends and shows typing indicators. [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 `typing-indicator-rework-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 sends at most one typing signal every 8 seconds, names up to three people who are typing, and announces those names to screen readers, and a client that is not drawn keeps the typing indicators 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. @@ -664,6 +686,7 @@ The body has one optional object for each section. Fluxer leaves an absent secti | blocked_message_groups? | object | Any subset of the [blocked message groups](#blocked-message-groups-configuration-object) fields | | expression_info_card? | object | Any subset of the [expression info card](#expression-info-card-configuration-object) fields | | guild_header_collapse? | object | Any subset of the [guild header collapse](#guild-header-collapse-configuration-object) fields | +| typing_indicator_rework? | object | Any subset of the [typing indicator rework](#typing-indicator-rework-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 | @@ -683,6 +706,7 @@ The body has one optional object for each section. Fluxer leaves an absent secti `blocked_message_groups` takes every [blocked message groups configuration](#blocked-message-groups-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. `expression_info_card` takes every [expression info card configuration](#expression-info-card-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. `guild_header_collapse` takes every [guild header collapse configuration](#guild-header-collapse-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. +`typing_indicator_rework` takes every [typing indicator rework configuration](#typing-indicator-rework-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. @@ -720,7 +744,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`, `guild_activity_log_presentation`, `message_hover_tracking`, `message_keyboard_focus`, `blocked_message_groups`, `expression_info_card`, `guild_header_collapse`, `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`, `guild_activity_log_presentation`, `message_hover_tracking`, `message_keyboard_focus`, `blocked_message_groups`, `expression_info_card`, `guild_header_collapse`, `typing_indicator_rework`, `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 1f2b29e7e..82e4974ee 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, `message_hover_tracking`, which selects one of two client implementations of the message hover state, `message_keyboard_focus`, which selects one of two client implementations of keyboard navigation in the message list, `blocked_message_groups`, which selects how a client renders a revealed block of blocked messages, `guild_activity_log_presentation`, which selects one of two client renderings of the community activity log, `expression_info_card`, which selects what a client shows for an emoji or a sticker in a message, and `guild_header_collapse`, which selects how a client draws the guild banner above the channel list. +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, `message_keyboard_focus`, which selects one of two client implementations of keyboard navigation in the message list, `blocked_message_groups`, which selects how a client renders a revealed block of blocked messages, `guild_activity_log_presentation`, which selects one of two client renderings of the community activity log, `expression_info_card`, which selects what a client shows for an emoji or a sticker in a message, `guild_header_collapse`, which selects how a client draws the guild banner above the channel list, and `typing_indicator_rework`, which selects how a client sends and shows typing indicators. 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. @@ -39,10 +39,11 @@ One entry per experiment. The envelope reports this object even when it is empty | guild_activity_log_presentation? | [guild activity log presentation assignment](#guild-activity-log-presentation-assignment-object) object | The caller's guild activity log presentation assignment | | expression_info_card? | [expression info card assignment](#expression-info-card-assignment-object) object | The caller's expression info card assignment | | guild_header_collapse? | [guild header collapse assignment](#guild-header-collapse-assignment-object) object | The caller's guild header collapse assignment | +| typing_indicator_rework? | [typing indicator rework assignment](#typing-indicator-rework-assignment-object) object | The caller's typing indicator rework assignment | Ignore unknown experiments and treat a missing experiment as off. -This server version writes `voice_noise_suppression`, `message_hover_tracking`, `message_keyboard_focus`, `blocked_message_groups`, `guild_activity_log_presentation`, `expression_info_card`, and `guild_header_collapse` 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`, `message_keyboard_focus`, `blocked_message_groups`, `guild_activity_log_presentation`, `expression_info_card`, `guild_header_collapse`, and `typing_indicator_rework` 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 @@ -270,6 +271,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. +## Typing indicator rework assignment object + +One resolution of the instance typing indicator rework rollout against one account. Every field is present whenever the key is written. + +The rollout selects how a client sends and shows typing indicators. A drawn client sends one typing signal 1.5 seconds after a person starts typing, or at once when they resume 16 seconds or more after it scheduled the previous one, and then at most once every 8 seconds while they keep typing. Only a keystroke starts a signal, so none is sent more than 1.5 seconds after they stop. It treats a signal it receives as valid for 10 seconds from the moment it arrives, unless a further signal refreshes it, and it drops a person from the list once their message arrives. It names up to three people and collapses a larger group into one line, and it announces up to three names to screen readers even where the visible row has no room for them. A client that is not drawn keeps the typing indicators 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 | + +### Typing indicator rework 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 6424b9652..59fc11c8c 100644 --- a/packages/schema/src/domains/admin/AdminSchemas.ts +++ b/packages/schema/src/domains/admin/AdminSchemas.ts @@ -47,6 +47,10 @@ import { MessageKeyboardFocusConfigResponse, MessageKeyboardFocusConfigUpdateRequest, } from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; +import { + TypingIndicatorReworkConfigResponse, + TypingIndicatorReworkConfigUpdateRequest, +} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas'; import {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import { InstanceCaptchaProviderSchema, @@ -660,6 +664,7 @@ export const InstanceConfigResponse = z.object({ blocked_message_groups: BlockedMessageGroupsConfigResponse, expression_info_card: ExpressionInfoCardConfigResponse, guild_header_collapse: GuildHeaderCollapseConfigResponse, + typing_indicator_rework: TypingIndicatorReworkConfigResponse, registration: InstanceRegistrationResponse, self_hosted: z.boolean(), app_public: AppPublicConfigResponse, @@ -702,6 +707,7 @@ export const InstanceConfigUpdateRequest = z.object({ blocked_message_groups: BlockedMessageGroupsConfigUpdateRequest.nullish(), expression_info_card: ExpressionInfoCardConfigUpdateRequest.nullish(), guild_header_collapse: GuildHeaderCollapseConfigUpdateRequest.nullish(), + typing_indicator_rework: TypingIndicatorReworkConfigUpdateRequest.nullish(), registration: z .object({ mode: InstanceRegistrationModeSchema.optional(), diff --git a/packages/schema/src/domains/experiment/ExperimentSchemas.ts b/packages/schema/src/domains/experiment/ExperimentSchemas.ts index 0aef6a92d..a534909c2 100644 --- a/packages/schema/src/domains/experiment/ExperimentSchemas.ts +++ b/packages/schema/src/domains/experiment/ExperimentSchemas.ts @@ -28,6 +28,10 @@ import { INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT, MessageKeyboardFocusAssignmentResponse, } from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas'; +import { + INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT, + TypingIndicatorReworkAssignmentResponse, +} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas'; import {z} from 'zod'; export const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS = 60; @@ -72,6 +76,7 @@ const ExperimentAssignmentsSchema = z.object({ guild_activity_log_presentation: GuildActivityLogPresentationAssignmentResponse.optional(), expression_info_card: ExpressionInfoCardAssignmentResponse.optional(), guild_header_collapse: GuildHeaderCollapseAssignmentResponse.optional(), + typing_indicator_rework: TypingIndicatorReworkAssignmentResponse.optional(), }); export const ExperimentAssignmentsResponse = z.object({ @@ -129,3 +134,9 @@ export function readGuildHeaderCollapseAssignment( ): GuildHeaderCollapseAssignmentResponse { return response.assignments.guild_header_collapse ?? INERT_GUILD_HEADER_COLLAPSE_ASSIGNMENT; } + +export function readTypingIndicatorReworkAssignment( + response: ExperimentAssignmentsResponse, +): TypingIndicatorReworkAssignmentResponse { + return response.assignments.typing_indicator_rework ?? INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT; +} diff --git a/packages/schema/src/domains/experiment/TypingIndicatorReworkSchemas.ts b/packages/schema/src/domains/experiment/TypingIndicatorReworkSchemas.ts new file mode 100644 index 000000000..91098440c --- /dev/null +++ b/packages/schema/src/domains/experiment/TypingIndicatorReworkSchemas.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createClientBehaviorExperiment} from '@fluxer/schema/src/domains/experiment/ClientBehaviorExperiment'; +import type {z} from 'zod'; + +const typingIndicatorRework = createClientBehaviorExperiment({defaultRolloutSalt: 'typing-indicator-rework-v1'}); + +export const TypingIndicatorReworkConfigSchema = typingIndicatorRework.ConfigSchema; + +export type TypingIndicatorReworkConfig = z.infer; + +export const DEFAULT_TYPING_INDICATOR_REWORK_CONFIG: TypingIndicatorReworkConfig = typingIndicatorRework.DEFAULT_CONFIG; + +export const TypingIndicatorReworkConfigUpdateRequest = typingIndicatorRework.ConfigUpdateRequest; + +export type TypingIndicatorReworkConfigUpdateRequest = z.infer; + +export const TypingIndicatorReworkConfigResponse = typingIndicatorRework.ConfigSchema; + +export type TypingIndicatorReworkConfigResponse = TypingIndicatorReworkConfig; + +export const TypingIndicatorReworkAssignmentResponse = typingIndicatorRework.AssignmentResponse; + +export type TypingIndicatorReworkAssignmentResponse = z.infer; + +export const INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT: TypingIndicatorReworkAssignmentResponse = + typingIndicatorRework.INERT_ASSIGNMENT; + +export function resolveTypingIndicatorReworkAssignment( + config: TypingIndicatorReworkConfig, + userId: string, +): TypingIndicatorReworkAssignmentResponse { + return typingIndicatorRework.resolveAssignment(config, userId); +} + +export function isTypingIndicatorReworkTargeted(assignment: TypingIndicatorReworkAssignmentResponse): boolean { + return assignment.enabled && assignment.user_targeted; +} diff --git a/packages/schema/src/domains/tests/TypingIndicatorReworkSchemas.test.ts b/packages/schema/src/domains/tests/TypingIndicatorReworkSchemas.test.ts new file mode 100644 index 000000000..1ac9c2506 --- /dev/null +++ b/packages/schema/src/domains/tests/TypingIndicatorReworkSchemas.test.ts @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + DEFAULT_TYPING_INDICATOR_REWORK_CONFIG, + INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT, + isTypingIndicatorReworkTargeted, + resolveTypingIndicatorReworkAssignment, + type TypingIndicatorReworkConfig, + TypingIndicatorReworkConfigSchema, +} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas'; +import {describe, expect, it} from 'vitest'; + +const USER_ID = '1485064866382176262'; +const OTHER_USER_ID = '1485064866382176263'; + +function config(overrides: Partial = {}): TypingIndicatorReworkConfig { + return {...DEFAULT_TYPING_INDICATOR_REWORK_CONFIG, ...overrides}; +} + +describe('TypingIndicatorReworkConfigSchema', () => { + it('defaults to a disabled rollout that targets nobody', () => { + expect(DEFAULT_TYPING_INDICATOR_REWORK_CONFIG).toEqual({ + enabled: false, + config_version: 0, + rollout_basis_points: 0, + rollout_salt: 'typing-indicator-rework-v1', + included_user_ids: [], + excluded_user_ids: [], + }); + }); + + it('rejects a rollout share above the bucket resolution', () => { + expect(() => TypingIndicatorReworkConfigSchema.parse({rollout_basis_points: 10001})).toThrow(); + }); + + it('rejects a target that is not a snowflake', () => { + expect(() => TypingIndicatorReworkConfigSchema.parse({included_user_ids: ['not-a-snowflake']})).toThrow(); + }); +}); + +describe('resolveTypingIndicatorReworkAssignment', () => { + it('reports the inert assignment with the stored revision while the rollout is off', () => { + const assignment = resolveTypingIndicatorReworkAssignment( + config({enabled: false, config_version: 4, rollout_basis_points: 10000}), + USER_ID, + ); + + expect(assignment).toEqual({...INERT_TYPING_INDICATOR_REWORK_ASSIGNMENT, config_version: 4}); + expect(isTypingIndicatorReworkTargeted(assignment)).toBe(false); + }); + + it('targets every account at the full rollout share', () => { + const assignment = resolveTypingIndicatorReworkAssignment( + config({enabled: true, config_version: 2, rollout_basis_points: 10000}), + USER_ID, + ); + + expect(assignment).toEqual({enabled: true, config_version: 2, user_targeted: true, source: 'canary'}); + expect(isTypingIndicatorReworkTargeted(assignment)).toBe(true); + }); + + it('targets no account at a zero rollout share', () => { + const assignment = resolveTypingIndicatorReworkAssignment(config({enabled: true}), USER_ID); + + expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: false, source: null}); + expect(isTypingIndicatorReworkTargeted(assignment)).toBe(false); + }); + + it('targets an allowlisted account outside the sampled share', () => { + const assignment = resolveTypingIndicatorReworkAssignment( + config({enabled: true, included_user_ids: [USER_ID]}), + USER_ID, + ); + + expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: true, source: 'user_rule'}); + }); + + it('excludes an account even when it is allowlisted and inside the share', () => { + const assignment = resolveTypingIndicatorReworkAssignment( + config({ + enabled: true, + rollout_basis_points: 10000, + included_user_ids: [USER_ID], + excluded_user_ids: [USER_ID], + }), + USER_ID, + ); + + expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: false, source: null}); + expect( + resolveTypingIndicatorReworkAssignment(config({enabled: true, rollout_basis_points: 10000}), OTHER_USER_ID), + ).toEqual({enabled: true, config_version: 0, user_targeted: true, source: 'canary'}); + }); + + it('resolves the same account the same way on every call', () => { + const rollout = config({enabled: true, rollout_basis_points: 5000}); + const first = resolveTypingIndicatorReworkAssignment(rollout, USER_ID); + const second = resolveTypingIndicatorReworkAssignment(rollout, USER_ID); + + expect(first).toEqual(second); + }); +});