From 373e23a69f8be0339bccb315bc34ba6048cffb77 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 14 Apr 2025 21:31:02 -0400 Subject: [PATCH 1/4] api [nfc]: Add TopicName.processLikeServer The point of this helper is to replicate what a topic sent from the client will become, after being processed by the server. This important when trying to create a local copy of a stream message, whose topic can get translated when it's delivered by the server. --- lib/api/model/model.dart | 56 ++++++++++++++++++++++++++++++++++ lib/api/route/messages.dart | 9 ------ test/api/model/model_test.dart | 25 +++++++++++++++ 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 6b2cb4dd02..a2874c4c44 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -550,6 +550,15 @@ String? tryParseEmojiCodeToUnicode(String emojiCode) { } } +/// The topic servers understand to mean "there is no topic". +/// +/// This should match +/// https://github.com/zulip/zulip/blob/6.0/zerver/actions/message_edit.py#L940 +/// or similar logic at the latest `main`. +// This is hardcoded in the server, and therefore untranslated; that's +// zulip/zulip#3639. +const String kNoTopicTopic = '(no topic)'; + /// The name of a Zulip topic. // TODO(dart): Can we forbid calling Object members on this extension type? // (The lack of "implements Object" ought to do that, but doesn't.) @@ -600,6 +609,53 @@ extension type const TopicName(String _value) { /// using [canonicalize]. bool isSameAs(TopicName other) => canonicalize() == other.canonicalize(); + /// Process this topic to match how it would appear on a message object from + /// the server. + /// + /// This returns the [TopicName] the server would be predicted to include + /// in a message object resulting from sending to this [TopicName] + /// in a [sendMessage] request. + /// + /// This [TopicName] is required to have no leading or trailing whitespace. + /// + /// For a client that supports empty topics, when FL>=334, the server converts + /// `store.realmEmptyTopicDisplayName` to an empty string; when FL>=370, + /// the server converts "(no topic)" to an empty string as well. + /// + /// See API docs: + /// https://zulip.com/api/send-message#parameter-topic + TopicName processLikeServer({ + required int zulipFeatureLevel, + required String? realmEmptyTopicDisplayName, + }) { + assert(_value.trim() == _value); + // TODO(server-10) simplify this away + if (zulipFeatureLevel < 334) { + // From the API docs: + // > Before Zulip 10.0 (feature level 334), empty string was not a valid + // > topic name for channel messages. + assert(_value.isNotEmpty); + return this; + } + + // TODO(server-10) simplify this away + if (zulipFeatureLevel < 370 && _value == kNoTopicTopic) { + // From the API docs: + // > Before Zulip 10.0 (feature level 370), "(no topic)" was not + // > interpreted as an empty string. + return TopicName(kNoTopicTopic); + } + + if (_value == kNoTopicTopic || _value == realmEmptyTopicDisplayName) { + // From the API docs: + // > When "(no topic)" or the value of realm_empty_topic_display_name + // > found in the POST /register response is used for [topic], + // > it is interpreted as an empty string. + return TopicName(''); + } + return TopicName(_value); + } + TopicName.fromJson(this._value); String toJson() => apiName; diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index ccafdbce45..f55e630585 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -178,15 +178,6 @@ const int kMaxTopicLengthCodePoints = 60; // https://zulip.com/api/send-message#parameter-content const int kMaxMessageLengthCodePoints = 10000; -/// The topic servers understand to mean "there is no topic". -/// -/// This should match -/// https://github.com/zulip/zulip/blob/6.0/zerver/actions/message_edit.py#L940 -/// or similar logic at the latest `main`. -// This is hardcoded in the server, and therefore untranslated; that's -// zulip/zulip#3639. -const String kNoTopicTopic = '(no topic)'; - /// https://zulip.com/api/send-message Future sendMessage( ApiConnection connection, { diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index b1552deb5b..6012f29ead 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -161,6 +161,31 @@ void main() { doCheck(eg.t('✔ a'), eg.t('✔ b'), false); }); + + test('processLikeServer', () { + final emptyTopicDisplayName = eg.defaultRealmEmptyTopicDisplayName; + void doCheck(TopicName topic, TopicName expected, int zulipFeatureLevel) { + check(topic.processLikeServer( + zulipFeatureLevel: zulipFeatureLevel, + realmEmptyTopicDisplayName: emptyTopicDisplayName), + ).equals(expected); + } + + check(() => eg.t('').processLikeServer( + zulipFeatureLevel: 333, + realmEmptyTopicDisplayName: emptyTopicDisplayName), + ).throws(); + doCheck(eg.t('(no topic)'), eg.t('(no topic)'), 333); + doCheck(eg.t(emptyTopicDisplayName), eg.t(emptyTopicDisplayName), 333); + doCheck(eg.t('other topic'), eg.t('other topic'), 333); + + doCheck(eg.t(''), eg.t(''), 334); + doCheck(eg.t('(no topic)'), eg.t('(no topic)'), 334); + doCheck(eg.t(emptyTopicDisplayName), eg.t(''), 334); + doCheck(eg.t('other topic'), eg.t('other topic'), 334); + + doCheck(eg.t('(no topic)'), eg.t(''), 370); + }); }); group('DmMessage', () { From b982a781dd962c818ab1bc88240d6a891c9feb33 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Mon, 14 Apr 2025 21:33:41 -0400 Subject: [PATCH 2/4] store [nfc]: Move zulip{FeatureLevel,Version} to PerAccountStoreBase --- lib/model/store.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index 300b7dc22f..f7a4e0ca4e 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -385,6 +385,12 @@ abstract class PerAccountStoreBase { /// This returns null if [reference] fails to parse as a URL. Uri? tryResolveUrl(String reference) => _tryResolveUrl(realmUrl, reference); + /// Always equal to `connection.zulipFeatureLevel` + /// and `account.zulipFeatureLevel`. + int get zulipFeatureLevel => connection.zulipFeatureLevel!; + + String get zulipVersion => account.zulipVersion; + //////////////////////////////// // Data attached to the self-account on the realm. @@ -558,11 +564,6 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor //////////////////////////////// // Data attached to the realm or the server. - /// Always equal to `connection.zulipFeatureLevel` - /// and `account.zulipFeatureLevel`. - int get zulipFeatureLevel => connection.zulipFeatureLevel!; - - String get zulipVersion => account.zulipVersion; final RealmWildcardMentionPolicy realmWildcardMentionPolicy; // TODO(#668): update this realm setting final bool realmMandatoryTopics; // TODO(#668): update this realm setting /// For docs, please see [InitialSnapshot.realmWaitingPeriodThreshold]. From fa5dfd568865f4991aae1dc8d8699501b2647d84 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 22 Apr 2025 00:04:54 -0400 Subject: [PATCH 3/4] test [nfc]: Add utcTimestamp --- test/example_data.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/example_data.dart b/test/example_data.dart index 7b517aca79..9577ef0e73 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -69,6 +69,20 @@ ZulipApiException apiExceptionUnauthorized({String routeName = 'someRoute'}) { data: {}, message: 'Invalid API key'); } +//////////////////////////////////////////////////////////////// +// Time values. +// + +final timeInPast = DateTime.utc(2025, 4, 1, 8, 30, 0); + +/// The UNIX timestamp, in UTC seconds. +/// +/// This is the commonly used format in the Zulip API for timestamps. +int utcTimestamp([DateTime? dateTime]) { + dateTime ??= timeInPast; + return dateTime.toUtc().millisecondsSinceEpoch ~/ 1000; +} + //////////////////////////////////////////////////////////////// // Realm-wide (or server-wide) metadata. // From 0c03009977b548bfe273930f2b1dfa68ebcd46ec Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Tue, 22 Apr 2025 13:03:37 -0400 Subject: [PATCH 4/4] binding [nfc]: Add utcNow This will be the same as `DateTime.timestamp()` in live code (therefore the NFC). For testing, utcNow uses a clock instance that can be controlled by FakeAsync. We could have made call sites of `DateTime.now()` use it too, but those for now don't need it for testing. --- lib/model/binding.dart | 8 ++++++++ test/model/binding.dart | 3 +++ 2 files changed, 11 insertions(+) diff --git a/lib/model/binding.dart b/lib/model/binding.dart index 198e0ae119..fb3add46da 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -120,6 +120,11 @@ abstract class ZulipBinding { /// This wraps [url_launcher.closeInAppWebView]. Future closeInAppWebView(); + /// Provides access to the current UTC date and time. + /// + /// Outside tests, this just calls [DateTime.timestamp]. + DateTime utcNow(); + /// Provides access to a new stopwatch. /// /// Outside tests, this just calls the [Stopwatch] constructor. @@ -383,6 +388,9 @@ class LiveZulipBinding extends ZulipBinding { return url_launcher.closeInAppWebView(); } + @override + DateTime utcNow() => DateTime.timestamp(); + @override Stopwatch stopwatch() => Stopwatch(); diff --git a/test/model/binding.dart b/test/model/binding.dart index 31f5738ddf..2c70b68826 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -241,6 +241,9 @@ class TestZulipBinding extends ZulipBinding { _closeInAppWebViewCallCount++; } + @override + DateTime utcNow() => clock.now().toUtc(); + @override Stopwatch stopwatch() => clock.stopwatch();