Skip to content

local echo (4.5/n): Prep for creating outbox messages on send #1509

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions lib/api/model/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down Expand Up @@ -604,6 +613,51 @@ 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 assumes that the topic is constructed from a string without
/// leading/trailing whitespace.
///
/// For a client that does not support empty topics, when FL>=334, the server
/// converts empty topics to `store.realmEmptyTopicDisplayName`; when FL>=370,
/// the server converts "(no topic)" to `store.realmEmptyTopicDisplayName`
/// 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);
}

// TODO(#1250): This assumes that the 'empty_topic_name' client capability
// is not declared. When we set 'empty_topic_name' to true,
// make this return an empty topic if the value matches "(no topic)"
// or realmEmptyTopicDisplayName.
if (_value == kNoTopicTopic || _value.isEmpty) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "(no topic)" case here looks like it started more recently than the other, at FL 370:
https://zulip.com/api/changelog

return TopicName(realmEmptyTopicDisplayName!);
}
return TopicName(_value);
}

TopicName.fromJson(this._value);

String toJson() => apiName;
Expand Down
9 changes: 0 additions & 9 deletions lib/api/route/messages.dart
Original file line number Diff line number Diff line change
Expand Up @@ -169,15 +169,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<SendMessageResult> sendMessage(
ApiConnection connection, {
Expand Down
8 changes: 8 additions & 0 deletions lib/model/binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ abstract class ZulipBinding {
/// This wraps [url_launcher.closeInAppWebView].
Future<void> 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.
Expand Down Expand Up @@ -383,6 +388,9 @@ class LiveZulipBinding extends ZulipBinding {
return url_launcher.closeInAppWebView();
}

@override
DateTime utcNow() => DateTime.timestamp();

@override
Stopwatch stopwatch() => Stopwatch();

Expand Down
11 changes: 6 additions & 5 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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].
Expand Down
25 changes: 25 additions & 0 deletions test/api/model/model_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();
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(emptyTopicDisplayName), 334);
doCheck(eg.t('(no topic)'), eg.t('(no topic)'), 334);
doCheck(eg.t(emptyTopicDisplayName), eg.t(emptyTopicDisplayName), 334);
doCheck(eg.t('other topic'), eg.t('other topic'), 334);

doCheck(eg.t('(no topic)'), eg.t(emptyTopicDisplayName), 370);
});
});

group('DmMessage', () {
Expand Down
14 changes: 14 additions & 0 deletions test/example_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
3 changes: 3 additions & 0 deletions test/model/binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,9 @@ class TestZulipBinding extends ZulipBinding {
_closeInAppWebViewCallCount++;
}

@override
DateTime utcNow() => clock.now().toUtc();

@override
Stopwatch stopwatch() => clock.stopwatch();

Expand Down
Loading