diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 837aab120f..f543144ce1 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -645,7 +645,7 @@ class UserTopicEvent extends Event { String get type => 'user_topic'; final int streamId; - final String topicName; + final TopicName topicName; final int lastUpdated; final UserTopicVisibilityPolicy visibilityPolicy; @@ -725,9 +725,9 @@ class UpdateMessageEvent extends Event { final PropagateMode? propagateMode; @JsonKey(name: 'orig_subject') - final String? origTopic; + final TopicName? origTopic; @JsonKey(name: 'subject') - final String? newTopic; + final TopicName? newTopic; // final List topicLinks; // TODO handle @@ -788,7 +788,7 @@ class DeleteMessageEvent extends Event { @MessageTypeConverter() final MessageType messageType; final int? streamId; - final String? topic; + final TopicName? topic; DeleteMessageEvent({ required super.id, @@ -924,7 +924,7 @@ class UpdateMessageFlagsMessageDetail { final bool? mentioned; final List? userIds; final int? streamId; - final String? topic; + final TopicName? topic; UpdateMessageFlagsMessageDetail({ required this.type, @@ -1002,7 +1002,7 @@ class TypingEvent extends Event { @JsonKey(name: 'recipients', fromJson: _recipientIdsFromJson) final List? recipientIds; final int? streamId; - final String? topic; + final TopicName? topic; TypingEvent({ required super.id, diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 8f11b8b655..9229bf9757 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -389,7 +389,7 @@ UserTopicEvent _$UserTopicEventFromJson(Map json) => UserTopicEvent( id: (json['id'] as num).toInt(), streamId: (json['stream_id'] as num).toInt(), - topicName: json['topic_name'] as String, + topicName: TopicName.fromJson(json['topic_name'] as String), lastUpdated: (json['last_updated'] as num).toInt(), visibilityPolicy: $enumDecode( _$UserTopicVisibilityPolicyEnumMap, json['visibility_policy']), @@ -430,8 +430,12 @@ UpdateMessageEvent _$UpdateMessageEventFromJson(Map json) => newStreamId: (json['new_stream_id'] as num?)?.toInt(), propagateMode: $enumDecodeNullable(_$PropagateModeEnumMap, json['propagate_mode']), - origTopic: json['orig_subject'] as String?, - newTopic: json['subject'] as String?, + origTopic: json['orig_subject'] == null + ? null + : TopicName.fromJson(json['orig_subject'] as String), + newTopic: json['subject'] == null + ? null + : TopicName.fromJson(json['subject'] as String), origContent: json['orig_content'] as String?, origRenderedContent: json['orig_rendered_content'] as String?, content: json['content'] as String?, @@ -487,7 +491,9 @@ DeleteMessageEvent _$DeleteMessageEventFromJson(Map json) => messageType: const MessageTypeConverter().fromJson(json['message_type'] as String), streamId: (json['stream_id'] as num?)?.toInt(), - topic: json['topic'] as String?, + topic: json['topic'] == null + ? null + : TopicName.fromJson(json['topic'] as String), ); Map _$DeleteMessageEventToJson(DeleteMessageEvent instance) => @@ -561,7 +567,9 @@ UpdateMessageFlagsMessageDetail _$UpdateMessageFlagsMessageDetailFromJson( ?.map((e) => (e as num).toInt()) .toList(), streamId: (json['stream_id'] as num?)?.toInt(), - topic: json['topic'] as String?, + topic: json['topic'] == null + ? null + : TopicName.fromJson(json['topic'] as String), ); Map _$UpdateMessageFlagsMessageDetailToJson( @@ -609,7 +617,9 @@ TypingEvent _$TypingEventFromJson(Map json) => TypingEvent( senderId: (TypingEvent._readSenderId(json, 'sender_id') as num).toInt(), recipientIds: TypingEvent._recipientIdsFromJson(json['recipients']), streamId: (json['stream_id'] as num?)?.toInt(), - topic: json['topic'] as String?, + topic: json['topic'] == null + ? null + : TopicName.fromJson(json['topic'] as String), ); Map _$TypingEventToJson(TypingEvent instance) => diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 6be0cadda9..efe7a79ebc 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -231,7 +231,7 @@ class UserSettings { @JsonSerializable(fieldRename: FieldRename.snake) class UserTopicItem { final int streamId; - final String topicName; + final TopicName topicName; final int lastUpdated; @JsonKey(unknownEnumValue: UserTopicVisibilityPolicy.unknown) final UserTopicVisibilityPolicy visibilityPolicy; @@ -310,7 +310,7 @@ class UnreadDmSnapshot { /// An item in [UnreadMessagesSnapshot.channels]. @JsonSerializable(fieldRename: FieldRename.snake) class UnreadChannelSnapshot { - final String topic; + final TopicName topic; final int streamId; final List unreadMessageIds; diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 75472c1d7f..445bb8fdb6 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -188,7 +188,7 @@ const _$EmojisetEnumMap = { UserTopicItem _$UserTopicItemFromJson(Map json) => UserTopicItem( streamId: (json['stream_id'] as num).toInt(), - topicName: json['topic_name'] as String, + topicName: TopicName.fromJson(json['topic_name'] as String), lastUpdated: (json['last_updated'] as num).toInt(), visibilityPolicy: $enumDecode( _$UserTopicVisibilityPolicyEnumMap, json['visibility_policy'], @@ -260,7 +260,7 @@ Map _$UnreadDmSnapshotToJson(UnreadDmSnapshot instance) => UnreadChannelSnapshot _$UnreadChannelSnapshotFromJson( Map json) => UnreadChannelSnapshot( - topic: json['topic'] as String, + topic: TopicName.fromJson(json['topic'] as String), streamId: (json['stream_id'] as num).toInt(), unreadMessageIds: (json['unread_message_ids'] as List) .map((e) => (e as num).toInt()) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index b36e1f2490..e55dc1d1f0 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -556,11 +556,11 @@ sealed class Message { final String senderFullName; final int senderId; final String senderRealmStr; - @JsonKey(name: 'subject') - String topic; + /// Poll data if "submessages" describe a poll, `null` otherwise. @JsonKey(name: 'submessages', readValue: _readPoll, fromJson: Poll.fromJson, toJson: Poll.toJson) Poll? poll; + final int timestamp; String get type; @@ -613,7 +613,6 @@ sealed class Message { required this.senderFullName, required this.senderId, required this.senderRealmStr, - required this.topic, required this.timestamp, required this.flags, required this.matchContent, @@ -656,6 +655,38 @@ enum MessageFlag { String toJson() => _$MessageFlagEnumMap[this]!; } +/// 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.) +// In particular an interpolation "foo > $topic" is a bug we'd like to catch. +// TODO(dart): Can we forbid using this extension type as a key in a Map? +// (The lack of "implements Object" arguably should do that, but doesn't.) +// Using as a Map key is almost certainly a bug because it won't case-fold; +// see for example #739, #980, #1205. +extension type const TopicName(String _value) { + /// The string this topic is identified by in the Zulip API. + /// + /// This should be used in constructing HTTP requests to the server, + /// but rarely for other purposes. See [displayName] and [canonicalize]. + String get apiName => _value; + + /// The string this topic is displayed as to the user in our UI. + /// + /// At the moment this always equals [apiName]. + /// In the future this will become null for the "general chat" topic (#1250), + /// so that UI code can identify when it needs to represent the topic + /// specially in the way prescribed for "general chat". + // TODO(#1250) carry out that plan + String get displayName => _value; + + /// The key to use for "same topic as" comparisons. + String canonicalize() => apiName.toLowerCase(); + + TopicName.fromJson(this._value); + + String toJson() => apiName; +} + @JsonSerializable(fieldRename: FieldRename.snake) class StreamMessage extends Message { @override @@ -667,8 +698,16 @@ class StreamMessage extends Message { // invalidated. @JsonKey(required: true, disallowNullValue: true) String? displayRecipient; + int streamId; + // The topic/subject is documented to be present on DMs too, just empty. + // We ignore it on DMs; if a future server introduces distinct topics in DMs, + // that will need new UI that we'll design then as part of that feature, + // and ignoring the topics seems as good a fallback behavior as any. + @JsonKey(name: 'subject') + TopicName topic; + StreamMessage({ required super.client, required super.content, @@ -683,13 +722,13 @@ class StreamMessage extends Message { required super.senderFullName, required super.senderId, required super.senderRealmStr, - required super.topic, required super.timestamp, required super.flags, required super.matchContent, required super.matchTopic, required this.displayRecipient, required this.streamId, + required this.topic, }); factory StreamMessage.fromJson(Map json) => @@ -786,7 +825,6 @@ class DmMessage extends Message { required super.senderFullName, required super.senderId, required super.senderRealmStr, - required super.topic, required super.timestamp, required super.flags, required super.matchContent, @@ -817,14 +855,14 @@ enum MessageEditState { /// The Zulip "resolved topics" feature is implemented by renaming the topic; /// but for purposes of [Message.editState], we want to ignore such renames. /// This method identifies topic moves that should be ignored in that context. - static bool topicMoveWasResolveOrUnresolve(String topic, String prevTopic) { - if (topic.startsWith(_resolvedTopicPrefix) - && topic.substring(_resolvedTopicPrefix.length) == prevTopic) { + static bool topicMoveWasResolveOrUnresolve(TopicName topic, TopicName prevTopic) { + if (topic.apiName.startsWith(_resolvedTopicPrefix) + && topic.apiName.substring(_resolvedTopicPrefix.length) == prevTopic.apiName) { return true; } - if (prevTopic.startsWith(_resolvedTopicPrefix) - && prevTopic.substring(_resolvedTopicPrefix.length) == topic) { + if (prevTopic.apiName.startsWith(_resolvedTopicPrefix) + && prevTopic.apiName.substring(_resolvedTopicPrefix.length) == topic.apiName) { return true; } @@ -857,8 +895,10 @@ enum MessageEditState { } // TODO(server-5) prev_subject was the old name of prev_topic on pre-5.0 servers - final prevTopic = (entry['prev_topic'] ?? entry['prev_subject']) as String?; - final topic = entry['topic'] as String?; + final prevTopicStr = (entry['prev_topic'] ?? entry['prev_subject']) as String?; + final prevTopic = prevTopicStr == null ? null : TopicName.fromJson(prevTopicStr); + final topicStr = entry['topic'] as String?; + final topic = topicStr == null ? null : TopicName.fromJson(topicStr); if (prevTopic != null) { // TODO(server-5) pre-5.0 servers do not have the 'topic' field if (topic == null) { diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 2c5adbe15c..c8434a0fa8 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -267,13 +267,13 @@ StreamMessage _$StreamMessageFromJson(Map json) { senderFullName: json['sender_full_name'] as String, senderId: (json['sender_id'] as num).toInt(), senderRealmStr: json['sender_realm_str'] as String, - topic: json['subject'] as String, timestamp: (json['timestamp'] as num).toInt(), flags: Message._flagsFromJson(json['flags']), matchContent: json['match_content'] as String?, matchTopic: json['match_subject'] as String?, displayRecipient: json['display_recipient'] as String?, streamId: (json['stream_id'] as num).toInt(), + topic: TopicName.fromJson(json['subject'] as String), )..poll = Poll.fromJson(Message._readPoll(json, 'submessages')); } @@ -292,7 +292,6 @@ Map _$StreamMessageToJson(StreamMessage instance) => 'sender_full_name': instance.senderFullName, 'sender_id': instance.senderId, 'sender_realm_str': instance.senderRealmStr, - 'subject': instance.topic, 'submessages': Poll.toJson(instance.poll), 'timestamp': instance.timestamp, 'flags': instance.flags, @@ -302,6 +301,7 @@ Map _$StreamMessageToJson(StreamMessage instance) => if (instance.displayRecipient case final value?) 'display_recipient': value, 'stream_id': instance.streamId, + 'subject': instance.topic, }; const _$MessageEditStateEnumMap = { @@ -338,7 +338,6 @@ DmMessage _$DmMessageFromJson(Map json) => DmMessage( senderFullName: json['sender_full_name'] as String, senderId: (json['sender_id'] as num).toInt(), senderRealmStr: json['sender_realm_str'] as String, - topic: json['subject'] as String, timestamp: (json['timestamp'] as num).toInt(), flags: Message._flagsFromJson(json['flags']), matchContent: json['match_content'] as String?, @@ -361,7 +360,6 @@ Map _$DmMessageToJson(DmMessage instance) => { 'sender_full_name': instance.senderFullName, 'sender_id': instance.senderId, 'sender_realm_str': instance.senderRealmStr, - 'subject': instance.topic, 'submessages': Poll.toJson(instance.poll), 'timestamp': instance.timestamp, 'flags': instance.flags, diff --git a/lib/api/model/narrow.dart b/lib/api/model/narrow.dart index 3ec08a4fe4..8082ac64ff 100644 --- a/lib/api/model/narrow.dart +++ b/lib/api/model/narrow.dart @@ -1,5 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; +import 'model.dart'; + part 'narrow.g.dart'; typedef ApiNarrow = List; @@ -26,7 +28,37 @@ ApiNarrow resolveDmElements(ApiNarrow narrow, int zulipFeatureLevel) { /// please add more as needed. sealed class ApiNarrowElement { String get operator; - Object get operand; + + /// The operand of this narrow filter. + /// + /// The base-class getter [ApiNarrowElement.operand] returns `dynamic`, + /// and its value should only be used for encoding as JSON, for use in a + /// request to the Zulip server. + /// + /// For any operations that depend more specifically on the operand's type, + /// do not use run-time type checks on the value of [operand]; instead, make + /// a run-time type check (e.g. with `switch`) on the [ApiNarrowElement] + /// itself, and use the [operand] getter of the specific subtype. + /// + /// That makes a difference because [ApiNarrowTopic.operand] has type + /// [TopicName]; at runtime a [TopicName] is indistinguishable from [String], + /// but an [ApiNarrowTopic] can still be distinguished from other subclasses. + // + // We can't just write [Object] here; if we do, the compiler rejects the + // override in ApiNarrowTopic because TopicName can't be assigned to Object. + // The reason that could be bad is that a caller of [ApiNarrowElement.operand] + // could take the result and call Object members on it, like toString, even + // though TopicName doesn't declare those members. + // + // In this case that's fine because the only plausible thing to do with + // a generic [ApiNarrowElement.operand] is to encode it as JSON anyway, + // which behaves just fine on TopicName. + // + // ... Even if it weren't fine, in the case of Object this protection is + // thoroughly undermined already: code that has a TopicName can call Object + // members on it directly. See comments at [TopicName]. + dynamic get operand; // see justification for `dynamic` above + final bool negated; ApiNarrowElement({this.negated = false}); @@ -54,12 +86,12 @@ class ApiNarrowStream extends ApiNarrowElement { class ApiNarrowTopic extends ApiNarrowElement { @override String get operator => 'topic'; - @override final String operand; + @override final TopicName operand; ApiNarrowTopic(this.operand, {super.negated}); factory ApiNarrowTopic.fromJson(Map json) => ApiNarrowTopic( - json['operand'] as String, + TopicName.fromJson(json['operand'] as String), negated: json['negated'] as bool? ?? false, ); } diff --git a/lib/api/notifications.dart b/lib/api/notifications.dart index 2337c0456e..6d028aa267 100644 --- a/lib/api/notifications.dart +++ b/lib/api/notifications.dart @@ -1,6 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; +import 'model/model.dart'; + part 'notifications.g.dart'; /// Parsed version of an FCM message, of any type. @@ -187,7 +189,7 @@ class FcmMessageChannelRecipient extends FcmMessageRecipient { @JsonKey(name: 'stream') final String? streamName; - final String topic; + final TopicName topic; FcmMessageChannelRecipient({required this.streamId, required this.streamName, required this.topic}); diff --git a/lib/api/notifications.g.dart b/lib/api/notifications.g.dart index 4a9752a9af..b56412f1fd 100644 --- a/lib/api/notifications.g.dart +++ b/lib/api/notifications.g.dart @@ -47,7 +47,7 @@ FcmMessageChannelRecipient _$FcmMessageChannelRecipientFromJson( FcmMessageChannelRecipient( streamId: const _IntConverter().fromJson(json['stream_id'] as String), streamName: json['stream'] as String?, - topic: json['topic'] as String, + topic: TopicName.fromJson(json['topic'] as String), ); RemoveFcmMessage _$RemoveFcmMessageFromJson(Map json) => diff --git a/lib/api/route/channels.dart b/lib/api/route/channels.dart index 00832f7fd0..bfa46f5ab8 100644 --- a/lib/api/route/channels.dart +++ b/lib/api/route/channels.dart @@ -28,7 +28,7 @@ class GetStreamTopicsResult { @JsonSerializable(fieldRename: FieldRename.snake) class GetStreamTopicsEntry { final int maxId; - final String name; + final TopicName name; GetStreamTopicsEntry({ required this.maxId, @@ -46,7 +46,7 @@ class GetStreamTopicsEntry { // TODO(server-7): remove this and just use updateUserTopic Future updateUserTopicCompat(ApiConnection connection, { required int streamId, - required String topic, + required TopicName topic, required UserTopicVisibilityPolicy visibilityPolicy, }) { final useLegacyApi = connection.zulipFeatureLevel! < 170; @@ -59,7 +59,7 @@ Future updateUserTopicCompat(ApiConnection connection, { // https://zulip.com/api/mute-topic return connection.patch('muteTopic', (_) {}, 'users/me/subscriptions/muted_topics', { 'stream_id': streamId, - 'topic': RawParameter(topic), + 'topic': RawParameter(topic.apiName), 'op': RawParameter(op), }); } else { @@ -76,14 +76,14 @@ Future updateUserTopicCompat(ApiConnection connection, { // TODO(server-7) remove FL 170+ mention in doc, and the related `assert` Future updateUserTopic(ApiConnection connection, { required int streamId, - required String topic, + required TopicName topic, required UserTopicVisibilityPolicy visibilityPolicy, }) { assert(visibilityPolicy != UserTopicVisibilityPolicy.unknown); assert(connection.zulipFeatureLevel! >= 170); return connection.post('updateUserTopic', (_) {}, 'user_topics', { 'stream_id': streamId, - 'topic': RawParameter(topic), + 'topic': RawParameter(topic.apiName), 'visibility_policy': visibilityPolicy, }); } diff --git a/lib/api/route/channels.g.dart b/lib/api/route/channels.g.dart index 561b43f005..4a5f7009c3 100644 --- a/lib/api/route/channels.g.dart +++ b/lib/api/route/channels.g.dart @@ -26,7 +26,7 @@ GetStreamTopicsEntry _$GetStreamTopicsEntryFromJson( Map json) => GetStreamTopicsEntry( maxId: (json['max_id'] as num).toInt(), - name: json['name'] as String, + name: TopicName.fromJson(json['name'] as String), ); Map _$GetStreamTopicsEntryToJson( diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 26fd80f3ac..ea7421733e 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -186,7 +186,7 @@ Future sendMessage( StreamDestination() => { 'type': RawParameter('stream'), 'to': destination.streamId, - 'topic': RawParameter(destination.topic), + 'topic': RawParameter(destination.topic.apiName), }, DmDestination() => { 'type': supportsTypeDirect ? RawParameter('direct') : RawParameter('private'), @@ -231,7 +231,7 @@ class StreamDestination extends MessageDestination { const StreamDestination(this.streamId, this.topic); final int streamId; - final String topic; + final TopicName topic; } /// A DM conversation, for specifying to [sendMessage]. @@ -449,10 +449,10 @@ Future markStreamAsRead(ApiConnection connection, { // TODO(server-6): Remove as deprecated by updateMessageFlagsForNarrow Future markTopicAsRead(ApiConnection connection, { required int streamId, - required String topicName, + required TopicName topicName, }) { return connection.post('markTopicAsRead', (_) {}, 'mark_topic_as_read', { 'stream_id': streamId, - 'topic_name': RawParameter(topicName), + 'topic_name': RawParameter(topicName.apiName), }); } diff --git a/lib/api/route/typing.dart b/lib/api/route/typing.dart index b9f8503172..8e2e5dae6b 100644 --- a/lib/api/route/typing.dart +++ b/lib/api/route/typing.dart @@ -17,7 +17,7 @@ Future setTypingStatus(ApiConnection connection, { 'type': RawParameter(supportsTypeChannel ? 'channel' : 'stream'), if (supportsStreamId) 'stream_id': destination.streamId else 'to': [destination.streamId], - 'topic': RawParameter(destination.topic), + 'topic': RawParameter(destination.topic.apiName), }); case DmDestination(): final supportsDirect = connection.zulipFeatureLevel! >= 174; // TODO(server-7) diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 5b66a6d52a..cd651bdc65 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -485,7 +485,7 @@ class MentionAutocompleteView extends AutocompleteView _topics = []; + Iterable _topics = []; bool _isFetching = false; /// Fetches topics of the current stream narrow, expected to fetch @@ -843,7 +843,7 @@ class TopicAutocompleteView extends AutocompleteView> get debugTopicVisibility; + Map> get debugTopicVisibility; /// Whether this topic should appear when already focusing on its stream. /// @@ -63,7 +63,7 @@ mixin ChannelStore { /// /// For UI contexts that are not specific to a particular stream, see /// [isTopicVisible]. - bool isTopicVisibleInStream(int streamId, String topic) { + bool isTopicVisibleInStream(int streamId, TopicName topic) { return _isTopicVisibleInStream(topicVisibilityPolicy(streamId, topic)); } @@ -100,7 +100,7 @@ mixin ChannelStore { /// /// For UI contexts that are specific to a particular stream, see /// [isTopicVisibleInStream]. - bool isTopicVisible(int streamId, String topic) { + bool isTopicVisible(int streamId, TopicName topic) { return _isTopicVisible(streamId, topicVisibilityPolicy(streamId, topic)); } @@ -171,7 +171,7 @@ class ChannelStoreImpl with ChannelStore { streams.putIfAbsent(stream.streamId, () => stream); } - final topicVisibility = >{}; + final topicVisibility = >{}; for (final item in initialSnapshot.userTopics ?? const []) { if (_warnInvalidVisibilityPolicy(item.visibilityPolicy)) { // Not a value we expect. Keep it out of our data structures. // TODO(log) @@ -204,12 +204,12 @@ class ChannelStoreImpl with ChannelStore { final Map subscriptions; @override - Map> get debugTopicVisibility => topicVisibility; + Map> get debugTopicVisibility => topicVisibility; - final Map> topicVisibility; + final Map> topicVisibility; @override - UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, String topic) { + UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) { return topicVisibility[streamId]?[topic] ?? UserTopicVisibilityPolicy.none; } diff --git a/lib/model/internal_link.dart b/lib/model/internal_link.dart index a5dd23c2a2..db11115cf3 100644 --- a/lib/model/internal_link.dart +++ b/lib/model/internal_link.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:json_annotation/json_annotation.dart'; +import '../api/model/model.dart'; import '../api/model/narrow.dart'; import 'narrow.dart'; import 'store.dart'; @@ -76,7 +77,7 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { final slugifiedName = _encodeHashComponent(name.replaceAll(' ', '-')); fragment.write('$streamId-$slugifiedName'); case ApiNarrowTopic(): - fragment.write(_encodeHashComponent(element.operand)); + fragment.write(_encodeHashComponent(element.operand.apiName)); case ApiNarrowDmModern(): final suffix = element.operand.length >= 3 ? 'group' : 'dm'; fragment.write('${element.operand.join(',')}-$suffix'); @@ -178,7 +179,7 @@ Narrow? _interpretNarrowSegments(List segments, PerAccountStore store) { if (topicElement != null) return null; final String? topic = decodeHashComponent(operand); if (topic == null) return null; - topicElement = ApiNarrowTopic(topic, negated: negated); + topicElement = ApiNarrowTopic(TopicName(topic), negated: negated); case _NarrowOperator.dm: case _NarrowOperator.pmWith: diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index ecfb16c2b9..670785ac4e 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -352,7 +352,7 @@ mixin _MessageSequence { bool haveSameRecipient(Message prevMessage, Message message) { if (prevMessage is StreamMessage && message is StreamMessage) { if (prevMessage.streamId != message.streamId) return false; - if (prevMessage.topic.toLowerCase() != message.topic.toLowerCase()) return false; + if (prevMessage.topic.canonicalize() != message.topic.canonicalize()) return false; } else if (prevMessage is DmMessage && message is DmMessage) { if (!_equalIdSequences(prevMessage.allRecipientIds, message.allRecipientIds)) { return false; @@ -686,8 +686,8 @@ class MessageListView with ChangeNotifier, _MessageSequence { void messagesMoved({ required int origStreamId, required int newStreamId, - required String origTopic, - required String newTopic, + required TopicName origTopic, + required TopicName newTopic, required List messageIds, required PropagateMode propagateMode, }) { diff --git a/lib/model/narrow.dart b/lib/model/narrow.dart index 1553750849..9e29808ceb 100644 --- a/lib/model/narrow.dart +++ b/lib/model/narrow.dart @@ -99,7 +99,7 @@ class TopicNarrow extends Narrow implements SendableNarrow { } final int streamId; - final String topic; + final TopicName topic; @override bool containsMessage(Message message) { @@ -114,7 +114,7 @@ class TopicNarrow extends Narrow implements SendableNarrow { StreamDestination get destination => StreamDestination(streamId, topic); @override - String toString() => 'TopicNarrow($streamId, $topic)'; + String toString() => 'TopicNarrow($streamId, ${topic.displayName})'; @override bool operator ==(Object other) { diff --git a/lib/model/recent_senders.dart b/lib/model/recent_senders.dart index d075d05eee..a5c4bca778 100644 --- a/lib/model/recent_senders.dart +++ b/lib/model/recent_senders.dart @@ -16,7 +16,7 @@ class RecentSenders { // topicSenders[streamId][topic][senderId] = MessageIdTracker @visibleForTesting - final Map>> topicSenders = {}; + final Map>> topicSenders = {}; /// The latest message the given user sent to the given stream, /// or null if no such message is known. @@ -29,7 +29,7 @@ class RecentSenders { /// or null if no such message is known. int? latestMessageIdOfSenderInTopic({ required int streamId, - required String topic, + required TopicName topic, required int senderId, }) => topicSenders[streamId]?[topic]?[senderId]?.maxId; @@ -38,7 +38,7 @@ class RecentSenders { /// The messages must be sorted by [Message.id] ascending. void handleMessages(List messages) { final messagesByUserInStream = <(int, int), QueueList>{}; - final messagesByUserInTopic = <(int, String, int), QueueList>{}; + final messagesByUserInTopic = <(int, TopicName, int), QueueList>{}; for (final message in messages) { if (message is! StreamMessage) continue; final StreamMessage(:streamId, :topic, :senderId, id: int messageId) = message; diff --git a/lib/model/store.dart b/lib/model/store.dart index 58a8a70615..56aef85172 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -466,10 +466,10 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess @override Map get subscriptions => _channels.subscriptions; @override - UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, String topic) => + UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) => _channels.topicVisibilityPolicy(streamId, topic); @override - Map> get debugTopicVisibility => + Map> get debugTopicVisibility => _channels.debugTopicVisibility; final ChannelStoreImpl _channels; diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart index 4f1ddc603c..1fcea3f83c 100644 --- a/lib/model/unreads.dart +++ b/lib/model/unreads.dart @@ -40,7 +40,7 @@ class Unreads extends ChangeNotifier { required int selfUserId, required ChannelStore channelStore, }) { - final streams = >>{}; + final streams = >>{}; final dms = >{}; final mentions = Set.of(initial.mentions); @@ -86,7 +86,7 @@ class Unreads extends ChangeNotifier { // int count; /// Unread stream messages, as: stream ID → topic → message IDs (sorted). - final Map>> streams; + final Map>> streams; /// Unread DM messages, as: DM narrow → message IDs (sorted). final Map> dms; @@ -185,7 +185,7 @@ class Unreads extends ChangeNotifier { return c; } - int countInTopicNarrow(int streamId, String topic) { + int countInTopicNarrow(int streamId, TopicName topic) { final topics = streams[streamId]; return topics?[topic]?.length ?? 0; } @@ -365,7 +365,7 @@ class Unreads extends ChangeNotifier { _slowRemoveAllInDms(messageIdsSet); } case UpdateMessageFlagsRemoveEvent(): - final newlyUnreadInStreams = >>{}; + final newlyUnreadInStreams = >>{}; final newlyUnreadInDms = >{}; for (final messageId in event.messages) { final detail = event.messageDetails![messageId]; @@ -449,12 +449,12 @@ class Unreads extends ChangeNotifier { ); } - void _addLastInStreamTopic(int messageId, int streamId, String topic) { + void _addLastInStreamTopic(int messageId, int streamId, TopicName topic) { ((streams[streamId] ??= {})[topic] ??= QueueList()).addLast(messageId); } // [messageIds] must be sorted ascending and without duplicates. - void _addAllInStreamTopic(QueueList messageIds, int streamId, String topic) { + void _addAllInStreamTopic(QueueList messageIds, int streamId, TopicName topic) { final topics = streams[streamId] ??= {}; topics.update(topic, ifAbsent: () => messageIds, @@ -469,7 +469,7 @@ class Unreads extends ChangeNotifier { void _slowRemoveAllInStreams(Set idsToRemove) { final newlyEmptyStreams = []; for (final MapEntry(key: streamId, value: topics) in streams.entries) { - final newlyEmptyTopics = []; + final newlyEmptyTopics = []; for (final MapEntry(key: topic, value: messageIds) in topics.entries) { messageIds.removeWhere((id) => idsToRemove.contains(id)); if (messageIds.isEmpty) { @@ -488,7 +488,7 @@ class Unreads extends ChangeNotifier { } } - void _removeAllInStreamTopic(Set incomingMessageIds, int streamId, String topic) { + void _removeAllInStreamTopic(Set incomingMessageIds, int streamId, TopicName topic) { final topics = streams[streamId]; if (topics == null) return; final messageIds = topics[topic]; diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 46ea95486a..3a8a8427dd 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -6,6 +6,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart' hide Notification; +import '../api/model/model.dart'; import '../api/notifications.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../host/android_notifications.dart'; @@ -263,9 +264,9 @@ class NotificationDisplayManager { // the first. messagingStyle.conversationTitle = switch (data.recipient) { FcmMessageChannelRecipient(:var streamName?, :var topic) => - '#$streamName > $topic', + '#$streamName > ${topic.displayName}', FcmMessageChannelRecipient(:var topic) => - '#(unknown channel) > $topic', // TODO get stream name from data + '#(unknown channel) > ${topic.displayName}', // TODO get stream name from data FcmMessageDmRecipient(:var allRecipientIds) when allRecipientIds.length > 2 => zulipLocalizations.notifGroupDmConversationLabel( data.senderFullName, allRecipientIds.length - 2), // TODO use others' names, from data @@ -538,8 +539,8 @@ class NotificationOpenPayload { case 'topic': final channelIdStr = url.queryParameters['channel_id']!; final channelId = int.parse(channelIdStr, radix: 10); - final topic = url.queryParameters['topic']!; - narrow = TopicNarrow(channelId, topic); + final topicStr = url.queryParameters['topic']!; + narrow = TopicNarrow(channelId, TopicName(topicStr)); case 'dm': final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!; final allRecipientIds = allRecipientIdsStr.split(',') @@ -572,7 +573,7 @@ class NotificationOpenPayload { TopicNarrow(streamId: var channelId, :var topic) => { 'narrow_type': 'topic', 'channel_id': channelId.toString(), - 'topic': topic, + 'topic': topic.apiName, }, DmNarrow(:var allRecipientIds) => { 'narrow_type': 'dm', diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index fca61d4810..b89e9fbdb3 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -151,7 +151,7 @@ class ActionSheetCancelButton extends StatelessWidget { /// Show a sheet of actions you can take on a topic. void showTopicActionSheet(BuildContext context, { required int channelId, - required String topic, + required TopicName topic, }) { final store = PerAccountStoreWidget.of(context); final subscription = store.subscriptions[channelId]; @@ -633,6 +633,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { @override void onPressed() async { final zulipLocalizations = ZulipLocalizations.of(pageContext); + final message = this.message; var composeBoxController = findMessageListPage().composeBoxController; // The compose box doesn't null out its controller; it's either always null @@ -644,7 +645,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { && composeBoxController.topic.textNormalized == kNoTopicTopic && message is StreamMessage ) { - composeBoxController.topic.value = TextEditingValue(text: message.topic); + composeBoxController.topic.setTopic(message.topic); } // This inserts a "[Quoting…]" placeholder into the content input, diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index a1e5289b01..ba921e7f08 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -321,13 +321,8 @@ class TopicAutocomplete extends AutocompleteField { TopicValidationError.tooLong, ]; } + + void setTopic(TopicName newTopic) { + value = TextEditingValue(text: newTopic.displayName); + } } enum ContentValidationError { @@ -486,7 +490,7 @@ class _StreamContentInputState extends State<_StreamContentInput> { ?? zulipLocalizations.composeBoxUnknownChannelName; return _ContentInput( narrow: widget.narrow, - destination: TopicNarrow(widget.narrow.streamId, _topicTextNormalized), + destination: TopicNarrow(widget.narrow.streamId, TopicName(_topicTextNormalized)), controller: widget.controller, hintText: zulipLocalizations.composeBoxChannelContentHint(streamName, _topicTextNormalized)); } @@ -546,7 +550,8 @@ class _FixedDestinationContentInput extends StatelessWidget { final store = PerAccountStoreWidget.of(context); final streamName = store.streams[streamId]?.name ?? zulipLocalizations.composeBoxUnknownChannelName; - return zulipLocalizations.composeBoxChannelContentHint(streamName, topic); + return zulipLocalizations.composeBoxChannelContentHint( + streamName, topic.displayName); case DmNarrow(otherRecipientIds: []): // The self-1:1 thread. return zulipLocalizations.composeBoxSelfDmContentHint; @@ -1151,7 +1156,7 @@ class _StreamComposeBoxBody extends _ComposeBoxBody { @override Widget buildSendButton() => _SendButton( controller: controller, getDestination: () => StreamDestination( - narrow.streamId, controller.topic.textNormalized), + narrow.streamId, TopicName(controller.topic.textNormalized)), ); } diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index 12ec8751a1..04d5246195 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -132,7 +132,7 @@ class _InboxPageState extends State with PerAccountStoreAwareStat }); for (final MapEntry(key: streamId, value: topics) in sortedUnreadStreams) { - final topicItems = <(String, int, bool, int)>[]; + final topicItems = <(TopicName, int, bool, int)>[]; int countInStream = 0; bool streamHasMention = false; for (final MapEntry(key: topic, value: messageIds) in topics.entries) { @@ -192,7 +192,7 @@ class _StreamSectionData extends _InboxSectionData { final int streamId; final int count; final bool hasMention; - final List<(String, int, bool, int)> items; + final List<(TopicName, int, bool, int)> items; const _StreamSectionData(this.streamId, this.count, this.hasMention, this.items); } @@ -487,7 +487,7 @@ class _TopicItem extends StatelessWidget { }); final int streamId; - final String topic; + final TopicName topic; final int count; final bool hasMention; @@ -524,7 +524,7 @@ class _TopicItem extends StatelessWidget { ), maxLines: 2, overflow: TextOverflow.ellipsis, - topic))), + topic.displayName))), const SizedBox(width: 12), if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), // TODO(design) copies the "@" marker color; is there a better color? diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 8c32a12115..f5416e3ccf 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -334,7 +334,7 @@ class MessageListAppBarTitle extends StatelessWidget { Widget _buildTopicRow(BuildContext context, { required ZulipStream? stream, - required String topic, + required TopicName topic, }) { final store = PerAccountStoreWidget.of(context); final designVariables = DesignVariables.of(context); @@ -344,7 +344,7 @@ class MessageListAppBarTitle extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - Flexible(child: Text(topic, style: const TextStyle( + Flexible(child: Text(topic.displayName, style: const TextStyle( fontSize: 13, ).merge(weightVariableTextStyle(context)))), if (icon != null) @@ -1091,7 +1091,7 @@ class StreamMessageRecipientHeader extends StatelessWidget { child: Row( children: [ Flexible( - child: Text(topic, + child: Text(topic.displayName, // TODO: Give a way to see the whole topic (maybe a // long-press interaction?) overflow: TextOverflow.ellipsis, diff --git a/test/api/model/events_checks.dart b/test/api/model/events_checks.dart index f95c6b4362..c1fa0a117f 100644 --- a/test/api/model/events_checks.dart +++ b/test/api/model/events_checks.dart @@ -51,8 +51,8 @@ extension UpdateMessageEventChecks on Subject { Subject get origStreamId => has((e) => e.origStreamId, 'origStreamId'); Subject get newStreamId => has((e) => e.newStreamId, 'newStreamId'); Subject get propagateMode => has((e) => e.propagateMode, 'propagateMode'); - Subject get origTopic => has((e) => e.origTopic, 'origTopic'); - Subject get newTopic => has((e) => e.newTopic, 'newTopic'); + Subject get origTopic => has((e) => e.origTopic, 'origTopic'); + Subject get newTopic => has((e) => e.newTopic, 'newTopic'); Subject get origContent => has((e) => e.origContent, 'origContent'); Subject get origRenderedContent => has((e) => e.origRenderedContent, 'origRenderedContent'); Subject get content => has((e) => e.content, 'content'); @@ -77,7 +77,7 @@ extension TypingEventChecks on Subject { Subject get senderId => has((e) => e.senderId, 'senderId'); Subject?> get recipientIds => has((e) => e.recipientIds, 'recipientIds'); Subject get streamId => has((e) => e.streamId, 'streamId'); - Subject get topic => has((e) => e.topic, 'topic'); + Subject get topic => has((e) => e.topic, 'topic'); } extension HeartbeatEventChecks on Subject { diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart index 0f1f52f843..51b36350cc 100644 --- a/test/api/model/events_test.dart +++ b/test/api/model/events_test.dart @@ -116,8 +116,8 @@ void main() { 'orig_subject': 'foo', 'subject': 'bar', }) as UpdateMessageEvent) - ..origTopic.equals('foo') - ..newTopic.equals('bar'); + ..origTopic.equals(const TopicName('foo')) + ..newTopic.equals(const TopicName('bar')); }); }); diff --git a/test/api/model/initial_snapshot_test.dart b/test/api/model/initial_snapshot_test.dart index 7b441de779..3228602fff 100644 --- a/test/api/model/initial_snapshot_test.dart +++ b/test/api/model/initial_snapshot_test.dart @@ -1,6 +1,7 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; +import 'package:zulip/api/model/model.dart'; import '../../stdlib_checks.dart'; @@ -20,7 +21,7 @@ void main() { check(snapshot.channels).single.jsonEquals( UnreadChannelSnapshot( - topic: 'topic name', streamId: 1, + topic: const TopicName('topic name'), streamId: 1, unreadMessageIds: [1, 2])); }); diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index 6a3eee8c8c..8b39b1ad57 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -38,7 +38,6 @@ extension MessageChecks on Subject { Subject get senderFullName => has((e) => e.senderFullName, 'senderFullName'); Subject get senderId => has((e) => e.senderId, 'senderId'); Subject get senderRealmStr => has((e) => e.senderRealmStr, 'senderRealmStr'); - Subject get topic => has((e) => e.topic, 'topic'); Subject get poll => has((e) => e.poll, 'poll'); Subject get timestamp => has((e) => e.timestamp, 'timestamp'); Subject get type => has((e) => e.type, 'type'); @@ -47,8 +46,14 @@ extension MessageChecks on Subject { Subject get matchTopic => has((e) => e.matchTopic, 'matchTopic'); } +extension TopicNameChecks on Subject { + Subject get apiName => has((x) => x.apiName, 'apiName'); + Subject get displayName => has((x) => x.displayName, 'displayName'); +} + extension StreamMessageChecks on Subject { Subject get displayRecipient => has((e) => e.displayRecipient, 'displayRecipient'); + Subject get topic => has((e) => e.topic, 'topic'); } extension ReactionsChecks on Subject { diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index b3636b5ed6..5013b9c6c1 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -89,7 +89,8 @@ void main() { check(baseStreamJson()).not((it) => it.containsKey('topic')); check(Message.fromJson(baseStreamJson() ..['subject'] = 'hello' - )).topic.equals('hello'); + )).isA() + .topic.equals(const TopicName('hello')); }); test('match_subject -> matchTopic', () { diff --git a/test/api/notifications_test.dart b/test/api/notifications_test.dart index fd4465f33a..53240d7b09 100644 --- a/test/api/notifications_test.dart +++ b/test/api/notifications_test.dart @@ -1,5 +1,6 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/notifications.dart'; import '../stdlib_checks.dart'; @@ -81,7 +82,7 @@ void main() { ..recipient.isA().which((it) => it ..streamId.equals(42) ..streamName.equals(streamJson['stream']!) - ..topic.equals(streamJson['topic']!)) + ..topic.jsonEquals(streamJson['topic']!)) ..content.equals(streamJson['content']!) ..time.equals(1546300800); @@ -279,7 +280,7 @@ extension MessageFcmMessageChecks on Subject { extension FcmMessageChannelRecipientChecks on Subject { Subject get streamId => has((x) => x.streamId, 'streamId'); Subject get streamName => has((x) => x.streamName, 'streamName'); - Subject get topic => has((x) => x.topic, 'topic'); + Subject get topic => has((x) => x.topic, 'topic'); } extension FcmMessageDmRecipientChecks on Subject { diff --git a/test/api/route/channels_test.dart b/test/api/route/channels_test.dart index d86c9447fe..011dc508c5 100644 --- a/test/api/route/channels_test.dart +++ b/test/api/route/channels_test.dart @@ -12,7 +12,7 @@ void main() { return FakeApiConnection.with_((connection) async { connection.prepare(json: {}); await updateUserTopic(connection, - streamId: 1, topic: 'topic', + streamId: 1, topic: const TopicName('topic'), visibilityPolicy: UserTopicVisibilityPolicy.followed); check(connection.takeRequests()).single.isA() ..method.equals('POST') @@ -28,7 +28,7 @@ void main() { test('updateUserTopic only accepts valid visibility policy', () { return FakeApiConnection.with_((connection) async { check(() => updateUserTopic(connection, - streamId: 1, topic: 'topic', + streamId: 1, topic: const TopicName('topic'), visibilityPolicy: UserTopicVisibilityPolicy.unknown), ).throws(); }); @@ -38,7 +38,7 @@ void main() { return FakeApiConnection.with_((connection) async { connection.prepare(json: {}); await updateUserTopicCompat(connection, - streamId: 1, topic: 'topic', + streamId: 1, topic: const TopicName('topic'), visibilityPolicy: UserTopicVisibilityPolicy.followed); check(connection.takeRequests()).single.isA() ..method.equals('POST') @@ -55,7 +55,7 @@ void main() { test('updateUserTopic throws AssertionError when FL < 170', () { return FakeApiConnection.with_(zulipFeatureLevel: 169, (connection) async { check(() => updateUserTopic(connection, - streamId: 1, topic: 'topic', + streamId: 1, topic: const TopicName('topic'), visibilityPolicy: UserTopicVisibilityPolicy.muted), ).throws(); }); @@ -64,7 +64,7 @@ void main() { test('updateUserTopicCompat throws UnsupportedError on unsupported policy', () { return FakeApiConnection.with_(zulipFeatureLevel: 169, (connection) async { check(() => updateUserTopicCompat(connection, - streamId: 1, topic: 'topic', + streamId: 1, topic: const TopicName('topic'), visibilityPolicy: UserTopicVisibilityPolicy.followed), ).throws(); }); @@ -74,7 +74,7 @@ void main() { return FakeApiConnection.with_(zulipFeatureLevel: 169, (connection) async { connection.prepare(json: {}); await updateUserTopicCompat(connection, - streamId: 1, topic: 'topic', + streamId: 1, topic: const TopicName('topic'), visibilityPolicy: UserTopicVisibilityPolicy.none); check(connection.takeRequests()).single.isA() ..method.equals('PATCH') @@ -91,7 +91,7 @@ void main() { return FakeApiConnection.with_(zulipFeatureLevel: 169, (connection) async { connection.prepare(json: {}); await updateUserTopicCompat(connection, - streamId: 1, topic: 'topic', + streamId: 1, topic: const TopicName('topic'), visibilityPolicy: UserTopicVisibilityPolicy.muted); check(connection.takeRequests()).single.isA() ..method.equals('PATCH') diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index c634c87ff7..eb8fbdac29 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -184,7 +184,7 @@ void main() { checkNarrow(const ChannelNarrow(12).apiEncode(), jsonEncode([ {'operator': 'stream', 'operand': 12}, ])); - checkNarrow(const TopicNarrow(12, 'stuff').apiEncode(), jsonEncode([ + checkNarrow(eg.topicNarrow(12, 'stuff').apiEncode(), jsonEncode([ {'operator': 'stream', 'operand': 12}, {'operator': 'topic', 'operand': 'stuff'}, ])); @@ -328,7 +328,7 @@ void main() { test('smoke', () { return FakeApiConnection.with_((connection) async { await checkSendMessage(connection, - destination: const StreamDestination(streamId, topic), content: content, + destination: StreamDestination(streamId, eg.t(topic)), content: content, queueId: 'abc:123', localId: '456', readBySender: true, @@ -347,7 +347,7 @@ void main() { test('to stream', () { return FakeApiConnection.with_((connection) async { await checkSendMessage(connection, - destination: const StreamDestination(streamId, topic), content: content, + destination: StreamDestination(streamId, eg.t(topic)), content: content, readBySender: true, expectedBodyFields: { 'type': 'stream', @@ -391,7 +391,7 @@ void main() { test('when readBySender is null, sends a User-Agent we know the server will recognize', () { return FakeApiConnection.with_((connection) async { await checkSendMessage(connection, - destination: const StreamDestination(streamId, topic), content: content, + destination: StreamDestination(streamId, eg.t(topic)), content: content, readBySender: null, expectedBodyFields: { 'type': 'stream', @@ -406,7 +406,7 @@ void main() { test('legacy: when server does not support readBySender, sends a User-Agent the server will recognize', () { return FakeApiConnection.with_(zulipFeatureLevel: 235, (connection) async { await checkSendMessage(connection, - destination: const StreamDestination(streamId, topic), content: content, + destination: StreamDestination(streamId, eg.t(topic)), content: content, readBySender: true, expectedBodyFields: { 'type': 'stream', @@ -743,7 +743,7 @@ void main() { }) async { connection.prepare(json: {}); await markTopicAsRead(connection, - streamId: streamId, topicName: topicName); + streamId: streamId, topicName: eg.t(topicName)); check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/mark_topic_as_read') diff --git a/test/api/route/typing_test.dart b/test/api/route/typing_test.dart index 0d933e6f54..551f1a5a4e 100644 --- a/test/api/route/typing_test.dart +++ b/test/api/route/typing_test.dart @@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; import 'package:checks/checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/typing.dart'; @@ -31,7 +32,7 @@ void main() { Future checkSetTypingStatusForTopic(TypingOp op, String expectedOp) { return FakeApiConnection.with_((connection) { return checkSetTypingStatus(connection, op, - destination: const StreamDestination(streamId, topic), + destination: const StreamDestination(streamId, TopicName(topic)), expectedBodyFields: { 'op': expectedOp, 'type': 'channel', @@ -64,7 +65,7 @@ void main() { test('legacy: use "stream" instead of "channel"', () { return FakeApiConnection.with_(zulipFeatureLevel: 247, (connection) { return checkSetTypingStatus(connection, TypingOp.start, - destination: const StreamDestination(streamId, topic), + destination: const StreamDestination(streamId, TopicName(topic)), expectedBodyFields: { 'op': 'start', 'type': 'stream', @@ -77,7 +78,7 @@ void main() { test('legacy: use to=[streamId] instead of stream_id=streamId', () { return FakeApiConnection.with_(zulipFeatureLevel: 214, (connection) { return checkSetTypingStatus(connection, TypingOp.start, - destination: const StreamDestination(streamId, topic), + destination: const StreamDestination(streamId, TopicName(topic)), expectedBodyFields: { 'op': 'start', 'type': 'stream', diff --git a/test/example_data.dart b/test/example_data.dart index d071307ec4..0e45321632 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -241,7 +241,8 @@ const _stream = stream; GetStreamTopicsEntry getStreamTopicsEntry({int? maxId, String? name}) { maxId ??= 123; - return GetStreamTopicsEntry(maxId: maxId, name: name ?? 'Test Topic #$maxId'); + return GetStreamTopicsEntry(maxId: maxId, + name: TopicName(name ?? 'Test Topic #$maxId')); } /// Construct an example subscription from a stream. @@ -283,11 +284,20 @@ Subscription subscription( ); } +/// The [TopicName] constructor, but shorter. +/// +/// Useful in test code that mentions a lot of topics in a compact format. +TopicName t(String apiName) => TopicName(apiName); + +TopicNarrow topicNarrow(int channelId, String topicName) { + return TopicNarrow(channelId, TopicName(topicName)); +} + UserTopicItem userTopicItem( ZulipStream stream, String topic, UserTopicVisibilityPolicy policy) { return UserTopicItem( streamId: stream.streamId, - topicName: topic, + topicName: TopicName(topic), lastUpdated: 1234567890, visibilityPolicy: policy, ); @@ -519,6 +529,18 @@ Submessage submessage({ // Aggregate data structures. // +UnreadChannelSnapshot unreadChannelMsgs({ + required String topic, + required int streamId, + required List unreadMessageIds, +}) { + return UnreadChannelSnapshot( + topic: TopicName(topic), + streamId: streamId, + unreadMessageIds: unreadMessageIds, + ); +} + UnreadMessagesSnapshot unreadMsgs({ int? count, List? dms, @@ -547,7 +569,7 @@ UserTopicEvent userTopicEvent( return UserTopicEvent( id: 1, streamId: streamId, - topicName: topic, + topicName: TopicName(topic), lastUpdated: 1234567890, visibilityPolicy: visibilityPolicy, ); @@ -605,8 +627,8 @@ UpdateMessageEvent _updateMessageMoveEvent( List messageIds, { required int origStreamId, int? newStreamId, - required String origTopic, - String? newTopic, + required TopicName origTopic, + TopicName? newTopic, String? origContent, String? newContent, required List flags, @@ -642,12 +664,15 @@ UpdateMessageEvent _updateMessageMoveEvent( UpdateMessageEvent updateMessageEventMoveFrom({ required List origMessages, int? newStreamId, - String? newTopic, + TopicName? newTopic, + String? newTopicStr, String? newContent, PropagateMode propagateMode = PropagateMode.changeOne, }) { _checkPositive(newStreamId, 'stream ID'); assert(origMessages.isNotEmpty); + assert(newTopic == null || newTopicStr == null); + newTopic ??= newTopicStr == null ? null : TopicName(newTopicStr); final origMessage = origMessages.first; // Only present on content change. final origContent = (newContent != null) ? origMessage.content : null; @@ -667,12 +692,15 @@ UpdateMessageEvent updateMessageEventMoveFrom({ UpdateMessageEvent updateMessageEventMoveTo({ required List newMessages, int? origStreamId, - String? origTopic, + TopicName? origTopic, + String? origTopicStr, String? origContent, PropagateMode propagateMode = PropagateMode.changeOne, }) { _checkPositive(origStreamId, 'stream ID'); assert(newMessages.isNotEmpty); + assert(origTopic == null || origTopicStr == null); + origTopic ??= origTopicStr == null ? null : TopicName(origTopicStr); final newMessage = newMessages.first; // Only present on topic move. final newTopic = (origTopic != null) ? newMessage.topic : null; diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index 4e4ae7d986..505f5189f2 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -66,6 +66,12 @@ extension TextChecks on Subject { Subject get style => has((t) => t.style, 'style'); } +extension TextEditingValueChecks on Subject { + Subject get text => has((x) => x.text, 'text'); + Subject get selection => has((x) => x.selection, 'selection'); + Subject get composing => has((x) => x.composing, 'composing'); +} + extension TextEditingControllerChecks on Subject { Subject get text => has((t) => t.text, 'text'); } diff --git a/test/model/autocomplete_checks.dart b/test/model/autocomplete_checks.dart index cb94735894..ec8acbe500 100644 --- a/test/model/autocomplete_checks.dart +++ b/test/model/autocomplete_checks.dart @@ -1,4 +1,5 @@ import 'package:checks/checks.dart'; +import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/autocomplete.dart'; import 'package:zulip/widgets/compose_box.dart'; @@ -20,5 +21,5 @@ extension UserMentionAutocompleteResultChecks on Subject { - Subject get topic => has((r) => r.topic, 'topic'); + Subject get topic => has((r) => r.topic, 'topic'); } diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index 9d6667ca3f..d6ed9574e0 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -481,10 +481,11 @@ void main() { } int compareAB({required String? topic}) { + final realTopic = topic == null ? null : TopicName(topic); final resultAB = MentionAutocompleteView.compareByRecency(userA, userB, - streamId: stream.streamId, topic: topic, store: store); + streamId: stream.streamId, topic: realTopic, store: store); final resultBA = MentionAutocompleteView.compareByRecency(userB, userA, - streamId: stream.streamId, topic: topic, store: store); + streamId: stream.streamId, topic: realTopic, store: store); switch (resultAB) { case <0: check(resultBA).isGreaterThan(0); case >0: check(resultBA).isLessThan(0); @@ -659,7 +660,7 @@ void main() { eg.user(fullName: 'b', isBot: true), ]; final stream = eg.stream(); - final narrow = TopicNarrow(stream.streamId, 'this'); + final narrow = eg.topicNarrow(stream.streamId, 'this'); await prepare(users: users, messages: [ eg.streamMessage(sender: users[1], stream: stream, topic: 'this'), eg.streamMessage(sender: users[0], stream: stream, topic: 'this'), @@ -790,7 +791,7 @@ void main() { final stream = eg.stream(); const topic = 'topic'; - final topicNarrow = TopicNarrow(stream.streamId, topic); + final topicNarrow = eg.topicNarrow(stream.streamId, topic); final users = [ eg.user(userId: 1, fullName: 'User One'), @@ -900,7 +901,7 @@ void main() { group('TopicAutocompleteQuery.testTopic', () { void doCheck(String rawQuery, String topic, bool expected) { - final result = TopicAutocompleteQuery(rawQuery).testTopic(topic); + final result = TopicAutocompleteQuery(rawQuery).testTopic(eg.t(topic)); expected ? check(result).isTrue() : check(result).isFalse(); } diff --git a/test/model/channel_test.dart b/test/model/channel_test.dart index 14a9f69ea3..f22ac7cc8f 100644 --- a/test/model/channel_test.dart +++ b/test/model/channel_test.dart @@ -123,14 +123,14 @@ void main() { group('getter topicVisibilityPolicy', () { test('with nothing for stream', () { final store = eg.store(); - check(store.topicVisibilityPolicy(stream1.streamId, 'topic')) + check(store.topicVisibilityPolicy(stream1.streamId, eg.t('topic'))) .equals(UserTopicVisibilityPolicy.none); }); test('with nothing for topic', () async { final store = eg.store(); await store.addUserTopic(stream1, 'other topic', UserTopicVisibilityPolicy.muted); - check(store.topicVisibilityPolicy(stream1.streamId, 'topic')) + check(store.topicVisibilityPolicy(stream1.streamId, eg.t('topic'))) .equals(UserTopicVisibilityPolicy.none); }); @@ -142,7 +142,7 @@ void main() { UserTopicVisibilityPolicy.followed, ]) { await store.addUserTopic(stream1, 'topic', policy); - check(store.topicVisibilityPolicy(stream1.streamId, 'topic')) + check(store.topicVisibilityPolicy(stream1.streamId, eg.t('topic'))) .equals(policy); } }); @@ -153,23 +153,23 @@ void main() { final store = eg.store(); await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1)); - check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isTrue(); - check(store.isTopicVisible (stream1.streamId, 'topic')).isTrue(); + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue(); + check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isTrue(); }); test('with policy none, stream muted', () async { final store = eg.store(); await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1, isMuted: true)); - check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isTrue(); - check(store.isTopicVisible (stream1.streamId, 'topic')).isFalse(); + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue(); + check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isFalse(); }); test('with policy none, stream unsubscribed', () async { final store = eg.store(); await store.addStream(stream1); - check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isTrue(); - check(store.isTopicVisible (stream1.streamId, 'topic')).isFalse(); + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue(); + check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isFalse(); }); test('with policy muted', () async { @@ -177,8 +177,8 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1)); await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.muted); - check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isFalse(); - check(store.isTopicVisible (stream1.streamId, 'topic')).isFalse(); + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isFalse(); + check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isFalse(); }); test('with policy unmuted', () async { @@ -186,8 +186,8 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1, isMuted: true)); await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.unmuted); - check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isTrue(); - check(store.isTopicVisible (stream1.streamId, 'topic')).isTrue(); + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue(); + check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isTrue(); }); test('with policy followed', () async { @@ -195,8 +195,8 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1, isMuted: true)); await store.addUserTopic(stream1, 'topic', UserTopicVisibilityPolicy.followed); - check(store.isTopicVisibleInStream(stream1.streamId, 'topic')).isTrue(); - check(store.isTopicVisible (stream1.streamId, 'topic')).isTrue(); + check(store.isTopicVisibleInStream(stream1.streamId, eg.t('topic'))).isTrue(); + check(store.isTopicVisible (stream1.streamId, eg.t('topic'))).isTrue(); }); }); @@ -265,16 +265,16 @@ void main() { eg.subscription(stream1, isMuted: streamMuted)); } await store.handleEvent(mkEvent(oldPolicy)); - final oldVisibleInStream = store.isTopicVisibleInStream(stream1.streamId, 'topic'); - final oldVisible = store.isTopicVisible(stream1.streamId, 'topic'); + final oldVisibleInStream = store.isTopicVisibleInStream(stream1.streamId, eg.t('topic')); + final oldVisible = store.isTopicVisible(stream1.streamId, eg.t('topic')); final event = mkEvent(newPolicy); final willChangeInStream = store.willChangeIfTopicVisibleInStream(event); final willChange = store.willChangeIfTopicVisible(event); await store.handleEvent(event); - final newVisibleInStream = store.isTopicVisibleInStream(stream1.streamId, 'topic'); - final newVisible = store.isTopicVisible(stream1.streamId, 'topic'); + final newVisibleInStream = store.isTopicVisibleInStream(stream1.streamId, eg.t('topic')); + final newVisible = store.isTopicVisible(stream1.streamId, eg.t('topic')); VisibilityEffect fromOldNew(bool oldVisible, bool newVisible) { if (newVisible == oldVisible) return VisibilityEffect.none; @@ -384,13 +384,13 @@ void main() { eg.userTopicItem(stream, 'topic 2', UserTopicVisibilityPolicy.unmuted), eg.userTopicItem(stream, 'topic 3', UserTopicVisibilityPolicy.followed), ])); - check(store.topicVisibilityPolicy(stream.streamId, 'topic 1')) + check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 1'))) .equals(UserTopicVisibilityPolicy.muted); - check(store.topicVisibilityPolicy(stream.streamId, 'topic 2')) + check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 2'))) .equals(UserTopicVisibilityPolicy.unmuted); - check(store.topicVisibilityPolicy(stream.streamId, 'topic 3')) + check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 3'))) .equals(UserTopicVisibilityPolicy.followed); - check(store.topicVisibilityPolicy(stream.streamId, 'topic 4')) + check(store.topicVisibilityPolicy(stream.streamId, eg.t('topic 4'))) .equals(UserTopicVisibilityPolicy.none); }); }); diff --git a/test/model/internal_link_test.dart b/test/model/internal_link_test.dart index 3949339321..eb91e35855 100644 --- a/test/model/internal_link_test.dart +++ b/test/model/internal_link_test.dart @@ -72,7 +72,7 @@ void main() { await store.addStream(eg.stream(streamId: streamId, name: name)); final narrow = topic == null ? ChannelNarrow(streamId) - : TopicNarrow(streamId, topic); + : eg.topicNarrow(streamId, topic); check(narrowLink(store, narrow, nearMessageId: nearMessageId)) .equals(store.realmUrl.resolve(expectedFragment)); } @@ -281,28 +281,28 @@ void main() { }); group('"/#narrow/stream/<...>/topic/<...>" returns expected TopicNarrow', () { - const testCases = [ - ('/#narrow/stream/check/topic/test', TopicNarrow(1, 'test')), - ('/#narrow/stream/mobile/subject/topic/near/378333', TopicNarrow(3, 'topic')), - ('/#narrow/stream/mobile/subject/topic/with/1', TopicNarrow(3, 'topic')), - ('/#narrow/stream/mobile/topic/topic/', TopicNarrow(3, 'topic')), - ('/#narrow/stream/stream/topic/topic/near/1', TopicNarrow(5, 'topic')), - ('/#narrow/stream/stream/topic/topic/with/22', TopicNarrow(5, 'topic')), - ('/#narrow/stream/stream/subject/topic/near/1', TopicNarrow(5, 'topic')), - ('/#narrow/stream/stream/subject/topic/with/333', TopicNarrow(5, 'topic')), - ('/#narrow/stream/stream/subject/topic', TopicNarrow(5, 'topic')), + final testCases = [ + ('/#narrow/stream/check/topic/test', eg.topicNarrow(1, 'test')), + ('/#narrow/stream/mobile/subject/topic/near/378333', eg.topicNarrow(3, 'topic')), + ('/#narrow/stream/mobile/subject/topic/with/1', eg.topicNarrow(3, 'topic')), + ('/#narrow/stream/mobile/topic/topic/', eg.topicNarrow(3, 'topic')), + ('/#narrow/stream/stream/topic/topic/near/1', eg.topicNarrow(5, 'topic')), + ('/#narrow/stream/stream/topic/topic/with/22', eg.topicNarrow(5, 'topic')), + ('/#narrow/stream/stream/subject/topic/near/1', eg.topicNarrow(5, 'topic')), + ('/#narrow/stream/stream/subject/topic/with/333', eg.topicNarrow(5, 'topic')), + ('/#narrow/stream/stream/subject/topic', eg.topicNarrow(5, 'topic')), ]; testExpectedNarrows(testCases, streams: streams); }); group('Both `stream` and `channel` can be used interchangeably', () { - const testCases = [ - ('/#narrow/stream/check', ChannelNarrow(1)), - ('/#narrow/channel/check', ChannelNarrow(1)), - ('/#narrow/stream/check/topic/test', TopicNarrow(1, 'test')), - ('/#narrow/channel/check/topic/test', TopicNarrow(1, 'test')), - ('/#narrow/stream/check/topic/test/near/378333', TopicNarrow(1, 'test')), - ('/#narrow/channel/check/topic/test/near/378333', TopicNarrow(1, 'test')), + final testCases = [ + ('/#narrow/stream/check', const ChannelNarrow(1)), + ('/#narrow/channel/check', const ChannelNarrow(1)), + ('/#narrow/stream/check/topic/test', eg.topicNarrow(1, 'test')), + ('/#narrow/channel/check/topic/test', eg.topicNarrow(1, 'test')), + ('/#narrow/stream/check/topic/test/near/378333', eg.topicNarrow(1, 'test')), + ('/#narrow/channel/check/topic/test/near/378333', eg.topicNarrow(1, 'test')), ]; testExpectedNarrows(testCases, streams: streams); }); @@ -414,13 +414,13 @@ void main() { eg.stream(streamId: 2, name: 'some stream'), eg.stream(streamId: 3, name: 'some.stream'), ]; - const testCases = [ - ('/#narrow/stream/some_stream', ChannelNarrow(1)), - ('/#narrow/stream/some.20stream', ChannelNarrow(2)), - ('/#narrow/stream/some.2Estream', ChannelNarrow(3)), - ('/#narrow/stream/some_stream/topic/some_topic', TopicNarrow(1, 'some_topic')), - ('/#narrow/stream/some_stream/topic/some.20topic', TopicNarrow(1, 'some topic')), - ('/#narrow/stream/some_stream/topic/some.2Etopic', TopicNarrow(1, 'some.topic')), + final testCases = [ + ('/#narrow/stream/some_stream', const ChannelNarrow(1)), + ('/#narrow/stream/some.20stream', const ChannelNarrow(2)), + ('/#narrow/stream/some.2Estream', const ChannelNarrow(3)), + ('/#narrow/stream/some_stream/topic/some_topic', eg.topicNarrow(1, 'some_topic')), + ('/#narrow/stream/some_stream/topic/some.20topic', eg.topicNarrow(1, 'some topic')), + ('/#narrow/stream/some_stream/topic/some.2Etopic', eg.topicNarrow(1, 'some.topic')), ]; testExpectedNarrows(testCases, streams: streams); }); @@ -518,8 +518,8 @@ void main() { return '#narrow/stream/${stream.streamId}-${stream.name}/topic/$operand'; } final testCases = [ - (mkUrlString('(no.20topic)'), TopicNarrow(stream.streamId, '(no topic)')), - (mkUrlString('lunch'), TopicNarrow(stream.streamId, 'lunch')), + (mkUrlString('(no.20topic)'), eg.topicNarrow(stream.streamId, '(no topic)')), + (mkUrlString('lunch'), eg.topicNarrow(stream.streamId, 'lunch')), ]; testExpectedNarrows(testCases, streams: [stream]); }); @@ -529,12 +529,12 @@ void main() { return '#narrow/stream/${stream.name}/topic/$operand'; } final testCases = [ - (mkUrlString('(no.20topic)'), TopicNarrow(stream.streamId, '(no topic)')), - (mkUrlString('google.2Ecom'), TopicNarrow(stream.streamId, 'google.com')), + (mkUrlString('(no.20topic)'), eg.topicNarrow(stream.streamId, '(no topic)')), + (mkUrlString('google.2Ecom'), eg.topicNarrow(stream.streamId, 'google.com')), (mkUrlString('google.com'), null), - (mkUrlString('topic.20name'), TopicNarrow(stream.streamId, 'topic name')), - (mkUrlString('stream'), TopicNarrow(stream.streamId, 'stream')), - (mkUrlString('topic'), TopicNarrow(stream.streamId, 'topic')), + (mkUrlString('topic.20name'), eg.topicNarrow(stream.streamId, 'topic name')), + (mkUrlString('stream'), eg.topicNarrow(stream.streamId, 'stream')), + (mkUrlString('topic'), eg.topicNarrow(stream.streamId, 'topic')), ]; testExpectedNarrows(testCases, streams: [stream]); }); diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index e92b2489b3..6a1d103c84 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -440,7 +440,7 @@ void main() { }); test('in TopicNarrow, stay visible', () async { - await prepare(narrow: TopicNarrow(stream.streamId, topic)); + await prepare(narrow: eg.topicNarrow(stream.streamId, topic)); await prepareMutes(); await prepareMessages(foundOldest: true, messages: [ eg.streamMessage(id: 1, stream: stream, topic: topic), @@ -720,7 +720,7 @@ void main() { await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: movedMessages, - newTopic: 'new', + newTopicStr: 'new', )); checkHasMessages(initialMessages + movedMessages); checkNotified(count: 2); @@ -738,7 +738,7 @@ void main() { await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: movedMessages, - newTopic: 'new', + newTopicStr: 'new', )); checkHasMessages(initialMessages + movedMessages); checkNotified(count: 2); @@ -752,7 +752,7 @@ void main() { messages: initialMessages + movedMessages, ).toJson()); await store.handleEvent(eg.updateMessageEventMoveTo( - origTopic: 'orig topic', + origTopicStr: 'orig topic', origStreamId: otherStream.streamId, newMessages: movedMessages, )); @@ -770,7 +770,7 @@ void main() { await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: movedMessages, - newTopic: 'new', + newTopicStr: 'new', newStreamId: otherStream.streamId, )); checkHasMessages(initialMessages); @@ -793,7 +793,7 @@ void main() { await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: otherChannelMovedMessages, - newTopic: 'new', + newTopicStr: 'new', )); checkHasMessages(initialMessages); checkNotNotified(); @@ -807,7 +807,7 @@ void main() { ).toJson()); await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: movedMessages, - newTopic: 'new', + newTopicStr: 'new', newStreamId: otherStream.streamId, propagateMode: propagateMode, )); @@ -832,7 +832,7 @@ void main() { }); group('in topic narrow', () { - final narrow = TopicNarrow(stream.streamId, 'topic'); + final narrow = eg.topicNarrow(stream.streamId, 'topic'); final initialMessages = List.generate(5, (i) => eg.streamMessage(stream: stream, topic: 'topic')); final movedMessages = List.generate(5, (i) => eg.streamMessage(stream: stream, topic: 'topic')); final otherTopicMovedMessages = List.generate(5, (i) => eg.streamMessage(stream: stream, topic: 'other topic')); @@ -855,7 +855,7 @@ void main() { ).toJson()); await store.handleEvent(eg.updateMessageEventMoveTo( origStreamId: origStreamId, - origTopic: origTopic, + origTopicStr: origTopic, newMessages: movedMessages, )); check(model).fetched.isFalse(); @@ -883,7 +883,7 @@ void main() { await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: movedMessages, newStreamId: newStreamId, - newTopic: newTopic, + newTopicStr: newTopic, )); checkHasMessages(initialMessages); checkNotifiedOnce(); @@ -896,7 +896,7 @@ void main() { await prepareNarrow(narrow, initialMessages); await store.handleEvent(eg.updateMessageEventMoveTo( - origTopic: 'other', + origTopicStr: 'other', newMessages: otherTopicMovedMessages, )); check(model).fetched.isTrue(); @@ -925,7 +925,7 @@ void main() { ).toJson()); await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: movedMessages, - newTopic: 'new', + newTopicStr: 'new', newStreamId: otherStream.streamId, propagateMode: propagateMode, )); @@ -937,21 +937,21 @@ void main() { handleMoveEvent(PropagateMode.changeOne); checkNotNotified(); checkHasMessages(initialMessages); - check(model).narrow.equals(TopicNarrow(stream.streamId, 'topic')); + check(model).narrow.equals(eg.topicNarrow(stream.streamId, 'topic')); }); test('follow to the new narrow when propagateMode = changeLater', () { handleMoveEvent(PropagateMode.changeLater); checkNotifiedOnce(); checkHasMessages(movedMessages); - check(model).narrow.equals(TopicNarrow(otherStream.streamId, 'new')); + check(model).narrow.equals(eg.topicNarrow(otherStream.streamId, 'new')); }); test('follow to the new narrow when propagateMode = changeAll', () { handleMoveEvent(PropagateMode.changeAll); checkNotifiedOnce(); checkHasMessages(movedMessages); - check(model).narrow.equals(TopicNarrow(otherStream.streamId, 'new')); + check(model).narrow.equals(eg.topicNarrow(otherStream.streamId, 'new')); }); test('handle move event before initial fetch', () => awaitFakeAsync((async) async { @@ -969,11 +969,11 @@ void main() { check(model).fetched.isFalse(); checkHasMessages([]); await store.handleEvent(eg.updateMessageEventMoveTo( - origTopic: 'topic', + origTopicStr: 'topic', newMessages: [followedMessage], propagateMode: PropagateMode.changeAll, )); - check(model).narrow.equals(TopicNarrow(stream.streamId, 'new')); + check(model).narrow.equals(eg.topicNarrow(stream.streamId, 'new')); async.elapse(const Duration(seconds: 2)); checkHasMessages([followedMessage]); @@ -1255,7 +1255,7 @@ void main() { int notifiedCount2 = 0; final model2 = MessageListView.init(store: store, - narrow: TopicNarrow(stream.streamId, 'hello')) + narrow: eg.topicNarrow(stream.streamId, 'hello')) ..addListener(() => notifiedCount2++); for (final m in [model1, model2]) { @@ -1481,7 +1481,7 @@ void main() { test('in TopicNarrow', () async { final stream = eg.stream(); - await prepare(narrow: TopicNarrow(stream.streamId, 'A')); + await prepare(narrow: eg.topicNarrow(stream.streamId, 'A')); await store.addStream(stream); await store.addSubscription(eg.subscription(stream, isMuted: true)); await store.addUserTopic(stream, 'A', UserTopicVisibilityPolicy.muted); diff --git a/test/model/message_test.dart b/test/model/message_test.dart index c1fad15ac7..43f17be61a 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -320,7 +320,7 @@ void main() { final originalDisplayRecipient = origMessages[0].displayRecipient!; await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: origMessages, - newTopic: 'new topic')); + newTopicStr: 'new topic')); checkNotified(count: 2); check(store).messages.values.every(((message) => message.isA() @@ -332,7 +332,7 @@ void main() { await prepareOrigMessages(origTopic: 'new topic'); await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: origMessages, - newTopic: '✔ new topic')); + newTopicStr: '✔ new topic')); checkNotified(count: 2); check(store).messages.values.every(((message) => message.editState.equals(MessageEditState.none))); }); @@ -341,7 +341,7 @@ void main() { await prepareOrigMessages(origTopic: '✔ new topic'); await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: origMessages, - newTopic: 'new topic')); + newTopicStr: 'new topic')); checkNotified(count: 2); check(store).messages.values.every(((message) => message.editState.equals(MessageEditState.none))); }); @@ -350,7 +350,7 @@ void main() { await prepareOrigMessages(origTopic: 'new topic'); await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: origMessages, - newTopic: '✔ new topic 2')); + newTopicStr: '✔ new topic 2')); checkNotified(count: 2); check(store).messages.values.every(((message) => message.editState.equals(MessageEditState.moved))); }); @@ -359,7 +359,7 @@ void main() { await prepareOrigMessages(origTopic: '✔ new topic'); await store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: origMessages, - newTopic: 'new topic 2')); + newTopicStr: 'new topic 2')); checkNotified(count: 2); check(store).messages.values.every(((message) => message.editState.equals(MessageEditState.moved))); }); diff --git a/test/model/narrow_checks.dart b/test/model/narrow_checks.dart index 241547d782..ce65de854d 100644 --- a/test/model/narrow_checks.dart +++ b/test/model/narrow_checks.dart @@ -1,5 +1,6 @@ import 'package:checks/checks.dart'; +import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/narrow.dart'; import 'package:zulip/model/narrow.dart'; @@ -14,5 +15,5 @@ extension DmNarrowChecks on Subject { extension TopicNarrowChecks on Subject { Subject get streamId => has((x) => x.streamId, 'streamId'); - Subject get topic => has((x) => x.topic, 'topic'); + Subject get topic => has((x) => x.topic, 'topic'); } diff --git a/test/model/recent_senders_test.dart b/test/model/recent_senders_test.dart index 2da516e11c..602362cbcc 100644 --- a/test/model/recent_senders_test.dart +++ b/test/model/recent_senders_test.dart @@ -7,7 +7,7 @@ import '../example_data.dart' as eg; /// [messages] should be sorted by [id] ascending. void checkMatchesMessages(RecentSenders model, List messages) { final Map>> messagesByUserInStream = {}; - final Map>>> messagesByUserInTopic = {}; + final Map>>> messagesByUserInTopic = {}; for (final message in messages) { if (message is! StreamMessage) { throw UnsupportedError('Message of type ${message.runtimeType} is not expected.'); @@ -199,15 +199,15 @@ void main() { model.handleMessages(messages); check(model.latestMessageIdOfSenderInTopic(streamId: 1, - topic: 'a', senderId: 10)).equals(300); + topic: eg.t('a'), senderId: 10)).equals(300); // No message of user 20 in topic "a". check(model.latestMessageIdOfSenderInTopic(streamId: 1, - topic: 'a', senderId: 20)).equals(null); + topic: eg.t('a'), senderId: 20)).equals(null); // No message in topic "b" at all. check(model.latestMessageIdOfSenderInTopic(streamId: 1, - topic: 'b', senderId: 10)).equals(null); + topic: eg.t('b'), senderId: 10)).equals(null); // No message in stream 2 at all. check(model.latestMessageIdOfSenderInTopic(streamId: 2, - topic: 'a', senderId: 10)).equals(null); + topic: eg.t('a'), senderId: 10)).equals(null); }); } diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 10e8698360..c8c6b4c266 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -386,7 +386,7 @@ void main() { final stream = eg.stream(); connection.prepare(json: SendMessageResult(id: 12345).toJson()); await store.sendMessage( - destination: StreamDestination(stream.streamId, 'world'), + destination: StreamDestination(stream.streamId, eg.t('world')), content: 'hello'); check(connection.takeRequests()).single.isA() ..method.equals('POST') diff --git a/test/model/typing_status_test.dart b/test/model/typing_status_test.dart index 45348afb01..4061301366 100644 --- a/test/model/typing_status_test.dart +++ b/test/model/typing_status_test.dart @@ -35,7 +35,7 @@ void checkSetTypingStatusRequests( 'type': 'channel', 'op': op.toJson(), 'stream_id': narrow.streamId.toString(), - 'topic': narrow.topic}), + 'topic': narrow.topic.apiName}), DmNarrow() => conditionTypingRequest({ 'type': 'direct', 'op': op.toJson(), @@ -94,7 +94,7 @@ void main() { } final stream = eg.stream(); - final topicNarrow = TopicNarrow(stream.streamId, 'foo'); + final topicNarrow = eg.topicNarrow(stream.streamId, 'foo'); final dmNarrow = DmNarrow.withUser(eg.otherUser.userId, selfUserId: eg.selfUser.userId); final groupNarrow = DmNarrow.withOtherUsers( @@ -272,7 +272,7 @@ void main() { final channel = eg.stream(); await store.addStream(channel); await store.addSubscription(eg.subscription(channel)); - narrow = TopicNarrow(channel.streamId, 'topic'); + narrow = eg.topicNarrow(channel.streamId, 'topic'); } /// Prepares store and triggers a "typing started" notice. diff --git a/test/model/unreads_checks.dart b/test/model/unreads_checks.dart index 836e497b2b..ac4d64846a 100644 --- a/test/model/unreads_checks.dart +++ b/test/model/unreads_checks.dart @@ -1,10 +1,11 @@ import 'package:checks/checks.dart'; import 'package:collection/collection.dart'; +import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/unreads.dart'; extension UnreadsChecks on Subject { - Subject>>> get streams => has((u) => u.streams, 'streams'); + Subject>>> get streams => has((u) => u.streams, 'streams'); Subject>> get dms => has((u) => u.dms, 'dms'); Subject> get mentions => has((u) => u.mentions, 'mentions'); Subject get oldUnreadsMissing => has((u) => u.oldUnreadsMissing, 'oldUnreadsMissing'); diff --git a/test/model/unreads_test.dart b/test/model/unreads_test.dart index f5eab5a845..40e074dcaa 100644 --- a/test/model/unreads_test.dart +++ b/test/model/unreads_test.dart @@ -58,7 +58,7 @@ void main() { assert(Set.of(messages.map((m) => m.id)).length == messages.length, 'checkMatchesMessages: duplicate messages in test input'); - final Map>> expectedStreams = {}; + final Map>> expectedStreams = {}; final Map> expectedDms = {}; final Set expectedMentions = {}; for (final message in messages) { @@ -114,10 +114,10 @@ void main() { prepare(initial: UnreadMessagesSnapshot( count: 0, channels: [ - UnreadChannelSnapshot(streamId: stream1.streamId, topic: 'a', unreadMessageIds: [1, 2]), - UnreadChannelSnapshot(streamId: stream1.streamId, topic: 'b', unreadMessageIds: [3, 4]), - UnreadChannelSnapshot(streamId: stream2.streamId, topic: 'b', unreadMessageIds: [5, 6]), - UnreadChannelSnapshot(streamId: stream2.streamId, topic: 'c', unreadMessageIds: [7, 8]), + eg.unreadChannelMsgs(streamId: stream1.streamId, topic: 'a', unreadMessageIds: [1, 2]), + eg.unreadChannelMsgs(streamId: stream1.streamId, topic: 'b', unreadMessageIds: [3, 4]), + eg.unreadChannelMsgs(streamId: stream2.streamId, topic: 'b', unreadMessageIds: [5, 6]), + eg.unreadChannelMsgs(streamId: stream2.streamId, topic: 'c', unreadMessageIds: [7, 8]), ], dms: [ UnreadDmSnapshot(otherUserId: 1, unreadMessageIds: [9, 10]), @@ -204,7 +204,7 @@ void main() { prepare(); fillWithMessages(List.generate(7, (i) => eg.streamMessage( stream: stream, topic: 'a', flags: []))); - check(model.countInTopicNarrow(stream.streamId, 'a')).equals(7); + check(model.countInTopicNarrow(stream.streamId, eg.t('a'))).equals(7); }); test('countInDmNarrow', () { @@ -538,7 +538,7 @@ void main() { messageIds: [11, 12], messageType: MessageType.stream, streamId: stream1.streamId, - topic: 'a', + topic: eg.t('a'), )); checkNotifiedOnce(); checkMatchesMessages(expectedRemainingMessages..removeAll([message11, message12])); @@ -547,7 +547,7 @@ void main() { messageIds: [13, 14], messageType: MessageType.stream, streamId: stream2.streamId, - topic: 'b', + topic: eg.t('b'), )); checkNotifiedOnce(); checkMatchesMessages(expectedRemainingMessages..removeAll([message13, message14])); @@ -1029,7 +1029,7 @@ void main() { type: MessageType.stream, mentioned: false, streamId: stream.streamId, - topic: topic, + topic: eg.t(topic), userIds: null, ), // message 2 and 3 have their details missing diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index cb49a5f8a8..063f83bbb2 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -1116,7 +1116,7 @@ void main() { payload = NotificationOpenPayload( realmUrl: Uri.parse('http://chat.example'), userId: 1001, - narrow: const TopicNarrow(1, 'topic A'), + narrow: eg.topicNarrow(1, 'topic A'), ); url = payload.buildUrl(); check(NotificationOpenPayload.parseUrl(url)) @@ -1146,7 +1146,7 @@ void main() { final url = NotificationOpenPayload( realmUrl: Uri.parse('http://chat.example'), userId: 1001, - narrow: const TopicNarrow(1, 'topic A'), + narrow: eg.topicNarrow(1, 'topic A'), ).buildUrl(); check(url) ..scheme.equals('zulip') @@ -1194,7 +1194,7 @@ void main() { ..userId.equals(1001) ..narrow.which((it) => it.isA() ..streamId.equals(1) - ..topic.equals('topic A')); + ..topic.equals(eg.t('topic A'))); }); test('parse: fails when missing any expected query parameters', () { diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index cf19bc3e9f..c10957363e 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -145,7 +145,7 @@ void main() { foundOldest: true, messages: [message]).toJson()); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: MessageListPage( - initNarrow: TopicNarrow(channel.streamId, topic)))); + initNarrow: eg.topicNarrow(channel.streamId, topic)))); // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); @@ -214,7 +214,7 @@ void main() { eg.streamMessage(stream: channel, topic: topic)]).toJson()); await tester.pumpWidget(TestZulipApp(accountId: account.id, child: MessageListPage( - initNarrow: TopicNarrow(channel.streamId, topic)))); + initNarrow: eg.topicNarrow(channel.streamId, topic)))); await tester.pumpAndSettle(); await tester.longPress(find.descendant( @@ -809,7 +809,7 @@ void main() { connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: [message]).toJson()); await store.handleEvent(eg.updateMessageEventMoveFrom( - newStreamId: newStream.streamId, newTopic: newTopic, + newStreamId: newStream.streamId, newTopicStr: newTopic, propagateMode: PropagateMode.changeAll, origMessages: [message])); @@ -823,7 +823,7 @@ void main() { ..method.equals('POST') ..url.path.equals('/api/v1/messages/flags/narrow') ..bodyFields['narrow'].equals( - jsonEncode(TopicNarrow(newStream.streamId, newTopic).apiEncode())); + jsonEncode(eg.topicNarrow(newStream.streamId, newTopic).apiEncode())); }); testWidgets('shows error when fails', (tester) async { diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 24ae2dba83..a3a4b3c5f7 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -17,6 +17,7 @@ import 'package:zulip/widgets/message_list.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; +import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/test_store.dart'; import '../test_images.dart'; @@ -273,7 +274,7 @@ void main() { group('TopicAutocomplete', () { void checkTopicShown(GetStreamTopicsEntry topic, PerAccountStore store, {required bool expected}) { - check(find.text(topic.name).evaluate().length).equals(expected ? 1 : 0); + check(find.text(topic.name.displayName).evaluate().length).equals(expected ? 1 : 0); } testWidgets('options appear, disappear, and change correctly', (WidgetTester tester) async { @@ -298,7 +299,7 @@ void main() { await tester.tap(find.text('Topic three')); await tester.pumpAndSettle(); check(tester.widget(topicInputFinder).controller!.text) - .equals(topic3.name); + .equals(topic3.name.displayName); checkTopicShown(topic1, store, expected: false); checkTopicShown(topic2, store, expected: false); checkTopicShown(topic3, store, expected: true); // shown in `_TopicInput` once @@ -309,5 +310,36 @@ void main() { await tester.pumpAndSettle(); checkTopicShown(topic2, store, expected: true); }); + + testWidgets('text selection is reset on choosing an option', (tester) async { + // TODO test also that composing region gets reset. + // (Just adding it to the updateEditingValue call below doesn't seem + // to suffice to set it up; the controller value after the pump still + // has empty composing region, so there's nothing to check after tap.) + + final topic = eg.getStreamTopicsEntry(name: 'some topic'); + final topicInputFinder = await setupToTopicInput(tester, topics: [topic]); + final controller = tester.widget(topicInputFinder).controller!; + + await tester.enterText(topicInputFinder, 'so'); + await tester.enterText(topicInputFinder, 'some'); + tester.testTextInput.updateEditingValue(const TextEditingValue( + text: 'some', + selection: TextSelection(baseOffset: 1, extentOffset: 3))); + await tester.pump(); + check(controller.value) + ..text.equals('some') + ..selection.equals( + const TextSelection(baseOffset: 1, extentOffset: 3)); + + await tester.tap(find.text('some topic')); + await tester.pump(); + check(controller.value) + ..text.equals('some topic') + ..selection.equals( + const TextSelection.collapsed(offset: 'some topic'.length)); + + await tester.pump(Duration.zero); + }); }); } diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 077134e7d1..cde7133d74 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -230,14 +230,14 @@ void main() { testWidgets('_FixedDestinationComposeBox', (tester) async { final channel = eg.stream(); await prepareComposeBox(tester, - narrow: TopicNarrow(channel.streamId, 'topic'), streams: [channel]); + narrow: eg.topicNarrow(channel.streamId, 'topic'), streams: [channel]); checkComposeBoxTextFields(tester, expectTopicTextField: false); }); }); group('ComposeBox typing notices', () { final channel = eg.stream(); - final narrow = TopicNarrow(channel.streamId, 'some topic'); + final narrow = eg.topicNarrow(channel.streamId, 'some topic'); void checkTypingRequest(TypingOp op, SendableNarrow narrow) => checkSetTypingStatusRequests(connection.takeRequests(), [(op, narrow)]); @@ -272,9 +272,9 @@ void main() { testWidgets('smoke ChannelNarrow', (tester) async { final narrow = ChannelNarrow(channel.streamId); - final destinationNarrow = TopicNarrow(narrow.streamId, 'test topic'); + final destinationNarrow = eg.topicNarrow(narrow.streamId, 'test topic'); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); - await enterTopic(tester, narrow: narrow, topic: destinationNarrow.topic); + await enterTopic(tester, narrow: narrow, topic: 'test topic'); await checkStartTyping(tester, destinationNarrow); @@ -339,9 +339,9 @@ void main() { testWidgets('for content input, unfocusing sends a "typing stopped" notice', (tester) async { final narrow = ChannelNarrow(channel.streamId); - final destinationNarrow = TopicNarrow(narrow.streamId, 'test topic'); + final destinationNarrow = eg.topicNarrow(narrow.streamId, 'test topic'); await prepareComposeBox(tester, narrow: narrow, streams: [channel]); - await enterTopic(tester, narrow: narrow, topic: destinationNarrow.topic); + await enterTopic(tester, narrow: narrow, topic: 'test topic'); await checkStartTyping(tester, destinationNarrow); @@ -402,7 +402,7 @@ void main() { addTearDown(TypingNotifier.debugReset); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - await prepareComposeBox(tester, narrow: const TopicNarrow(123, 'some topic'), + await prepareComposeBox(tester, narrow: eg.topicNarrow(123, 'some topic'), streams: [eg.stream(streamId: 123)]); await tester.enterText(contentInputFinder, 'hello world'); @@ -708,7 +708,7 @@ void main() { final narrowTestCases = [ ('channel', const ChannelNarrow(1)), - ('topic', const TopicNarrow(1, 'topic')), + ('topic', eg.topicNarrow(1, 'topic')), ]; for (final (String narrowType, Narrow narrow) in narrowTestCases) { @@ -802,7 +802,7 @@ void main() { group('ComposeBox content input scaling', () { const verticalPadding = 8; final stream = eg.stream(); - final narrow = TopicNarrow(stream.streamId, 'foo'); + final narrow = eg.topicNarrow(stream.streamId, 'foo'); Future checkContentInputMaxHeight(WidgetTester tester, { required double maxHeight, diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index b1f443c8b5..c220dc0e0c 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -138,7 +138,7 @@ void main() { final navObserver = TestNavigatorObserver() ..onPushed = (route, prevRoute) => pushedRoutes.add(route); final channel = eg.stream(); - await setupMessageListPage(tester, narrow: TopicNarrow(channel.streamId, 'hi'), + await setupMessageListPage(tester, narrow: eg.topicNarrow(channel.streamId, 'hi'), navObservers: [navObserver], streams: [channel], messageCount: 1); @@ -157,7 +157,7 @@ void main() { final channel = eg.stream(); const topic = 'topic'; await setupMessageListPage(tester, - narrow: TopicNarrow(channel.streamId, topic), + narrow: eg.topicNarrow(channel.streamId, topic), streams: [channel], subscriptions: [eg.subscription(channel)], messageCount: 1); await store.handleEvent(eg.userTopicEvent( @@ -661,7 +661,7 @@ void main() { const topic = 'foo'; final channel = eg.stream(); final otherChannel = eg.stream(); - final narrow = TopicNarrow(channel.streamId, topic); + final narrow = eg.topicNarrow(channel.streamId, topic); void prepareGetMessageResponse(List messages) { connection.prepare(json: eg.newestGetMessagesResult( @@ -671,7 +671,7 @@ void main() { void handleMessageMoveEvent(List messages, String newTopic, {int? newChannelId}) { store.handleEvent(eg.updateMessageEventMoveFrom( origMessages: messages, - newTopic: newTopic, + newTopicStr: newTopic, newStreamId: newChannelId, propagateMode: PropagateMode.changeAll)); } @@ -749,7 +749,8 @@ void main() { group('recipient headers', () { group('StreamMessageRecipientHeader', () { final stream = eg.stream(name: 'stream name'); - final message = eg.streamMessage(stream: stream, topic: 'topic name'); + const topic = 'topic name'; + final message = eg.streamMessage(stream: stream, topic: topic); FinderResult findInMessageList(String text) { // Stream name shows up in [AppBar] so need to avoid matching that @@ -808,7 +809,7 @@ void main() { narrow: const CombinedFeedNarrow(), messages: [message], subscriptions: [eg.subscription(stream)]); await store.handleEvent(eg.userTopicEvent( - stream.streamId, message.topic, UserTopicVisibilityPolicy.followed)); + stream.streamId, topic, UserTopicVisibilityPolicy.followed)); await tester.pump(); check(find.descendant( of: find.byType(MessageList), @@ -820,7 +821,7 @@ void main() { narrow: TopicNarrow.ofMessage(message), messages: [message], subscriptions: [eg.subscription(stream, isMuted: true)]); await store.handleEvent(eg.userTopicEvent( - stream.streamId, message.topic, UserTopicVisibilityPolicy.unmuted)); + stream.streamId, topic, UserTopicVisibilityPolicy.unmuted)); await tester.pump(); check(find.descendant( of: find.byType(MessageList), @@ -1134,7 +1135,7 @@ void main() { checkMarkersCount(edited: 1, moved: 0); await store.handleEvent(eg.updateMessageEventMoveFrom( - origMessages: [message, message2], newTopic: 'new')); + origMessages: [message, message2], newTopicStr: 'new')); await tester.pump(); checkMarkersCount(edited: 1, moved: 1); diff --git a/test/widgets/subscription_list_test.dart b/test/widgets/subscription_list_test.dart index 9439bac865..a58124a533 100644 --- a/test/widgets/subscription_list_test.dart +++ b/test/widgets/subscription_list_test.dart @@ -155,7 +155,7 @@ void main() { testWidgets('unread badge shows with unreads', (tester) async { final stream = eg.stream(); final unreadMsgs = eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]), + eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]), ]); await setupStreamListPage(tester, subscriptions: [ eg.subscription(stream), @@ -167,14 +167,14 @@ void main() { testWidgets('unread badge counts unmuted only', (tester) async { final stream = eg.stream(); final unreadMsgs = eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]), - UnreadChannelSnapshot(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]), + eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]), + eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]), ]); await setupStreamListPage(tester, subscriptions: [eg.subscription(stream, isMuted: true)], userTopics: [UserTopicItem( streamId: stream.streamId, - topicName: 'b', + topicName: eg.t('b'), lastUpdated: 1234567890, visibilityPolicy: UserTopicVisibilityPolicy.unmuted, )], @@ -198,7 +198,7 @@ void main() { testWidgets('muted unread badge shows when unreads are visible in channel but not inbox', (tester) async { final stream = eg.stream(); final unreadMsgs = eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]), + eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]), ]); await setupStreamListPage(tester, subscriptions: [eg.subscription(stream, isMuted: true)], @@ -211,7 +211,7 @@ void main() { testWidgets('muted unread badge does not show when unreads are visible in both channel & inbox', (tester) async { final stream = eg.stream(); final unreadMsgs = eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]), + eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]), ]); await setupStreamListPage(tester, subscriptions: [eg.subscription(stream, isMuted: false)], @@ -224,7 +224,7 @@ void main() { testWidgets('muted unread badge does not show when unreads are not visible in channel nor inbox', (tester) async { final stream = eg.stream(); final unreadMsgs = eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]), + eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'b', unreadMessageIds: [3]), ]); await setupStreamListPage(tester, subscriptions: [eg.subscription(stream, isMuted: true)], @@ -237,7 +237,7 @@ void main() { testWidgets('color propagates to icon and badge', (tester) async { final stream = eg.stream(); final unreadMsgs = eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]), + eg.unreadChannelMsgs(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]), ]); final subscription = eg.subscription(stream, color: Colors.red.argbInt); final swatch = ChannelColorSwatch.light(subscription.color); @@ -275,8 +275,8 @@ void main() { eg.userTopicItem(stream2, 'b', UserTopicVisibilityPolicy.unmuted), ], unreadMsgs: eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: stream1.streamId, topic: 'a', unreadMessageIds: [1, 2]), - UnreadChannelSnapshot(streamId: stream2.streamId, topic: 'b', unreadMessageIds: [3]), + eg.unreadChannelMsgs(streamId: stream1.streamId, topic: 'a', unreadMessageIds: [1, 2]), + eg.unreadChannelMsgs(streamId: stream2.streamId, topic: 'b', unreadMessageIds: [3]), ]), ); @@ -310,10 +310,10 @@ void main() { eg.userTopicItem(mutedStreamWithNoUnmutedUnreads, 'd', UserTopicVisibilityPolicy.muted), ], unreadMsgs: eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: unmutedStreamWithUnmutedUnreads.streamId, topic: 'a', unreadMessageIds: [1]), - UnreadChannelSnapshot(streamId: unmutedStreamWithNoUnmutedUnreads.streamId, topic: 'b', unreadMessageIds: [2]), - UnreadChannelSnapshot(streamId: mutedStreamWithUnmutedUnreads.streamId, topic: 'c', unreadMessageIds: [3]), - UnreadChannelSnapshot(streamId: mutedStreamWithNoUnmutedUnreads.streamId, topic: 'd', unreadMessageIds: [4]), + eg.unreadChannelMsgs(streamId: unmutedStreamWithUnmutedUnreads.streamId, topic: 'a', unreadMessageIds: [1]), + eg.unreadChannelMsgs(streamId: unmutedStreamWithNoUnmutedUnreads.streamId, topic: 'b', unreadMessageIds: [2]), + eg.unreadChannelMsgs(streamId: mutedStreamWithUnmutedUnreads.streamId, topic: 'c', unreadMessageIds: [3]), + eg.unreadChannelMsgs(streamId: mutedStreamWithNoUnmutedUnreads.streamId, topic: 'd', unreadMessageIds: [4]), ]), );