diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 70417e356e..a9d9a686a2 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -526,9 +526,12 @@ "@loginErrorMissingUsername": { "description": "Error message when an empty username was provided." }, - "topicValidationErrorTooLong": "Topic length shouldn't be greater than 60 characters.", + "topicValidationErrorTooLong": "Topic length shouldn't be greater than {num, plural, =1{1 character} other{{num} characters}}.", "@topicValidationErrorTooLong": { - "description": "Topic validation error when topic is too long." + "description": "Topic validation error when topic is too long.", + "placeholders": { + "num": {"type": "int", "example": "60"} + } }, "topicValidationErrorMandatoryButEmpty": "Topics are required in this organization.", "@topicValidationErrorMandatoryButEmpty": { diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 5882122baa..43c91d4976 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -24,6 +24,8 @@ class InitialSnapshot { final List customProfileFields; + final int maxTopicLength; + /// The realm-level policy, on pre-FL 163 servers, for visibility of real email addresses. /// /// Search for "email_address_visibility" in https://zulip.com/api/register-queue. @@ -123,6 +125,7 @@ class InitialSnapshot { required this.zulipMergeBase, required this.alertWords, required this.customProfileFields, + required this.maxTopicLength, required this.emailAddressVisibility, required this.serverTypingStartedExpiryPeriodMilliseconds, required this.serverTypingStoppedWaitPeriodMilliseconds, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 79cfbe5557..0d75baff83 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -22,6 +22,7 @@ InitialSnapshot _$InitialSnapshotFromJson( (json['custom_profile_fields'] as List) .map((e) => CustomProfileField.fromJson(e as Map)) .toList(), + maxTopicLength: (json['max_topic_length'] as num).toInt(), emailAddressVisibility: $enumDecodeNullable( _$EmailAddressVisibilityEnumMap, json['email_address_visibility'], @@ -115,6 +116,7 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) => 'zulip_merge_base': instance.zulipMergeBase, 'alert_words': instance.alertWords, 'custom_profile_fields': instance.customProfileFields, + 'max_topic_length': instance.maxTopicLength, 'email_address_visibility': _$EmailAddressVisibilityEnumMap[instance.emailAddressVisibility], 'server_typing_started_expiry_period_milliseconds': diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 5af312ce45..43552de94b 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -155,8 +155,6 @@ class GetMessagesResult { Map toJson() => _$GetMessagesResultToJson(this); } -// https://zulip.com/api/send-message#parameter-topic -const int kMaxTopicLengthCodePoints = 60; // https://zulip.com/api/send-message#parameter-content const int kMaxMessageLengthCodePoints = 10000; diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 3203569966..5a2a6335f6 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -810,8 +810,8 @@ abstract class ZulipLocalizations { /// Topic validation error when topic is too long. /// /// In en, this message translates to: - /// **'Topic length shouldn\'t be greater than 60 characters.'** - String get topicValidationErrorTooLong; + /// **'Topic length shouldn\'t be greater than {num, plural, =1{1 character} other{{num} characters}}.'** + String topicValidationErrorTooLong(int num); /// Topic validation error when topic is required but was empty. /// diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 20ad3cbe24..6b3557add4 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -408,7 +408,15 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get loginErrorMissingUsername => 'Please enter your username.'; @override - String get topicValidationErrorTooLong => 'Topic length shouldn\'t be greater than 60 characters.'; + String topicValidationErrorTooLong(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num characters', + one: '1 character', + ); + return 'Topic length shouldn\'t be greater than $_temp0.'; + } @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index a88981fc26..7f181f5073 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -408,7 +408,15 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get loginErrorMissingUsername => 'Please enter your username.'; @override - String get topicValidationErrorTooLong => 'Topic length shouldn\'t be greater than 60 characters.'; + String topicValidationErrorTooLong(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num characters', + one: '1 character', + ); + return 'Topic length shouldn\'t be greater than $_temp0.'; + } @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index be6eee8870..ec1ad123df 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -408,7 +408,15 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get loginErrorMissingUsername => 'Please enter your username.'; @override - String get topicValidationErrorTooLong => 'Topic length shouldn\'t be greater than 60 characters.'; + String topicValidationErrorTooLong(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num characters', + one: '1 character', + ); + return 'Topic length shouldn\'t be greater than $_temp0.'; + } @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 8e51c7a19b..eda227527f 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -408,7 +408,15 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get loginErrorMissingUsername => 'Please enter your username.'; @override - String get topicValidationErrorTooLong => 'Topic length shouldn\'t be greater than 60 characters.'; + String topicValidationErrorTooLong(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num characters', + one: '1 character', + ); + return 'Topic length shouldn\'t be greater than $_temp0.'; + } @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 0e4009a462..e03579f386 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -408,7 +408,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get loginErrorMissingUsername => 'Proszę podaj nazwę użytkownika.'; @override - String get topicValidationErrorTooLong => 'Tytuł nie może być dłuższy niż 60 znaków.'; + String topicValidationErrorTooLong(int num) { + return 'Tytuł nie może być dłuższy niż 60 znaków.'; + } @override String get topicValidationErrorMandatoryButEmpty => 'Wątki są wymagane przez tę organizację.'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index f3ec9d623c..226e45c106 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -408,7 +408,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get loginErrorMissingUsername => 'Пожалуйста, введите ваше имя пользователя.'; @override - String get topicValidationErrorTooLong => 'Длина темы не должна превышать 60 символов.'; + String topicValidationErrorTooLong(int num) { + return 'Длина темы не должна превышать 60 символов.'; + } @override String get topicValidationErrorMandatoryButEmpty => 'Темы обязательны в этой организации.'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 6d22409eb5..7a808172e6 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -408,7 +408,15 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get loginErrorMissingUsername => 'Prosím zadajte prihlasovacie meno.'; @override - String get topicValidationErrorTooLong => 'Topic length shouldn\'t be greater than 60 characters.'; + String topicValidationErrorTooLong(int num) { + String _temp0 = intl.Intl.pluralLogic( + num, + locale: localeName, + other: '$num characters', + one: '1 character', + ); + return 'Topic length shouldn\'t be greater than $_temp0.'; + } @override String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.'; diff --git a/lib/model/store.dart b/lib/model/store.dart index c3aaa501db..1b84b3c411 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -346,6 +346,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, UserStore, Channel realmEmptyTopicDisplayName: initialSnapshot.realmEmptyTopicDisplayName, realmDefaultExternalAccounts: initialSnapshot.realmDefaultExternalAccounts, customProfileFields: _sortCustomProfileFields(initialSnapshot.customProfileFields), + maxTopicLength: initialSnapshot.maxTopicLength, emailAddressVisibility: initialSnapshot.emailAddressVisibility, emoji: EmojiStoreImpl( realmUrl: realmUrl, allRealmEmoji: initialSnapshot.realmEmoji), @@ -389,6 +390,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, UserStore, Channel required String? realmEmptyTopicDisplayName, required this.realmDefaultExternalAccounts, required this.customProfileFields, + required this.maxTopicLength, required this.emailAddressVisibility, required EmojiStoreImpl emoji, required this.accountId, @@ -470,6 +472,8 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, UserStore, Channel } final String? _realmEmptyTopicDisplayName; // TODO(#668): update this realm setting + final int maxTopicLength; + final Map realmDefaultExternalAccounts; List customProfileFields; /// For docs, please see [InitialSnapshot.emailAddressVisibility]. diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 1665227d51..655cde1c04 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -131,10 +131,10 @@ enum TopicValidationError { mandatoryButEmpty, tooLong; - String message(ZulipLocalizations zulipLocalizations) { + String message(ZulipLocalizations zulipLocalizations, {required int maxTopicLength}) { switch (this) { case tooLong: - return zulipLocalizations.topicValidationErrorTooLong; + return zulipLocalizations.topicValidationErrorTooLong(maxTopicLength); case mandatoryButEmpty: return zulipLocalizations.topicValidationErrorMandatoryButEmpty; } @@ -143,6 +143,7 @@ enum TopicValidationError { class ComposeTopicController extends ComposeController { ComposeTopicController({required this.store}) { + maxLengthUnicodeCodePoints = store.maxTopicLength; _update(); } @@ -151,8 +152,7 @@ class ComposeTopicController extends ComposeController { // TODO(#668): listen to [PerAccountStore] once we subscribe to this value bool get mandatory => store.realmMandatoryTopics; - // TODO(#307) use `max_topic_length` instead of hardcoded limit - @override final maxLengthUnicodeCodePoints = kMaxTopicLengthCodePoints; + @override late int maxLengthUnicodeCodePoints; @override String _computeTextNormalized() { @@ -1105,10 +1105,9 @@ class _SendButtonState extends State<_SendButton> { if (_hasValidationErrors) { final zulipLocalizations = ZulipLocalizations.of(context); List validationErrorMessages = [ - for (final error in (controller is StreamComposeBoxController - ? controller.topic.validationErrors - : const [])) - error.message(zulipLocalizations), + if (controller is StreamComposeBoxController) + for (final error in controller.topic.validationErrors) + error.message(zulipLocalizations, maxTopicLength: controller.topic.maxLengthUnicodeCodePoints), for (final error in controller.content.validationErrors) error.message(zulipLocalizations), ]; diff --git a/test/example_data.dart b/test/example_data.dart index d44849bc6a..0ce19ff8f3 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -897,6 +897,7 @@ InitialSnapshot initialSnapshot({ String? zulipMergeBase, List? alertWords, List? customProfileFields, + int? maxTopicLength, EmailAddressVisibility? emailAddressVisibility, int? serverTypingStartedExpiryPeriodMilliseconds, int? serverTypingStoppedWaitPeriodMilliseconds, @@ -927,6 +928,7 @@ InitialSnapshot initialSnapshot({ zulipMergeBase: zulipMergeBase ?? recentZulipVersion, alertWords: alertWords ?? ['klaxon'], customProfileFields: customProfileFields ?? [], + maxTopicLength: maxTopicLength ?? 60, emailAddressVisibility: emailAddressVisibility ?? EmailAddressVisibility.everyone, serverTypingStartedExpiryPeriodMilliseconds: serverTypingStartedExpiryPeriodMilliseconds ?? 15000, diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index e02fd97a15..4161a40e7c 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -48,6 +48,7 @@ void main() { List streams = const [], bool? mandatoryTopics, int? zulipFeatureLevel, + int? maxTopicLength, }) async { if (narrow case ChannelNarrow(:var streamId) || TopicNarrow(: var streamId)) { assert(streams.any((stream) => stream.streamId == streamId), @@ -60,6 +61,7 @@ void main() { await testBinding.globalStore.add(selfAccount, eg.initialSnapshot( zulipFeatureLevel: zulipFeatureLevel, realmMandatoryTopics: mandatoryTopics, + maxTopicLength: maxTopicLength, )); store = await testBinding.globalStore.perAccount(selfAccount.id); @@ -286,41 +288,47 @@ void main() { }); group('topic', () { - Future prepareWithTopic(WidgetTester tester, String topic) async { + Future prepareWithTopic(WidgetTester tester, String topic, int maxTopicLength) async { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); final narrow = ChannelNarrow(channel.streamId); - await prepareComposeBox(tester, narrow: narrow, streams: [channel]); + await prepareComposeBox(tester, narrow: narrow, streams: [channel], + maxTopicLength: maxTopicLength); await enterTopic(tester, narrow: narrow, topic: topic); await enterContent(tester, 'some content'); } - Future checkErrorResponse(WidgetTester tester) async { + Future checkErrorResponse(WidgetTester tester, {required int maxTopicLength}) async { await tester.tap(find.byWidget(checkErrorDialog(tester, expectedTitle: 'Message not sent', - expectedMessage: 'Topic length shouldn\'t be greater than 60 characters.'))); + expectedMessage: 'Topic length shouldn\'t be greater than $maxTopicLength characters.'))); } + final ValueVariant maxTopicLengthVariants = ValueVariant({50, 60, 70}); + testWidgets('too-long topic is rejected', (tester) async { + final maxTopicLength = maxTopicLengthVariants.currentValue!; await prepareWithTopic(tester, - makeStringWithCodePoints(kMaxTopicLengthCodePoints + 1)); + makeStringWithCodePoints(maxTopicLength + 1), maxTopicLength); await tapSendButton(tester); - await checkErrorResponse(tester); - }); + await checkErrorResponse(tester, maxTopicLength: maxTopicLength); + }, variant: maxTopicLengthVariants); testWidgets('max-length topic not rejected', (tester) async { + final maxTopicLength = maxTopicLengthVariants.currentValue!; await prepareWithTopic(tester, - makeStringWithCodePoints(kMaxTopicLengthCodePoints)); + makeStringWithCodePoints(maxTopicLength), maxTopicLength); await tapSendButton(tester); checkNoErrorDialog(tester); - }); + }, variant: maxTopicLengthVariants); testWidgets('code points not counted unnecessarily', (tester) async { - await prepareWithTopic(tester, 'a' * kMaxTopicLengthCodePoints); + final maxTopicLength = maxTopicLengthVariants.currentValue!; + await prepareWithTopic(tester, 'a' * maxTopicLength, maxTopicLength); check((controller as StreamComposeBoxController) .topic.debugLengthUnicodeCodePointsIfLong).isNull(); - }); + }, variant: maxTopicLengthVariants); }); });