From 4983a7ccb4cbbb4f30d73be58e4a47ef60e42244 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 9 Jun 2026 12:18:12 +0430 Subject: [PATCH 01/29] dart [nfc]: Use dot shorthands for MessageType --- lib/api/model/events.dart | 10 +++++----- lib/model/narrow.dart | 2 +- lib/model/recent_senders.dart | 2 +- lib/model/typing_status.dart | 4 ++-- lib/model/unreads.dart | 8 ++++---- test/api/model/events_test.dart | 6 +++--- test/example_data.dart | 10 +++++----- test/model/recent_senders_test.dart | 2 +- test/model/unreads_test.dart | 18 +++++++++--------- 9 files changed, 31 insertions(+), 31 deletions(-) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 0af145330..dcef98e10 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -1423,7 +1423,7 @@ class DeleteMessageEvent extends Event { factory DeleteMessageEvent.fromJson(Map json) { final result = _$DeleteMessageEventFromJson(json); // Crunchy-shell validation - if (result.messageType == MessageType.stream) { + if (result.messageType == .stream) { result.streamId as int; result.topic as String; } @@ -1557,10 +1557,10 @@ class UpdateMessageFlagsMessageDetail { final result = _$UpdateMessageFlagsMessageDetailFromJson(json); // Crunchy-shell validation switch (result.type) { - case MessageType.stream: + case .stream: result.streamId as int; result.topic as String; - case MessageType.direct: + case .direct: result.userIds as List; } return result; @@ -1647,10 +1647,10 @@ class TypingEvent extends Event { final result = _$TypingEventFromJson(json); // Crunchy-shell validation switch (result.messageType) { - case MessageType.stream: + case .stream: result.streamId as int; result.topic as String; - case MessageType.direct: + case .direct: result.recipientIds as List; } return result; diff --git a/lib/model/narrow.dart b/lib/model/narrow.dart index 8042dbee5..aa47b5328 100644 --- a/lib/model/narrow.dart +++ b/lib/model/narrow.dart @@ -241,7 +241,7 @@ class DmNarrow extends Narrow implements SendableNarrow { UpdateMessageFlagsMessageDetail detail, { required int selfUserId, }) { - assert(detail.type == MessageType.direct); + assert(detail.type == .direct); return DmNarrow.withOtherUsers(detail.userIds!, selfUserId: selfUserId); } diff --git a/lib/model/recent_senders.dart b/lib/model/recent_senders.dart index 274d70fd0..c9a8c9626 100644 --- a/lib/model/recent_senders.dart +++ b/lib/model/recent_senders.dart @@ -87,7 +87,7 @@ class RecentSenders { } void handleDeleteMessageEvent(DeleteMessageEvent event, Map cachedMessages) { - if (event.messageType != MessageType.stream) return; + if (event.messageType != .stream) return; final messagesByUser = >{}; for (final id in event.messageIds) { diff --git a/lib/model/typing_status.dart b/lib/model/typing_status.dart index fdb63339b..1552d4a06 100644 --- a/lib/model/typing_status.dart +++ b/lib/model/typing_status.dart @@ -63,9 +63,9 @@ class TypingStatus extends HasRealmStore with ChangeNotifier { void handleTypingEvent(TypingEvent event) { SendableNarrow narrow = switch (event.messageType) { - MessageType.direct => DmNarrow( + .direct => DmNarrow( allRecipientIds: event.recipientIds!, selfUserId: selfUserId), - MessageType.stream => TopicNarrow(event.streamId!, event.topic!), + .stream => TopicNarrow(event.streamId!, event.topic!), }; bool hasUpdate = false; diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart index a45f53f50..c21d8459c 100644 --- a/lib/model/unreads.dart +++ b/lib/model/unreads.dart @@ -412,13 +412,13 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier { void handleDeleteMessageEvent(DeleteMessageEvent event) { mentions.removeAll(event.messageIds); switch (event.messageType) { - case MessageType.stream: + case .stream: // All the messages are in [event.streamId] and [event.topic], // so we can be more efficient than _removeAllInStreamsAndDms. final streamId = event.streamId!; final topic = event.topic!; _removeAllInStreamTopic(Set.of(event.messageIds), streamId, topic); - case MessageType.direct: + case .direct: _removeAllInStreamsAndDms(event.messageIds, expectOnlyDms: true); } for (final messageId in event.messageIds) { @@ -490,13 +490,13 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier { mentions.add(messageId); } switch (detail.type) { - case MessageType.stream: + case .stream: final UpdateMessageFlagsMessageDetail(:streamId, :topic) = detail; locatorMap[messageId] = TopicNarrow(streamId!, topic!); final topics = (newlyUnreadInStreams[streamId] ??= makeTopicKeyedMap()); final messageIds = (topics[topic] ??= QueueList()); messageIds.add(messageId); - case MessageType.direct: + case .direct: final narrow = DmNarrow.ofUpdateMessageFlagsMessageDetail(selfUserId: selfUserId, detail); locatorMap[messageId] = narrow; diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart index 234915c39..81fce4b4a 100644 --- a/test/api/model/events_test.dart +++ b/test/api/model/events_test.dart @@ -275,7 +275,7 @@ void main() { 'type': 'delete_message', 'message_ids': [1, 2, 3], 'message_type': 'private', - })).messageType.equals(MessageType.direct); + })).messageType.equals(.direct); }); group('update_message_flags/remove', () { @@ -310,7 +310,7 @@ void main() { ...messageDetail, 'type': 'private', }}})).messageDetails.isNotNull() - .values.single.type.equals(MessageType.direct); + .values.single.type.equals(.direct); }); }); @@ -343,7 +343,7 @@ void main() { check(TypingEvent.fromJson({ ...directMessageJson, 'message_type': 'private', - })).messageType.equals(MessageType.direct); + })).messageType.equals(.direct); }); test('stream type missing streamId/topic', () { diff --git a/test/example_data.dart b/test/example_data.dart index 820f00497..d08d115c5 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1107,7 +1107,7 @@ DeleteMessageEvent deleteMessageEvent(List messages) { return DeleteMessageEvent( id: 0, messageIds: messages.map((message) => message.id).toList(), - messageType: MessageType.stream, + messageType: .stream, streamId: messages[0].streamId, topic: messages[0].topic, ); @@ -1255,14 +1255,14 @@ UpdateMessageFlagsRemoveEvent updateMessageFlagsRemoveEvent( message.id, switch (message) { StreamMessage() => UpdateMessageFlagsMessageDetail( - type: MessageType.stream, + type: .stream, mentioned: mentioned, streamId: message.streamId, topic: message.topic, userIds: null, ), DmMessage() => UpdateMessageFlagsMessageDetail( - type: MessageType.direct, + type: .direct, mentioned: mentioned, streamId: null, topic: null, @@ -1293,13 +1293,13 @@ TypingEvent typingEvent(SendableNarrow narrow, TypingOp op, int senderId) { switch (narrow) { case TopicNarrow(): return TypingEvent(id: 0, op: op, senderId: senderId, - messageType: MessageType.stream, + messageType: .stream, streamId: narrow.channelId, topic: narrow.topic, recipientIds: null); case DmNarrow(): return TypingEvent(id: 0, op: op, senderId: senderId, - messageType: MessageType.direct, + messageType: .direct, recipientIds: narrow.allRecipientIds, streamId: null, topic: null); diff --git a/test/model/recent_senders_test.dart b/test/model/recent_senders_test.dart index e8435fb17..0203a980c 100644 --- a/test/model/recent_senders_test.dart +++ b/test/model/recent_senders_test.dart @@ -179,7 +179,7 @@ void main() { model.handleDeleteMessageEvent(DeleteMessageEvent( id: 0, messageIds: [messages[1].id], - messageType: MessageType.stream, + messageType: .stream, streamId: stream.streamId, topic: eg.t('oThEr'), ), {messages[1].id: messages[1]}); diff --git a/test/model/unreads_test.dart b/test/model/unreads_test.dart index 1d5261311..244d6bef3 100644 --- a/test/model/unreads_test.dart +++ b/test/model/unreads_test.dart @@ -855,7 +855,7 @@ void main() { final event = switch (message) { StreamMessage() => DeleteMessageEvent( id: 0, - messageType: MessageType.stream, + messageType: .stream, messageIds: [message.id], streamId: message.streamId, topic: () { @@ -867,7 +867,7 @@ void main() { ), DmMessage() => DeleteMessageEvent( id: 0, - messageType: MessageType.direct, + messageType: .direct, messageIds: [message.id], streamId: null, topic: null, @@ -888,7 +888,7 @@ void main() { model.handleDeleteMessageEvent(DeleteMessageEvent( id: 0, messageIds: [11, 12], - messageType: MessageType.stream, + messageType: .stream, streamId: stream1.streamId, topic: eg.t('a'), )); @@ -897,7 +897,7 @@ void main() { model.handleDeleteMessageEvent(DeleteMessageEvent( id: 0, messageIds: [13, 14], - messageType: MessageType.stream, + messageType: .stream, streamId: stream2.streamId, topic: eg.t('b'), )); @@ -906,7 +906,7 @@ void main() { model.handleDeleteMessageEvent(DeleteMessageEvent( id: 0, messageIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], - messageType: MessageType.direct, + messageType: .direct, streamId: null, topic: null, )); @@ -922,7 +922,7 @@ void main() { model.handleDeleteMessageEvent(DeleteMessageEvent( id: 0, messageIds: [message.id], - messageType: MessageType.stream, + messageType: .stream, streamId: message.streamId, topic: message.topic, )); @@ -941,7 +941,7 @@ void main() { model.handleDeleteMessageEvent(DeleteMessageEvent( id: 0, messageIds: [message.id], - messageType: MessageType.direct, + messageType: .direct, streamId: null, topic: null, )); @@ -1373,7 +1373,7 @@ void main() { messages: [message1.id, message2.id, message3.id, message4.id], messageDetails: { message1.id: UpdateMessageFlagsMessageDetail( - type: MessageType.stream, + type: .stream, mentioned: false, streamId: stream.streamId, topic: eg.t(topic), @@ -1381,7 +1381,7 @@ void main() { ), // message 2 and 3 have their details missing message4.id: UpdateMessageFlagsMessageDetail( - type: MessageType.direct, + type: .direct, mentioned: false, streamId: null, topic: null, From 80bed4b8ec848733b6d5e6ac9a7fccdff4a7f322 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 9 Jun 2026 12:20:41 +0430 Subject: [PATCH 02/29] api [nfc]: Move MessageType next to Message We'll soon change Message.type to use MessageType instead of String. This will make it compatible with the modern values "channel" and "direct" when the server starts sending them in the future. Having it next to Message seems more appropriate than its previous location. --- lib/api/model/events.dart | 24 ------------------------ lib/api/model/events.g.dart | 5 ----- lib/api/model/model.dart | 24 ++++++++++++++++++++++++ lib/api/model/model.g.dart | 5 +++++ 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index dcef98e10..06cab28c8 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -1434,30 +1434,6 @@ class DeleteMessageEvent extends Event { Map toJson() => _$DeleteMessageEventToJson(this); } -/// As in [DeleteMessageEvent.messageType], -/// [UpdateMessageFlagsMessageDetail.type], -/// or [TypingEvent.messageType]. -@JsonEnum(alwaysCreate: true) -enum MessageType { - stream, - direct; -} - -class MessageTypeConverter extends JsonConverter { - const MessageTypeConverter(); - - @override - MessageType fromJson(String json) { - if (json == 'private') json = 'direct'; // TODO(server-future) - return $enumDecode(_$MessageTypeEnumMap, json); - } - - @override - String toJson(MessageType object) { - return _$MessageTypeEnumMap[object]!; - } -} - /// A Zulip event of type `update_message_flags`. /// /// For the corresponding API docs, see subclasses. diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index b6d46e49c..2ba69a7cc 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -1167,8 +1167,3 @@ HeartbeatEvent _$HeartbeatEventFromJson(Map json) => Map _$HeartbeatEventToJson(HeartbeatEvent instance) => {'id': instance.id, 'type': instance.type}; - -const _$MessageTypeEnumMap = { - MessageType.stream: 'stream', - MessageType.direct: 'direct', -}; diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 271985a58..27dc4a631 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -1289,6 +1289,30 @@ sealed class Message extends MessageBase { Map toJson(); } +/// As in [DeleteMessageEvent.messageType], +/// [UpdateMessageFlagsMessageDetail.type], +/// or [TypingEvent.messageType]. +@JsonEnum(alwaysCreate: true) +enum MessageType { + stream, + direct; +} + +class MessageTypeConverter extends JsonConverter { + const MessageTypeConverter(); + + @override + MessageType fromJson(String json) { + if (json == 'private') json = 'direct'; // TODO(server-future) + return $enumDecode(_$MessageTypeEnumMap, json); + } + + @override + String toJson(MessageType object) { + return _$MessageTypeEnumMap[object]!; + } +} + /// https://zulip.com/api/update-message-flags#available-flags @JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true) enum MessageFlag { diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 4e71852af..d6673319b 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -617,6 +617,11 @@ const _$SubscriptionPropertyEnumMap = { SubscriptionProperty.unknown: 'unknown', }; +const _$MessageTypeEnumMap = { + MessageType.stream: 'stream', + MessageType.direct: 'direct', +}; + const _$MessageFlagEnumMap = { MessageFlag.read: 'read', MessageFlag.starred: 'starred', From bb5b96af77c2c19f28ab9cd030e4e538897dcaa9 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 9 Jun 2026 12:33:42 +0430 Subject: [PATCH 03/29] api [nfc]: Move MessageTypeConverter.fromJson logic to MessageType.fromJson And change MessageTypeConverter.fromJson to forward to MessageType.fromJson. In future commits, we can use the compatibility logic independent of MessageTypeConverter; simply by using MessageType.fromJson. --- lib/api/model/model.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 27dc4a631..7db8f761f 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -1296,6 +1296,11 @@ sealed class Message extends MessageBase { enum MessageType { stream, direct; + + factory fromJson(String json) { + if (json == 'private') json = 'direct'; // TODO(server-future) + return $enumDecode(_$MessageTypeEnumMap, json); + } } class MessageTypeConverter extends JsonConverter { @@ -1303,8 +1308,7 @@ class MessageTypeConverter extends JsonConverter { @override MessageType fromJson(String json) { - if (json == 'private') json = 'direct'; // TODO(server-future) - return $enumDecode(_$MessageTypeEnumMap, json); + return MessageType.fromJson(json); } @override From f854928a95f7f5520d8cc2f86a9fa7d954c3e7e6 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 9 Jun 2026 14:31:58 +0430 Subject: [PATCH 04/29] api: Replace `stream` with `channel` for MessageType As of 2026-06, the server only sends "stream" type we already support. This change is for accepting the modern "channel" type that the server may start sending in the future. --- lib/api/model/events.dart | 18 +++--- lib/api/model/model.dart | 3 +- lib/api/model/model.g.dart | 2 +- lib/model/recent_senders.dart | 2 +- lib/model/typing_status.dart | 2 +- lib/model/unreads.dart | 4 +- test/api/model/events_test.dart | 92 ++++++++++++++++++++++------- test/example_data.dart | 6 +- test/model/recent_senders_test.dart | 2 +- test/model/unreads_test.dart | 10 ++-- 10 files changed, 97 insertions(+), 44 deletions(-) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 06cab28c8..2f0164858 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -1405,8 +1405,9 @@ class DeleteMessageEvent extends Event { final List messageIds; // final int messageId; // Not present; we support the bulk_message_deletion capability - // The server never actually sends "direct" here yet (it's "private" instead), - // but we accept both forms for forward-compatibility. + // The server never actually sends "channel" or "direct" here yet + // (it's "stream" or "private" instead), but we accept both the old + // and new forms for forward-compatibility. @MessageTypeConverter() final MessageType messageType; final int? streamId; @@ -1423,7 +1424,7 @@ class DeleteMessageEvent extends Event { factory DeleteMessageEvent.fromJson(Map json) { final result = _$DeleteMessageEventFromJson(json); // Crunchy-shell validation - if (result.messageType == .stream) { + if (result.messageType == .channel) { result.streamId as int; result.topic as String; } @@ -1512,8 +1513,9 @@ class UpdateMessageFlagsRemoveEvent extends UpdateMessageFlagsEvent { /// As in [UpdateMessageFlagsRemoveEvent.messageDetails]. @JsonSerializable(fieldRename: FieldRename.snake) class UpdateMessageFlagsMessageDetail { - // The server never actually sends "direct" here yet (it's "private" instead), - // but we accept both forms for forward-compatibility. + // The server never actually sends "channel" or "direct" here yet + // (it's "stream" or "private" instead), but we accept both the old + // and new forms for forward-compatibility. @MessageTypeConverter() final MessageType type; final bool? mentioned; @@ -1533,7 +1535,7 @@ class UpdateMessageFlagsMessageDetail { final result = _$UpdateMessageFlagsMessageDetailFromJson(json); // Crunchy-shell validation switch (result.type) { - case .stream: + case .channel: result.streamId as int; result.topic as String; case .direct: @@ -1590,6 +1592,8 @@ class TypingEvent extends Event { String get type => 'typing'; final TypingOp op; + // The server never actually sends "channel" here yet (it's "stream" instead), + // but we accept both forms for forward-compatibility. @MessageTypeConverter() final MessageType messageType; @JsonKey(readValue: _readSenderId) @@ -1623,7 +1627,7 @@ class TypingEvent extends Event { final result = _$TypingEventFromJson(json); // Crunchy-shell validation switch (result.messageType) { - case .stream: + case .channel: result.streamId as int; result.topic as String; case .direct: diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 7db8f761f..92dca6800 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -1294,10 +1294,11 @@ sealed class Message extends MessageBase { /// or [TypingEvent.messageType]. @JsonEnum(alwaysCreate: true) enum MessageType { - stream, + channel, direct; factory fromJson(String json) { + if (json == 'stream') json = 'channel'; // TODO(server-future) if (json == 'private') json = 'direct'; // TODO(server-future) return $enumDecode(_$MessageTypeEnumMap, json); } diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index d6673319b..1e55f49dc 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -618,7 +618,7 @@ const _$SubscriptionPropertyEnumMap = { }; const _$MessageTypeEnumMap = { - MessageType.stream: 'stream', + MessageType.channel: 'channel', MessageType.direct: 'direct', }; diff --git a/lib/model/recent_senders.dart b/lib/model/recent_senders.dart index c9a8c9626..2e2d4b617 100644 --- a/lib/model/recent_senders.dart +++ b/lib/model/recent_senders.dart @@ -87,7 +87,7 @@ class RecentSenders { } void handleDeleteMessageEvent(DeleteMessageEvent event, Map cachedMessages) { - if (event.messageType != .stream) return; + if (event.messageType != .channel) return; final messagesByUser = >{}; for (final id in event.messageIds) { diff --git a/lib/model/typing_status.dart b/lib/model/typing_status.dart index 1552d4a06..d51bc3107 100644 --- a/lib/model/typing_status.dart +++ b/lib/model/typing_status.dart @@ -65,7 +65,7 @@ class TypingStatus extends HasRealmStore with ChangeNotifier { SendableNarrow narrow = switch (event.messageType) { .direct => DmNarrow( allRecipientIds: event.recipientIds!, selfUserId: selfUserId), - .stream => TopicNarrow(event.streamId!, event.topic!), + .channel => TopicNarrow(event.streamId!, event.topic!), }; bool hasUpdate = false; diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart index c21d8459c..5e8096155 100644 --- a/lib/model/unreads.dart +++ b/lib/model/unreads.dart @@ -412,7 +412,7 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier { void handleDeleteMessageEvent(DeleteMessageEvent event) { mentions.removeAll(event.messageIds); switch (event.messageType) { - case .stream: + case .channel: // All the messages are in [event.streamId] and [event.topic], // so we can be more efficient than _removeAllInStreamsAndDms. final streamId = event.streamId!; @@ -490,7 +490,7 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier { mentions.add(messageId); } switch (detail.type) { - case .stream: + case .channel: final UpdateMessageFlagsMessageDetail(:streamId, :topic) = detail; locatorMap[messageId] = TopicNarrow(streamId!, topic!); final topics = (newlyUnreadInStreams[streamId] ??= makeTopicKeyedMap()); diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart index 81fce4b4a..3494ea25e 100644 --- a/test/api/model/events_test.dart +++ b/test/api/model/events_test.dart @@ -245,6 +245,7 @@ void main() { 'message_type': 'private', })).returnsNormally(); + // TODO(server-future): remove final baseJsonStream = { 'id': 1, 'type': 'delete_message', @@ -252,21 +253,31 @@ void main() { 'message_type': 'stream', }; - check(() => DeleteMessageEvent.fromJson({ - ...baseJsonStream - })).throws(); + // Future server. + final baseJsonChannel = { + 'id': 1, + 'type': 'delete_message', + 'message_ids': [1, 2, 3], + 'message_type': 'channel', + }; - check(() => DeleteMessageEvent.fromJson({ - ...baseJsonStream, 'stream_id': 1, 'topic': 'some topic', - })).returnsNormally(); + for (final baseJson in [baseJsonStream, baseJsonChannel]) { + check(() => DeleteMessageEvent.fromJson({ + ...baseJson + })).throws(); - check(() => DeleteMessageEvent.fromJson({ - ...baseJsonStream, 'stream_id': 1, - })).throws(); + check(() => DeleteMessageEvent.fromJson({ + ...baseJson, 'stream_id': 1, 'topic': 'some topic', + })).returnsNormally(); - check(() => DeleteMessageEvent.fromJson({ - ...baseJsonStream, 'topic': 'some topic', - })).throws(); + check(() => DeleteMessageEvent.fromJson({ + ...baseJson, 'stream_id': 1, + })).throws(); + + check(() => DeleteMessageEvent.fromJson({ + ...baseJson, 'topic': 'some topic', + })).throws(); + } }); test('delete_message: private -> direct', () { @@ -278,6 +289,17 @@ void main() { })).messageType.equals(.direct); }); + test('delete_message: stream -> channel', () { + check(DeleteMessageEvent.fromJson({ + 'id': 1, + 'type': 'delete_message', + 'message_ids': [1, 2, 3], + 'message_type': 'stream', + 'stream_id': 1, + 'topic': 'some topic', + })).messageType.equals(.channel); + }); + group('update_message_flags/remove', () { final baseJson = { 'id': 1, @@ -312,6 +334,16 @@ void main() { }}})).messageDetails.isNotNull() .values.single.type.equals(.direct); }); + + test('stream -> channel', () { + check(UpdateMessageFlagsRemoveEvent.fromJson({ + ...baseJson, + 'flag': 'read', + 'message_details': { + '123': {'type': 'stream', 'mentioned': false, 'stream_id': 1, 'topic': 'some topic'} + }})).messageDetails.isNotNull() + .values.single.type.equals(.channel); + }); }); group('typing status event', () { @@ -346,16 +378,32 @@ void main() { })).messageType.equals(.direct); }); - test('stream type missing streamId/topic', () { - check(() => TypingEvent.fromJson({ - ...baseJson, 'message_type': 'stream', 'stream_id': 123, 'topic': 'foo'})) - .returnsNormally(); - check(() => TypingEvent.fromJson({ - ...baseJson, 'message_type': 'stream'})).throws(); - check(() => TypingEvent.fromJson({ - ...baseJson, 'message_type': 'stream', 'topic': 'foo'})).throws(); - check(() => TypingEvent.fromJson({ - ...baseJson, 'message_type': 'stream', 'stream_id': 123})).throws(); + test('stream/channel type missing streamId/topic', () { + // TODO(server-future): remove + final baseJsonStream = {...baseJson, 'message_type': 'stream'}; + // Future server. + final baseJsonChannel = {...baseJson, 'message_type': 'channel'}; + + for (final baseJson in [baseJsonStream, baseJsonChannel]) { + check(() => TypingEvent.fromJson({ + ...baseJson, 'stream_id': 123, 'topic': 'foo'})) + .returnsNormally(); + check(() => TypingEvent.fromJson({ + ...baseJson})).throws(); + check(() => TypingEvent.fromJson({ + ...baseJson, 'topic': 'foo'})).throws(); + check(() => TypingEvent.fromJson({ + ...baseJson, 'stream_id': 123})).throws(); + } + }); + + test('stream -> channel', () { + check(TypingEvent.fromJson({ + ...baseJson, + 'stream_id': 123, + 'topic': 'foo', + 'message_type': 'stream', + })).messageType.equals(.channel); }); test('direct type sort recipient ids', () { diff --git a/test/example_data.dart b/test/example_data.dart index d08d115c5..2d5500d2e 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1107,7 +1107,7 @@ DeleteMessageEvent deleteMessageEvent(List messages) { return DeleteMessageEvent( id: 0, messageIds: messages.map((message) => message.id).toList(), - messageType: .stream, + messageType: .channel, streamId: messages[0].streamId, topic: messages[0].topic, ); @@ -1255,7 +1255,7 @@ UpdateMessageFlagsRemoveEvent updateMessageFlagsRemoveEvent( message.id, switch (message) { StreamMessage() => UpdateMessageFlagsMessageDetail( - type: .stream, + type: .channel, mentioned: mentioned, streamId: message.streamId, topic: message.topic, @@ -1293,7 +1293,7 @@ TypingEvent typingEvent(SendableNarrow narrow, TypingOp op, int senderId) { switch (narrow) { case TopicNarrow(): return TypingEvent(id: 0, op: op, senderId: senderId, - messageType: .stream, + messageType: .channel, streamId: narrow.channelId, topic: narrow.topic, recipientIds: null); diff --git a/test/model/recent_senders_test.dart b/test/model/recent_senders_test.dart index 0203a980c..62e2f4a62 100644 --- a/test/model/recent_senders_test.dart +++ b/test/model/recent_senders_test.dart @@ -179,7 +179,7 @@ void main() { model.handleDeleteMessageEvent(DeleteMessageEvent( id: 0, messageIds: [messages[1].id], - messageType: .stream, + messageType: .channel, streamId: stream.streamId, topic: eg.t('oThEr'), ), {messages[1].id: messages[1]}); diff --git a/test/model/unreads_test.dart b/test/model/unreads_test.dart index 244d6bef3..81ff3c5ec 100644 --- a/test/model/unreads_test.dart +++ b/test/model/unreads_test.dart @@ -855,7 +855,7 @@ void main() { final event = switch (message) { StreamMessage() => DeleteMessageEvent( id: 0, - messageType: .stream, + messageType: .channel, messageIds: [message.id], streamId: message.streamId, topic: () { @@ -888,7 +888,7 @@ void main() { model.handleDeleteMessageEvent(DeleteMessageEvent( id: 0, messageIds: [11, 12], - messageType: .stream, + messageType: .channel, streamId: stream1.streamId, topic: eg.t('a'), )); @@ -897,7 +897,7 @@ void main() { model.handleDeleteMessageEvent(DeleteMessageEvent( id: 0, messageIds: [13, 14], - messageType: .stream, + messageType: .channel, streamId: stream2.streamId, topic: eg.t('b'), )); @@ -922,7 +922,7 @@ void main() { model.handleDeleteMessageEvent(DeleteMessageEvent( id: 0, messageIds: [message.id], - messageType: .stream, + messageType: .channel, streamId: message.streamId, topic: message.topic, )); @@ -1373,7 +1373,7 @@ void main() { messages: [message1.id, message2.id, message3.id, message4.id], messageDetails: { message1.id: UpdateMessageFlagsMessageDetail( - type: .stream, + type: .channel, mentioned: false, streamId: stream.streamId, topic: eg.t(topic), From 9c56c05b9f5d7c22288e76080f95ac37809271f3 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 9 Jun 2026 14:48:23 +0430 Subject: [PATCH 05/29] api: Change Message.type to `MessageType`, from `String` This way, it can also accept "channel" and "direct" as the valid values for the message type when the server starts sending them in the future. As of 2026-06, the server only sends "stream" and "private" for message types. Now, we map them to "channel" and "direct" in deserialization. --- lib/api/model/model.dart | 22 ++++++++++++---------- lib/api/model/model.g.dart | 14 +++++++------- test/api/model/model_checks.dart | 2 +- test/api/model/model_test.dart | 27 +++++++++++++++++++++++++++ test/example_data.dart | 4 ++-- 5 files changed, 49 insertions(+), 20 deletions(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 92dca6800..08904778f 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -1221,7 +1221,10 @@ sealed class Message extends MessageBase { @JsonKey(name: 'submessages', readValue: _readPoll, fromJson: Poll.fromJson, toJson: Poll.toJson) Poll? poll; - String get type; + // The server never actually sends "channel" or "direct" here yet + // (it's "stream" or "private" instead), but we accept both the old + // and new forms for forward-compatibility. + MessageType get type; // final List topicLinks; // TODO handle // final string type; // handled by runtime type of object @@ -1280,18 +1283,17 @@ sealed class Message extends MessageBase { // TODO(dart): This has to be a static method, because factories/constructors // do not support type parameters: https://github.com/dart-lang/language/issues/647 static Message fromJson(Map json) { - final type = json['type'] as String; - if (type == 'stream') return StreamMessage.fromJson(json); - if (type == 'private') return DmMessage.fromJson(json); - throw Exception("Message.fromJson: unexpected message type $type"); + return switch (MessageType.fromJson(json['type'] as String)) { + .channel => StreamMessage.fromJson(json), + .direct => DmMessage.fromJson(json), + }; } Map toJson(); } -/// As in [DeleteMessageEvent.messageType], -/// [UpdateMessageFlagsMessageDetail.type], -/// or [TypingEvent.messageType]. +/// As in [Message.type], [DeleteMessageEvent.messageType], +/// [UpdateMessageFlagsMessageDetail.type], or [TypingEvent.messageType]. @JsonEnum(alwaysCreate: true) enum MessageType { channel, @@ -1368,7 +1370,7 @@ enum MessageFlag { class StreamMessage extends Message { @override @JsonKey(includeToJson: true) - String get type => 'stream'; + MessageType get type => .channel; @JsonKey(includeToJson: true) int get streamId => conversation.streamId; @@ -1422,7 +1424,7 @@ class StreamMessage extends Message { class DmMessage extends Message { @override @JsonKey(includeToJson: true) - String get type => 'private'; + MessageType get type => .direct; /// The user IDs of all users in the thread, sorted numerically, as in /// `display_recipient` from the server. diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 1e55f49dc..0921f0840 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -504,7 +504,7 @@ Map _$StreamMessageToJson(StreamMessage instance) => 'flags': instance.flags, 'match_content': instance.matchContent, 'match_subject': instance.matchTopic, - 'type': instance.type, + 'type': _$MessageTypeEnumMap[instance.type]!, 'stream_id': instance.streamId, 'subject': instance.topic, 'display_recipient': instance.displayRecipient, @@ -516,6 +516,11 @@ const _$MessageEditStateEnumMap = { MessageEditState.moved: 'moved', }; +const _$MessageTypeEnumMap = { + MessageType.channel: 'channel', + MessageType.direct: 'direct', +}; + DmMessage _$DmMessageFromJson(Map json) => DmMessage( client: json['client'] as String, content: json['content'] as String, @@ -558,7 +563,7 @@ Map _$DmMessageToJson(DmMessage instance) => { 'flags': instance.flags, 'match_content': instance.matchContent, 'match_subject': instance.matchTopic, - 'type': instance.type, + 'type': _$MessageTypeEnumMap[instance.type]!, 'display_recipient': DmMessage._allRecipientIdsToJson( instance.allRecipientIds, ), @@ -617,11 +622,6 @@ const _$SubscriptionPropertyEnumMap = { SubscriptionProperty.unknown: 'unknown', }; -const _$MessageTypeEnumMap = { - MessageType.channel: 'channel', - MessageType.direct: 'direct', -}; - const _$MessageFlagEnumMap = { MessageFlag.read: 'read', MessageFlag.starred: 'starred', diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index dddd4d8a9..0aebdb256 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -109,7 +109,7 @@ extension MessageChecks on Subject { Subject get senderFullName => has((e) => e.senderFullName, 'senderFullName'); Subject get senderRealmStr => has((e) => e.senderRealmStr, 'senderRealmStr'); Subject get poll => has((e) => e.poll, 'poll'); - Subject get type => has((e) => e.type, 'type'); + Subject get type => has((e) => e.type, 'type'); Subject> get flags => has((e) => e.flags, 'flags'); Subject get matchContent => has((e) => e.matchContent, 'matchContent'); Subject get matchTopic => has((e) => e.matchTopic, 'matchTopic'); diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index f04bbd6d4..49df045e7 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -149,6 +149,33 @@ void main() { Map baseStreamJson() => deepToJson(eg.streamMessage()) as Map; + Map baseDmJson() => + deepToJson(eg.dmMessage(from: eg.selfUser, to: [])) as Map; + + test('type: stream -> channel', () { + check(baseStreamJson()['type']).equals('channel'); + check(Message.fromJson(baseStreamJson())) + .isA() + .type.equals(.channel); + + check(Message.fromJson(baseStreamJson() + ..['type'] = 'stream' + )).isA() + .type.equals(.channel); + }); + + test('type: private -> direct', () { + check(baseDmJson()['type']).equals('direct'); + check(Message.fromJson(baseDmJson())) + .isA() + .type.equals(.direct); + + check(Message.fromJson(baseDmJson() + ..['type'] = 'private' + )).isA() + .type.equals(.direct); + }); + test('subject -> topic', () { check(baseStreamJson()).not((it) => it.containsKey('topic')); check(Message.fromJson(baseStreamJson() diff --git a/test/example_data.dart b/test/example_data.dart index 2d5500d2e..38f7c0dd3 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -801,7 +801,7 @@ StreamMessage streamMessage({ 'subject': topic ?? _defaultTopic, 'submessages': submessages ?? [], 'timestamp': timestamp ?? 1678139636, - 'type': 'stream', + 'type': 'channel', 'match_content': matchContent, 'match_subject': matchTopic, }) as Map); @@ -844,7 +844,7 @@ DmMessage dmMessage({ 'subject': '', 'submessages': submessages ?? [], 'timestamp': timestamp ?? 1678139636, - 'type': 'private', + 'type': 'direct', }) as Map); } From 1aa4aaca33bffd5112f031c7c804d254ffa05f4a Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 9 Jun 2026 22:17:35 +0430 Subject: [PATCH 06/29] api: Explain why MessageType cannot be unknown --- lib/api/model/model.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 08904778f..7f5a4a33d 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -1297,7 +1297,14 @@ sealed class Message extends MessageBase { @JsonEnum(alwaysCreate: true) enum MessageType { channel, - direct; + direct, + + // We don't accept an unknown message type because we generally cannot decide + // how to interpret such data. In particular, when a message with an unknown + // type is received from the server, we cannot determine which message model + // to instantiate: either [StreamMessage] or [DmMessage]. See [Message.fromJson]. + // unknown, + ; factory fromJson(String json) { if (json == 'stream') json = 'channel'; // TODO(server-future) From 945522dc457f4a4aa593cd3ce6dfa505d0fe508a Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 9 Jun 2026 22:52:18 +0430 Subject: [PATCH 07/29] api: In sendMessage, use "channel" when supported The modern type "channel" was added in server-9 (FL-248). --- lib/api/route/messages.dart | 3 ++- test/api/route/messages_test.dart | 19 +++++++++++++++++-- test/model/message_test.dart | 2 +- test/widgets/compose_box_test.dart | 2 +- test/widgets/message_list_test.dart | 2 +- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 6724b6236..8a83caf90 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -134,12 +134,13 @@ Future sendMessage( String? localId, bool? readBySender, }) { + final supportsTypeChannel = connection.zulipFeatureLevel! >= 248; // TODO(server-9) final supportsTypeDirect = connection.zulipFeatureLevel! >= 174; // TODO(server-7) final supportsReadBySender = connection.zulipFeatureLevel! >= 236; // TODO(server-8) return connection.post('sendMessage', SendMessageResult.fromJson, 'messages', { ...(switch (destination) { StreamDestination() => { - 'type': RawParameter('stream'), + 'type': supportsTypeChannel ? RawParameter('channel') : RawParameter('stream'), 'to': destination.streamId, 'topic': RawParameter(destination.topic.apiName), }, diff --git a/test/api/route/messages_test.dart b/test/api/route/messages_test.dart index 1b4f86c62..0395645d0 100644 --- a/test/api/route/messages_test.dart +++ b/test/api/route/messages_test.dart @@ -286,7 +286,7 @@ void main() { localId: '456', readBySender: true, expectedBodyFields: { - 'type': 'stream', + 'type': 'channel', 'to': streamId.toString(), 'topic': topic, 'content': content, @@ -299,6 +299,21 @@ void main() { test('to stream', () { return FakeApiConnection.with_((connection) async { + await checkSendMessage(connection, + destination: StreamDestination(streamId, eg.t(topic)), content: content, + readBySender: true, + expectedBodyFields: { + 'type': 'channel', + 'to': streamId.toString(), + 'topic': topic, + 'content': content, + 'read_by_sender': 'true', + }); + }); + }); + + test('to stream, with legacy type "stream"', () { + return FakeApiConnection.with_(zulipFeatureLevel: 247, (connection) async { await checkSendMessage(connection, destination: StreamDestination(streamId, eg.t(topic)), content: content, readBySender: true, @@ -347,7 +362,7 @@ void main() { destination: StreamDestination(streamId, eg.t(topic)), content: content, readBySender: null, expectedBodyFields: { - 'type': 'stream', + 'type': 'channel', 'to': streamId.toString(), 'topic': topic, 'content': content, diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 9b8517c6d..bba0bce27 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -159,7 +159,7 @@ void main() { ..method.equals('POST') ..url.path.equals('/api/v1/messages') ..bodyFields.deepEquals({ - 'type': 'stream', + 'type': 'channel', 'to': stream.streamId.toString(), 'topic': 'world', 'content': 'hello', diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 22dfeecc3..603589965 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -1027,7 +1027,7 @@ void main() { ..method.equals('POST') ..url.path.equals('/api/v1/messages') ..bodyFields.deepEquals({ - 'type': 'stream', + 'type': 'channel', 'to': '123', 'topic': 'some topic', 'content': 'hello world', diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index f703aae43..246d1992a 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1681,7 +1681,7 @@ void main() { ..method.equals('POST') ..url.path.equals('/api/v1/messages') ..bodyFields.deepEquals({ - 'type': 'stream', + 'type': 'channel', 'to': '${otherChannel.streamId}', 'topic': 'new topic', 'content': 'Some text', From acf3ce5df0f407d9b7562b1a12092d9bbff184aa Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 9 Jun 2026 23:45:42 +0430 Subject: [PATCH 08/29] dart [nfc]: Use dot shorthands for TypingOp --- lib/model/typing_status.dart | 8 ++-- test/api/route/typing_test.dart | 12 +++--- test/model/typing_status_test.dart | 62 ++++++++++++++--------------- test/widgets/compose_box_test.dart | 24 +++++------ test/widgets/message_list_test.dart | 18 ++++----- 5 files changed, 62 insertions(+), 62 deletions(-) diff --git a/lib/model/typing_status.dart b/lib/model/typing_status.dart index d51bc3107..d7b80d550 100644 --- a/lib/model/typing_status.dart +++ b/lib/model/typing_status.dart @@ -70,9 +70,9 @@ class TypingStatus extends HasRealmStore with ChangeNotifier { bool hasUpdate = false; switch (event.op) { - case TypingOp.start: + case .start: hasUpdate = _addTypist(narrow, event.senderId); - case TypingOp.stop: + case .stop: hasUpdate = _removeTypist(narrow, event.senderId); } @@ -176,7 +176,7 @@ class TypingNotifier extends HasRealmStore { unawaited(setTypingStatus( connection, - op: TypingOp.start, + op: .start, destination: _currentDestination!.destination)); } @@ -190,7 +190,7 @@ class TypingNotifier extends HasRealmStore { unawaited(setTypingStatus( connection, - op: TypingOp.stop, + op: .stop, destination: destination.destination)); } diff --git a/test/api/route/typing_test.dart b/test/api/route/typing_test.dart index 551f1a5a4..c6210f0a6 100644 --- a/test/api/route/typing_test.dart +++ b/test/api/route/typing_test.dart @@ -43,16 +43,16 @@ void main() { } test('send typing status start for topic', () { - return checkSetTypingStatusForTopic(TypingOp.start, 'start'); + return checkSetTypingStatusForTopic(.start, 'start'); }); test('send typing status stop for topic', () { - return checkSetTypingStatusForTopic(TypingOp.stop, 'stop'); + return checkSetTypingStatusForTopic(.stop, 'stop'); }); test('send typing status start for dm', () { return FakeApiConnection.with_((connection) { - return checkSetTypingStatus(connection, TypingOp.start, + return checkSetTypingStatus(connection, .start, destination: const DmDestination(userIds: userIds), expectedBodyFields: { 'op': 'start', @@ -64,7 +64,7 @@ void main() { test('legacy: use "stream" instead of "channel"', () { return FakeApiConnection.with_(zulipFeatureLevel: 247, (connection) { - return checkSetTypingStatus(connection, TypingOp.start, + return checkSetTypingStatus(connection, .start, destination: const StreamDestination(streamId, TopicName(topic)), expectedBodyFields: { 'op': 'start', @@ -77,7 +77,7 @@ void main() { test('legacy: use to=[streamId] instead of stream_id=streamId', () { return FakeApiConnection.with_(zulipFeatureLevel: 214, (connection) { - return checkSetTypingStatus(connection, TypingOp.start, + return checkSetTypingStatus(connection, .start, destination: const StreamDestination(streamId, TopicName(topic)), expectedBodyFields: { 'op': 'start', @@ -90,7 +90,7 @@ void main() { test('legacy: use "private" instead of "direct"', () { return FakeApiConnection.with_(zulipFeatureLevel: 173, (connection) { - return checkSetTypingStatus(connection, TypingOp.start, + return checkSetTypingStatus(connection, .start, destination: const DmDestination(userIds: userIds), expectedBodyFields: { 'op': 'start', diff --git a/test/model/typing_status_test.dart b/test/model/typing_status_test.dart index eb0cc83d0..d31691a4c 100644 --- a/test/model/typing_status_test.dart +++ b/test/model/typing_status_test.dart @@ -88,7 +88,7 @@ void main() { typistsByNarrow.forEach((narrow, typists) { for (final typist in typists) { - handleTypingEvent(narrow, TypingOp.start, typist); + handleTypingEvent(narrow, .start, typist); checkNotifiedOnce(); } }); @@ -114,15 +114,15 @@ void main() { test('add typists in separate narrows', () { prepareModel(); - handleTypingEvent(dmNarrow, TypingOp.start, eg.otherUser); + handleTypingEvent(dmNarrow, .start, eg.otherUser); checkTypists({dmNarrow: [eg.otherUser]}); checkNotifiedOnce(); - handleTypingEvent(groupNarrow, TypingOp.start, eg.thirdUser); + handleTypingEvent(groupNarrow, .start, eg.thirdUser); checkTypists({dmNarrow: [eg.otherUser], groupNarrow: [eg.thirdUser]}); checkNotifiedOnce(); - handleTypingEvent(topicNarrow, TypingOp.start, eg.fourthUser); + handleTypingEvent(topicNarrow, .start, eg.fourthUser); checkTypists({ dmNarrow: [eg.otherUser], groupNarrow: [eg.thirdUser], @@ -133,7 +133,7 @@ void main() { test('add a typist in the same narrow', () { prepareModel(typistsByNarrow: {groupNarrow: [eg.otherUser]}); - handleTypingEvent(groupNarrow, TypingOp.start, eg.thirdUser); + handleTypingEvent(groupNarrow, .start, eg.thirdUser); checkTypists({groupNarrow: [eg.otherUser, eg.thirdUser]}); checkNotifiedOnce(); }); @@ -142,7 +142,7 @@ void main() { prepareModel(); model.handleTypingEvent( - eg.typingEvent(groupNarrow, TypingOp.start, eg.selfUser.userId)); + eg.typingEvent(groupNarrow, .start, eg.selfUser.userId)); checkTypists({}); checkNotNotified(); }); @@ -152,7 +152,7 @@ void main() { test('remove a typist from an unknown narrow', () { prepareModel(typistsByNarrow: {groupNarrow: [eg.otherUser]}); - handleTypingEvent(dmNarrow, TypingOp.stop, eg.otherUser); + handleTypingEvent(dmNarrow, .stop, eg.otherUser); checkTypists({groupNarrow: [eg.otherUser]}); checkNotNotified(); }); @@ -160,7 +160,7 @@ void main() { test('remove an unknown typist from a known narrow', () { prepareModel(typistsByNarrow: {groupNarrow: [eg.otherUser]}); - handleTypingEvent(groupNarrow, TypingOp.stop, eg.thirdUser); + handleTypingEvent(groupNarrow, .stop, eg.thirdUser); checkTypists({groupNarrow: [eg.otherUser]}); checkNotNotified(); }); @@ -170,7 +170,7 @@ void main() { groupNarrow: [eg.otherUser, eg.thirdUser], }); - handleTypingEvent(groupNarrow, TypingOp.stop, eg.otherUser); + handleTypingEvent(groupNarrow, .stop, eg.otherUser); checkTypists({groupNarrow: [eg.thirdUser]}); checkNotifiedOnce(); }); @@ -178,7 +178,7 @@ void main() { test('remove all typists in a narrow', () { prepareModel(typistsByNarrow: {dmNarrow: [eg.otherUser]}); - handleTypingEvent(dmNarrow, TypingOp.stop, eg.otherUser); + handleTypingEvent(dmNarrow, .stop, eg.otherUser); checkTypists({}); checkNotifiedOnce(); }); @@ -190,15 +190,15 @@ void main() { topicNarrow: [eg.fourthUser], }); - handleTypingEvent(groupNarrow, TypingOp.stop, eg.thirdUser); + handleTypingEvent(groupNarrow, .stop, eg.thirdUser); checkTypists({dmNarrow: [eg.otherUser], topicNarrow: [eg.fourthUser]}); checkNotifiedOnce(); - handleTypingEvent(dmNarrow, TypingOp.stop, eg.otherUser); + handleTypingEvent(dmNarrow, .stop, eg.otherUser); checkTypists({topicNarrow: [eg.fourthUser]}); checkNotifiedOnce(); - handleTypingEvent(topicNarrow, TypingOp.stop, eg.fourthUser); + handleTypingEvent(topicNarrow, .stop, eg.fourthUser); checkTypists({}); checkNotifiedOnce(); }); @@ -222,7 +222,7 @@ void main() { prepareModel(typistsByNarrow: {dmNarrow: [eg.otherUser]}); check(async.pendingTimers).length.equals(1); - handleTypingEvent(dmNarrow, TypingOp.stop, eg.otherUser); + handleTypingEvent(dmNarrow, .stop, eg.otherUser); checkTypists({}); check(async.pendingTimers).isEmpty(); checkNotifiedOnce(); @@ -235,7 +235,7 @@ void main() { async.elapse(const Duration(seconds: 10)); checkTypists({dmNarrow: [eg.otherUser]}); // The timer should restart from the event. - handleTypingEvent(dmNarrow, TypingOp.start, eg.otherUser); + handleTypingEvent(dmNarrow, .start, eg.otherUser); check(async.pendingTimers).length.equals(1); checkNotNotified(); @@ -282,7 +282,7 @@ void main() { await prepare(); connection.prepare(json: {}); model.keystroke(narrow); - checkTypingRequest(TypingOp.start, narrow); + checkTypingRequest(.start, narrow); // Finish the pending API request first, // so that the idle timer is the only timer left. @@ -316,7 +316,7 @@ void main() { async.elapse(const Duration(milliseconds: 1)); // t = typingStoppedWaitPeriod + 100ms: // The new timer expires and the "typing stopped" notice is sent. - checkTypingRequest(TypingOp.stop, narrow); + checkTypingRequest(.stop, narrow); })); test('start typing repeatedly does not resend "typing started" notices', () => awaitFakeAsync((async) async { @@ -342,12 +342,12 @@ void main() { // t > typingStartedWaitPeriod: Resume sending "typing started" notices. connection.prepare(json: {}); model.keystroke(narrow); - checkTypingRequest(TypingOp.start, narrow); + checkTypingRequest(.start, narrow); // Ensures that a "typing stopped" notice is sent when the test ends. connection.prepare(json: {}); async.flushTimers(); - checkTypingRequest(TypingOp.stop, narrow); + checkTypingRequest(.stop, narrow); })); test('after stopped wait period, send a "typing stopped" notice', () => awaitFakeAsync((async) async { @@ -355,7 +355,7 @@ void main() { connection.prepare(json: {}); async.elapse(store.serverTypingStoppedWaitPeriod); - checkTypingRequest(TypingOp.stop, narrow); + checkTypingRequest(.stop, narrow); check(async.pendingTimers).isEmpty(); })); @@ -364,7 +364,7 @@ void main() { connection.prepare(json: {}); model.stoppedComposing(); - checkTypingRequest(TypingOp.stop, narrow); + checkTypingRequest(.stop, narrow); async.elapse(Duration.zero); check(async.pendingTimers).isEmpty(); @@ -375,19 +375,19 @@ void main() { connection.prepare(json: {}); model.stoppedComposing(); - checkTypingRequest(TypingOp.stop, narrow); + checkTypingRequest(.stop, narrow); // The "typing started" notice would have been throttled if we did not // reset the TypingNotifier internal states before sending the "typing // stopped" notice. connection.prepare(json: {}); model.keystroke(narrow); - checkTypingRequest(TypingOp.start, narrow); + checkTypingRequest(.start, narrow); // Ensures that a "typing stopped" notice is sent when the test ends. connection.prepare(json: {}); async.flushTimers(); - checkTypingRequest(TypingOp.stop, narrow); + checkTypingRequest(.stop, narrow); })); test('disposing store cancels the idle timer', () => awaitFakeAsync((async) async { @@ -411,7 +411,7 @@ void main() { // t = 0ms: Start typing. The idle timer is set to typingStoppedWaitPeriod. connection.prepare(json: {}); model.keystroke(topicNarrow); - checkTypingRequest(TypingOp.start, topicNarrow); + checkTypingRequest(.start, topicNarrow); async.elapse(Duration.zero); check(async.pendingTimers).single; @@ -424,7 +424,7 @@ void main() { connection.prepare(json: {}); model.keystroke(dmNarrow); checkSetTypingStatusRequests(connection.takeRequests(), - [(TypingOp.stop, topicNarrow), (TypingOp.start, dmNarrow)]); + [(.stop, topicNarrow), (.start, dmNarrow)]); async.elapse(Duration.zero); check(async.pendingTimers).single; @@ -440,7 +440,7 @@ void main() { async.elapse(waitTime); // t = typingStoppedPeriod + 100ms: // The new timer has expired, and a "typing stopped" notice is expected. - checkTypingRequest(TypingOp.stop, dmNarrow); + checkTypingRequest(.stop, dmNarrow); })); test('start typing in a different destination resets typing started wait timeout', () => awaitFakeAsync((async) async { @@ -457,7 +457,7 @@ void main() { // t = 0ms: Start typing. The typing started time is set to 0ms. connection.prepare(json: {}); model.keystroke(topicNarrow); - checkTypingRequest(TypingOp.start, topicNarrow); + checkTypingRequest(.start, topicNarrow); async.elapse(waitInterval); // t = waitInterval * 1: @@ -469,7 +469,7 @@ void main() { connection.prepare(json: {}); model.keystroke(dmNarrow); checkSetTypingStatusRequests(connection.takeRequests(), - [(TypingOp.stop, topicNarrow), (TypingOp.start, dmNarrow)]); + [(.stop, topicNarrow), (.start, dmNarrow)]); while (async.elapsed <= store.serverTypingStartedWaitPeriod) { // t <= typingStartedWaitPeriod: "still typing" requests are throttled. @@ -494,12 +494,12 @@ void main() { // enough time since the last time we sent a "typing started" notice. connection.prepare(json: {}); model.keystroke(dmNarrow); - checkTypingRequest(TypingOp.start, dmNarrow); + checkTypingRequest(.start, dmNarrow); // Ensures that a "typing stopped" notice is sent when the test ends. connection.prepare(json: {}); async.flushTimers(); - checkTypingRequest(TypingOp.stop, dmNarrow); + checkTypingRequest(.stop, dmNarrow); })); }); } diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 603589965..05d7b5668 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -845,7 +845,7 @@ void main() { Future checkStartTyping(WidgetTester tester, SendableNarrow narrow) async { connection.prepare(json: {}); await enterContent(tester, 'hello world'); - checkTypingRequest(TypingOp.start, narrow); + checkTypingRequest(.start, narrow); } testWidgets('smoke TopicNarrow', (tester) async { @@ -856,7 +856,7 @@ void main() { connection.prepare(json: {}); await tester.pump(store.serverTypingStoppedWaitPeriod); - checkTypingRequest(TypingOp.stop, narrow); + checkTypingRequest(.stop, narrow); }); testWidgets('smoke DmNarrow', (tester) async { @@ -868,7 +868,7 @@ void main() { connection.prepare(json: {}); await tester.pump(store.serverTypingStoppedWaitPeriod); - checkTypingRequest(TypingOp.stop, narrow); + checkTypingRequest(.stop, narrow); }); testWidgets('smoke ChannelNarrow', (tester) async { @@ -882,7 +882,7 @@ void main() { connection.prepare(json: {}); await tester.pump(store.serverTypingStoppedWaitPeriod); - checkTypingRequest(TypingOp.stop, destinationNarrow); + checkTypingRequest(.stop, destinationNarrow); }); testWidgets('clearing text sends a "typing stopped" notice', (tester) async { @@ -893,7 +893,7 @@ void main() { connection.prepare(json: {}); await enterContent(tester, ''); - checkTypingRequest(TypingOp.stop, narrow); + checkTypingRequest(.stop, narrow); }); testWidgets('hitting send button sends a "typing stopped" notice', (tester) async { @@ -909,7 +909,7 @@ void main() { await tester.tap(sendButtonFinder); await tester.pump(Duration.zero); final requests = connection.takeRequests(); - checkSetTypingStatusRequests([requests.first], [(TypingOp.stop, narrow)]); + checkSetTypingStatusRequests([requests.first], [(.stop, narrow)]); check(requests).length.equals(2); }); @@ -940,7 +940,7 @@ void main() { connection.prepare(json: {}); (await ZulipApp.navigator).pop(); await tester.pump(Duration.zero); - checkTypingRequest(TypingOp.stop, narrow); + checkTypingRequest(.stop, narrow); }); testWidgets('for content input, unfocusing sends a "typing stopped" notice', (tester) async { @@ -955,7 +955,7 @@ void main() { connection.prepare(json: {}); FocusManager.instance.primaryFocus!.unfocus(); await tester.pump(Duration.zero); - checkTypingRequest(TypingOp.stop, destinationNarrow); + checkTypingRequest(.stop, destinationNarrow); }); testWidgets('selection change sends a "typing started" notice', (tester) async { @@ -966,17 +966,17 @@ void main() { connection.prepare(json: {}); await tester.pump(store.serverTypingStoppedWaitPeriod); - checkTypingRequest(TypingOp.stop, narrow); + checkTypingRequest(.stop, narrow); connection.prepare(json: {}); controller!.content.selection = const TextSelection(baseOffset: 0, extentOffset: 2); - checkTypingRequest(TypingOp.start, narrow); + checkTypingRequest(.start, narrow); // Ensures that a "typing stopped" notice is sent when the test ends. connection.prepare(json: {}); await tester.pump(store.serverTypingStoppedWaitPeriod); - checkTypingRequest(TypingOp.stop, narrow); + checkTypingRequest(.stop, narrow); }); testWidgets('unfocusing app sends a "typing stopped" notice', (tester) async { @@ -994,7 +994,7 @@ void main() { WidgetsBinding.instance.handleAppLifecycleStateChanged( AppLifecycleState.hidden); await tester.pump(Duration.zero); - checkTypingRequest(TypingOp.stop, narrow); + checkTypingRequest(.stop, narrow); WidgetsBinding.instance.handleAppLifecycleStateChanged( AppLifecycleState.paused); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 246d1992a..fe60e28a2 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1176,19 +1176,19 @@ void main() { await tester.pump(); check(finder.evaluate()).isEmpty(); await checkTyping(tester, - eg.typingEvent(narrow, TypingOp.start, eg.otherUser.userId), + eg.typingEvent(narrow, .start, eg.otherUser.userId), expected: 'Other User is typing…'); await checkTyping(tester, - eg.typingEvent(narrow, TypingOp.start, eg.selfUser.userId), + eg.typingEvent(narrow, .start, eg.selfUser.userId), expected: 'Other User is typing…'); await checkTyping(tester, - eg.typingEvent(narrow, TypingOp.start, eg.thirdUser.userId), + eg.typingEvent(narrow, .start, eg.thirdUser.userId), expected: 'Other User and Third User are typing…'); await checkTyping(tester, - eg.typingEvent(narrow, TypingOp.start, eg.fourthUser.userId), + eg.typingEvent(narrow, .start, eg.fourthUser.userId), expected: 'Several people are typing…'); await checkTyping(tester, - eg.typingEvent(narrow, TypingOp.stop, eg.otherUser.userId), + eg.typingEvent(narrow, .stop, eg.otherUser.userId), expected: 'Third User and Fourth User are typing…'); // Verify that typing indicators expire after a set duration. await tester.pump(const Duration(seconds: 15)); @@ -1202,7 +1202,7 @@ void main() { await setupMessageListPage(tester, narrow: narrow, users: [], messages: [streamMessage]); await checkTyping(tester, - eg.typingEvent(narrow, TypingOp.start, 1000), + eg.typingEvent(narrow, .start, 1000), expected: '(unknown user) is typing…', ); // Wait for the pending timers to end. @@ -1214,18 +1214,18 @@ void main() { narrow: topicNarrow, users: users, messages: [streamMessage]); await checkTyping(tester, - eg.typingEvent(topicNarrow, TypingOp.start, eg.otherUser.userId), + eg.typingEvent(topicNarrow, .start, eg.otherUser.userId), expected: 'Other User is typing…'); await checkTyping(tester, - eg.typingEvent(topicNarrow, TypingOp.start, eg.thirdUser.userId), + eg.typingEvent(topicNarrow, .start, eg.thirdUser.userId), expected: 'Other User and Third User are typing…'); await store.setMutedUsers([eg.otherUser.userId]); await tester.pump(); await checkTyping(tester, - eg.typingEvent(topicNarrow, TypingOp.start, eg.thirdUser.userId), + eg.typingEvent(topicNarrow, .start, eg.thirdUser.userId), expected: 'Third User is typing…', // no "Other User" ); From be1721ab85507c3449fdc8427b4a2caf16158f9c Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 15 Jun 2026 22:52:14 +0430 Subject: [PATCH 09/29] api: Add TypingOp.unknown --- lib/api/model/events.dart | 6 +++++- lib/api/model/events.g.dart | 12 ++++++++++-- lib/api/route/typing.dart | 6 +++++- lib/model/typing_status.dart | 4 ++++ test/api/model/events_checks.dart | 1 + test/api/model/events_test.dart | 10 ++++++++++ 6 files changed, 35 insertions(+), 4 deletions(-) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 2f0164858..54535d52f 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -1591,6 +1591,7 @@ class TypingEvent extends Event { @JsonKey(includeToJson: true) String get type => 'typing'; + @JsonKey(unknownEnumValue: TypingOp.unknown) final TypingOp op; // The server never actually sends "channel" here yet (it's "stream" instead), // but we accept both forms for forward-compatibility. @@ -1644,7 +1645,10 @@ class TypingEvent extends Event { @JsonEnum(fieldRename: FieldRename.snake) enum TypingOp { start, - stop; + stop, + + /// A new, unrecognized operation. + unknown; String toJson() => _$TypingOpEnumMap[this]!; } diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 2ba69a7cc..f12273d80 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -1061,7 +1061,11 @@ const _$SubmessageTypeEnumMap = { TypingEvent _$TypingEventFromJson(Map json) => TypingEvent( id: (json['id'] as num).toInt(), - op: $enumDecode(_$TypingOpEnumMap, json['op']), + op: $enumDecode( + _$TypingOpEnumMap, + json['op'], + unknownValue: TypingOp.unknown, + ), messageType: const MessageTypeConverter().fromJson( json['message_type'] as String, ), @@ -1085,7 +1089,11 @@ Map _$TypingEventToJson(TypingEvent instance) => 'topic': instance.topic, }; -const _$TypingOpEnumMap = {TypingOp.start: 'start', TypingOp.stop: 'stop'}; +const _$TypingOpEnumMap = { + TypingOp.start: 'start', + TypingOp.stop: 'stop', + TypingOp.unknown: 'unknown', +}; PresenceEvent _$PresenceEventFromJson(Map json) => PresenceEvent( diff --git a/lib/api/route/typing.dart b/lib/api/route/typing.dart index 8e2e5dae6..81899d867 100644 --- a/lib/api/route/typing.dart +++ b/lib/api/route/typing.dart @@ -7,7 +7,11 @@ import 'messages.dart'; Future setTypingStatus(ApiConnection connection, { required TypingOp op, required MessageDestination destination, -}) { +}) async { + if (op == .unknown) { + assert(false, 'TypingOp.unknown is not supported when setting typing status.'); + return; + } switch (destination) { case StreamDestination(): final supportsTypeChannel = connection.zulipFeatureLevel! >= 248; // TODO(server-9) diff --git a/lib/model/typing_status.dart b/lib/model/typing_status.dart index d7b80d550..f8b8f22a3 100644 --- a/lib/model/typing_status.dart +++ b/lib/model/typing_status.dart @@ -62,6 +62,8 @@ class TypingStatus extends HasRealmStore with ChangeNotifier { } void handleTypingEvent(TypingEvent event) { + if (event.op == .unknown) return; + SendableNarrow narrow = switch (event.messageType) { .direct => DmNarrow( allRecipientIds: event.recipientIds!, selfUserId: selfUserId), @@ -74,6 +76,8 @@ class TypingStatus extends HasRealmStore with ChangeNotifier { hasUpdate = _addTypist(narrow, event.senderId); case .stop: hasUpdate = _removeTypist(narrow, event.senderId); + case .unknown: + // Shouldn't reach here because of the early return. } if (hasUpdate) { diff --git a/test/api/model/events_checks.dart b/test/api/model/events_checks.dart index 030172169..42982ab27 100644 --- a/test/api/model/events_checks.dart +++ b/test/api/model/events_checks.dart @@ -85,6 +85,7 @@ extension UpdateMessageFlagsMessageDetailCheck on Subject { + Subject get op => has((e) => e.op, 'op'); Subject get messageType => has((e) => e.messageType, 'messageType'); Subject get senderId => has((e) => e.senderId, 'senderId'); Subject?> get recipientIds => has((e) => e.recipientIds, 'recipientIds'); diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart index 3494ea25e..dcd845183 100644 --- a/test/api/model/events_test.dart +++ b/test/api/model/events_test.dart @@ -360,6 +360,16 @@ void main() { 'recipients': [1, 2, 3].map((e) => {'user_id': e, 'email': '$e@example.com'}).toList(), }; + test('handle unknown op', () { + check(TypingEvent.fromJson(directMessageJson)) + .op.equals(.start); + + for (final unknown in ['unknown_op', '']) { + check(TypingEvent.fromJson({...directMessageJson, 'op': unknown})) + .op.equals(.unknown); + } + }); + test('direct message typing events', () { check(TypingEvent.fromJson(directMessageJson)) ..recipientIds.isNotNull().deepEquals([1, 2, 3]) From cce7d5bf2f66c2e33cedece4f7b1415afbec6d0e Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 10 Jun 2026 10:49:00 +0430 Subject: [PATCH 10/29] dart [nfc]: Use dot shorthands for ReactionOp --- lib/model/message.dart | 4 ++-- test/model/message_list_test.dart | 4 ++-- test/model/message_test.dart | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/model/message.dart b/lib/model/message.dart index 17d4775fb..b870d886d 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -836,14 +836,14 @@ class MessageStoreImpl extends HasChannelStore with MessageStore, _OutboxMessage if (message == null) return; switch (event.op) { - case ReactionOp.add: + case .add: (message.reactions ??= Reactions([])).add(Reaction( emojiName: event.emojiName, emojiCode: event.emojiCode, reactionType: event.reactionType, userId: event.userId, )); - case ReactionOp.remove: + case .remove: if (message.reactions == null) { // TODO(log) return; } diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index ac484bf64..45c8aaa69 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -2193,7 +2193,7 @@ void main() { await store.addMessage(message); await store.handleEvent( - eg.reactionEvent(eg.unicodeEmojiReaction, ReactionOp.add, message.id)); + eg.reactionEvent(eg.unicodeEmojiReaction, .add, message.id)); check(notifiedCount1).equals(3); // fetch, new-message event, reaction event check(notifiedCount2).equals(3); // fetch, new-message event, reaction event @@ -2236,7 +2236,7 @@ void main() { test('ReactionEvent is applied even when message not in any msglists', () async { await checkApplied( mkEvent: (message) => - eg.reactionEvent(eg.unicodeEmojiReaction, ReactionOp.add, message.id), + eg.reactionEvent(eg.unicodeEmojiReaction, .add, message.id), doCheckMessageAfterFetch: (messageSubject) => messageSubject.reactions.isNotNull().total.equals(1), ); diff --git a/test/model/message_test.dart b/test/model/message_test.dart index bba0bce27..755cf9492 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -1915,7 +1915,7 @@ void main() { final message = store.messages.values.single; await store.handleEvent( - eg.reactionEvent(eg.unicodeEmojiReaction, ReactionOp.add, originalMessage.id)); + eg.reactionEvent(eg.unicodeEmojiReaction, .add, originalMessage.id)); checkNotifiedOnce(); check(store.messages).values.single ..identicalTo(message) @@ -1927,7 +1927,7 @@ void main() { await prepare(); await prepareMessages([someMessage]); await store.handleEvent( - eg.reactionEvent(eg.unicodeEmojiReaction, ReactionOp.add, 1000)); + eg.reactionEvent(eg.unicodeEmojiReaction, .add, 1000)); checkNotNotified(); check(store.messages).values.single .reactions.isNull(); @@ -1959,7 +1959,7 @@ void main() { final message = store.messages.values.single; await store.handleEvent( - eg.reactionEvent(eventReaction, ReactionOp.remove, originalMessage.id)); + eg.reactionEvent(eventReaction, .remove, originalMessage.id)); checkNotifiedOnce(); check(store.messages).values.single ..identicalTo(message) @@ -1971,7 +1971,7 @@ void main() { await prepare(); await prepareMessages([someMessage]); await store.handleEvent( - eg.reactionEvent(eg.unicodeEmojiReaction, ReactionOp.remove, 1000)); + eg.reactionEvent(eg.unicodeEmojiReaction, .remove, 1000)); checkNotNotified(); check(store.messages).values.single .reactions.isNotNull().jsonEquals([eg.unicodeEmojiReaction]); From e6bad69f38a0a3a9b252cb8be383e7f5193d9d56 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 15 Jun 2026 23:16:38 +0430 Subject: [PATCH 11/29] api: Add ReactionOp.unknown --- lib/api/model/events.dart | 4 ++++ lib/api/model/events.g.dart | 7 ++++++- lib/model/message.dart | 4 ++++ test/api/model/events_checks.dart | 4 ++++ test/api/model/events_test.dart | 21 +++++++++++++++++++++ 5 files changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 54535d52f..b5154fff6 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -1727,6 +1727,7 @@ class ReactionEvent extends Event { @JsonKey(includeToJson: true) String get type => 'reaction'; + @JsonKey(unknownEnumValue: ReactionOp.unknown) final ReactionOp op; final String emojiName; @@ -1758,6 +1759,9 @@ class ReactionEvent extends Event { enum ReactionOp { add, remove, + + /// A new, unrecognized operation. + unknown; } /// A Zulip event of type `heartbeat`: https://zulip.com/api/get-events#heartbeat diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index f12273d80..b7ef64d9e 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -1139,7 +1139,11 @@ const _$PresenceStatusEnumMap = { ReactionEvent _$ReactionEventFromJson(Map json) => ReactionEvent( id: (json['id'] as num).toInt(), - op: $enumDecode(_$ReactionOpEnumMap, json['op']), + op: $enumDecode( + _$ReactionOpEnumMap, + json['op'], + unknownValue: ReactionOp.unknown, + ), emojiName: json['emoji_name'] as String, emojiCode: json['emoji_code'] as String, reactionType: $enumDecode(_$ReactionTypeEnumMap, json['reaction_type']), @@ -1162,6 +1166,7 @@ Map _$ReactionEventToJson(ReactionEvent instance) => const _$ReactionOpEnumMap = { ReactionOp.add: 'add', ReactionOp.remove: 'remove', + ReactionOp.unknown: 'unknown', }; const _$ReactionTypeEnumMap = { diff --git a/lib/model/message.dart b/lib/model/message.dart index b870d886d..a6c0d490a 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -832,6 +832,8 @@ class MessageStoreImpl extends HasChannelStore with MessageStore, _OutboxMessage } void handleReactionEvent(ReactionEvent event) { + if (event.op == .unknown) return; + final message = messages[event.messageId]; if (message == null) return; @@ -852,6 +854,8 @@ class MessageStoreImpl extends HasChannelStore with MessageStore, _OutboxMessage emojiCode: event.emojiCode, userId: event.userId, ); + case .unknown: + // Shouldn't reach here because of the early return. } _notifyMessageListViewsForOneMessage(event.messageId); } diff --git a/test/api/model/events_checks.dart b/test/api/model/events_checks.dart index 42982ab27..3cb819d45 100644 --- a/test/api/model/events_checks.dart +++ b/test/api/model/events_checks.dart @@ -93,6 +93,10 @@ extension TypingEventChecks on Subject { Subject get topic => has((e) => e.topic, 'topic'); } +extension ReactionEventChecks on Subject { + Subject get op => has((e) => e.op, 'op'); +} + extension HeartbeatEventChecks on Subject { // No properties not covered by Event. } diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart index dcd845183..6e6c4643d 100644 --- a/test/api/model/events_test.dart +++ b/test/api/model/events_test.dart @@ -423,4 +423,25 @@ void main() { })).recipientIds.isNotNull().deepEquals([1, 2, 4, 8, 10]); }); }); + + group('reaction event', () { + final json = Map.unmodifiable({ + 'id': 1, + 'type': 'reaction', + 'op': 'add', + 'emoji_name': '+1', + 'emoji_code': '1f44d', + 'reaction_type': 'unicode_emoji', + 'user_id': 100, + 'message_id': 1000, + }); + + test('handle unknown op', () { + check(ReactionEvent.fromJson(json)).op.equals(.add); + + for (final unknown in ['unknown_op', '']) { + check(ReactionEvent.fromJson({...json, 'op': unknown})).op.equals(.unknown); + } + }); + }); } From 4386dad5e1cc4b07cd05d1b78ebceef8b1d97575 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 10 Jun 2026 09:43:55 +0430 Subject: [PATCH 12/29] dart [nfc]: Use dot shorthands for RealmWildcardMentionPolicy --- test/example_data.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/example_data.dart b/test/example_data.dart index 38f7c0dd3..92c39c082 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1502,7 +1502,7 @@ InitialSnapshot initialSnapshot({ realmCanDeleteAnyMessageGroup: realmCanDeleteAnyMessageGroup, realmCanDeleteOwnMessageGroup: realmCanDeleteOwnMessageGroup, realmDeleteOwnMessagePolicy: realmDeleteOwnMessagePolicy, - realmWildcardMentionPolicy: realmWildcardMentionPolicy ?? RealmWildcardMentionPolicy.everyone, + realmWildcardMentionPolicy: realmWildcardMentionPolicy ?? .everyone, // no default; allow `null` to simulate servers without this realmTopicsPolicy: realmTopicsPolicy, realmMandatoryTopics: realmMandatoryTopics ?? true, From 8704bce38ca2e09b24ed8321c9e22f676224e1f4 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 15 Jun 2026 23:42:34 +0430 Subject: [PATCH 13/29] api: Make InitialSnapshot.realmWildcardMentionPolicy optional `realm_wildcard_mention_policy` is deprecated in server-10 (FL-352), so making it optional will avoid throwing when the server stops sending it. --- lib/api/model/initial_snapshot.dart | 2 +- lib/api/model/initial_snapshot.g.dart | 2 +- lib/model/realm.dart | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 1709d007b..a497749d8 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -94,7 +94,7 @@ class InitialSnapshot { /// The policy for who can use wildcard mentions in large channels. /// /// Search for "realm_wildcard_mention_policy" in https://zulip.com/api/register-queue. - final RealmWildcardMentionPolicy realmWildcardMentionPolicy; + final RealmWildcardMentionPolicy? realmWildcardMentionPolicy; // TODO(server-10) remove @JsonKey(unknownEnumValue: RealmTopicsPolicy.unknown) final RealmTopicsPolicy? realmTopicsPolicy; // TODO(server-11) diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 8ecc74a76..55ccb43d8 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -107,7 +107,7 @@ InitialSnapshot _$InitialSnapshotFromJson( _$RealmDeleteOwnMessagePolicyEnumMap, json['realm_delete_own_message_policy'], ), - realmWildcardMentionPolicy: $enumDecode( + realmWildcardMentionPolicy: $enumDecodeNullable( _$RealmWildcardMentionPolicyEnumMap, json['realm_wildcard_mention_policy'], ), diff --git a/lib/model/realm.dart b/lib/model/realm.dart index cffb3095f..06155b880 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -73,7 +73,7 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore { // Realm settings previously found in realm/update_dict events, // but now deprecated. - RealmWildcardMentionPolicy get realmWildcardMentionPolicy; // TODO(server-10) remove + RealmWildcardMentionPolicy? get realmWildcardMentionPolicy; // TODO(server-10) remove RealmDeleteOwnMessagePolicy? get realmDeleteOwnMessagePolicy; // TODO(server-10) remove //|////////////////////////////// @@ -212,7 +212,7 @@ mixin ProxyRealmStore on RealmStore { @override int get realmWaitingPeriodThreshold => realmStore.realmWaitingPeriodThreshold; @override - RealmWildcardMentionPolicy get realmWildcardMentionPolicy => realmStore.realmWildcardMentionPolicy; + RealmWildcardMentionPolicy? get realmWildcardMentionPolicy => realmStore.realmWildcardMentionPolicy; @override RealmDeleteOwnMessagePolicy? get realmDeleteOwnMessagePolicy => realmStore.realmDeleteOwnMessagePolicy; @override @@ -449,7 +449,7 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { final int realmWaitingPeriodThreshold; @override - final RealmWildcardMentionPolicy realmWildcardMentionPolicy; + final RealmWildcardMentionPolicy? realmWildcardMentionPolicy; @override final RealmDeleteOwnMessagePolicy? realmDeleteOwnMessagePolicy; From d4e411384494de01dc7f6b790d1150ad3db27c75 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 10 Jun 2026 10:03:41 +0430 Subject: [PATCH 14/29] api [nfc]: Explain why RealmWildcardMentionPolicy cannot be unknown --- lib/api/model/initial_snapshot.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index a497749d8..9cd67d2f7 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -237,7 +237,12 @@ enum RealmWildcardMentionPolicy { fullMembers(apiValue: 3), admins(apiValue: 5), nobody(apiValue: 6), - moderators(apiValue: 7); + moderators(apiValue: 7), + + // We don't accept an unknown value. `realm_wildcard_mention_policy` is + // deprecated in server-10, making future additions to this enum unlikely. + // unknown(apiValue: null), + ; const RealmWildcardMentionPolicy({required this.apiValue}); From 72316005d8b82bb0b0cb3f161cfcb8e1260dd93b Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 10 Jun 2026 10:20:28 +0430 Subject: [PATCH 15/29] dart [nfc]: Use dot shorthands for RealmDeleteOwnMessagePolicy --- lib/model/message.dart | 11 +++++------ test/model/message_test.dart | 14 +++++++------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/lib/model/message.dart b/lib/model/message.dart index a6c0d490a..ba28b7863 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart'; import '../api/exception.dart'; import '../api/model/events.dart'; -import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; import '../log.dart'; @@ -185,20 +184,20 @@ mixin MessageStore on ChannelStore { // but pre-291 servers shouldn't be giving us an unknown role.) switch (realmDeleteOwnMessagePolicy!) { - case RealmDeleteOwnMessagePolicy.everyone: + case .everyone: return true; - case RealmDeleteOwnMessagePolicy.members: + case .members: return role.isAtLeast(UserRole.member); - case RealmDeleteOwnMessagePolicy.fullMembers: { + case .fullMembers: { if (!role.isAtLeast(UserRole.member)) return false; if (role == UserRole.member) { return selfHasPassedWaitingPeriod(byDate: atDate); } return true; } - case RealmDeleteOwnMessagePolicy.moderators: + case .moderators: return role.isAtLeast(UserRole.moderator); - case RealmDeleteOwnMessagePolicy.admins: + case .admins: return role.isAtLeast(UserRole.administrator); } } diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 755cf9492..f3374acc3 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -1365,7 +1365,7 @@ void main() { timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, inRealmCanDeleteAnyMessageGroup: false, isChannelArchived: false, - realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone, + realmDeleteOwnMessagePolicy: .everyone, selfUserRole: UserRole.member, ))) ..equals(await evaluate( @@ -1385,7 +1385,7 @@ void main() { timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, inRealmCanDeleteAnyMessageGroup: false, isChannelArchived: false, - realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.admins, + realmDeleteOwnMessagePolicy: .admins, selfUserRole: UserRole.administrator, ))) ..equals(await evaluate( @@ -1405,7 +1405,7 @@ void main() { timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, inRealmCanDeleteAnyMessageGroup: false, isChannelArchived: false, - realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.admins, + realmDeleteOwnMessagePolicy: .admins, selfUserRole: UserRole.moderator, )))..equals(await evaluate( CanDeleteMessageParams.pre407( @@ -1429,7 +1429,7 @@ void main() { senderConfig: CanDeleteMessageSenderConfig.otherHuman, timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, isChannelArchived: false, - realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone, + realmDeleteOwnMessagePolicy: .everyone, selfUserRole: UserRole.member, )))..equals(await evaluate( CanDeleteMessageParams.pre291( @@ -1437,7 +1437,7 @@ void main() { timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, inRealmCanDeleteAnyMessageGroup: false, isChannelArchived: false, - realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone, + realmDeleteOwnMessagePolicy: .everyone, selfUserRole: UserRole.member))) ..isFalse(); }); @@ -1448,7 +1448,7 @@ void main() { senderConfig: CanDeleteMessageSenderConfig.otherHuman, timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, isChannelArchived: false, - realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone, + realmDeleteOwnMessagePolicy: .everyone, selfUserRole: UserRole.administrator, )))..equals(await evaluate( CanDeleteMessageParams.pre291( @@ -1456,7 +1456,7 @@ void main() { timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, inRealmCanDeleteAnyMessageGroup: true, isChannelArchived: false, - realmDeleteOwnMessagePolicy: RealmDeleteOwnMessagePolicy.everyone, + realmDeleteOwnMessagePolicy: .everyone, selfUserRole: UserRole.administrator))) ..isTrue(); }); From 41244cd51e8f3732b5c7ab1ba71b8a5db99205f3 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 10 Jun 2026 10:15:54 +0430 Subject: [PATCH 16/29] api [nfc]: Explain why RealmDeleteOwnMessagePolicy cannot be unknown --- lib/api/model/initial_snapshot.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 9cd67d2f7..e85a775cc 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -257,7 +257,12 @@ enum RealmDeleteOwnMessagePolicy { admins(apiValue: 2), fullMembers(apiValue: 3), moderators(apiValue: 4), - everyone(apiValue: 5); + everyone(apiValue: 5), + + // We don't accept an unknown value. `realm_delete_own_message_policy` is + // removed in server-10, making future additions to this enum impossible. + // unknown(apiValue: -100), + ; const RealmDeleteOwnMessagePolicy({required this.apiValue}); From 6ac5120c77ed08992d692ed13267a4f2f48e3fc8 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 10 Jun 2026 10:31:05 +0430 Subject: [PATCH 17/29] dart [nfc]: Use dot shorthands for CustomProfileFieldType --- lib/widgets/profile.dart | 18 +++++----- test/model/realm_test.dart | 20 ++++++------ test/widgets/profile_test.dart | 60 +++++++++++++++++----------------- 3 files changed, 49 insertions(+), 49 deletions(-) diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index f10f6211d..ee9f505a0 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -362,16 +362,16 @@ class _ProfileDataTable extends StatelessWidget { final store = PerAccountStoreWidget.of(context); switch (realmField.type) { - case CustomProfileFieldType.link: + case .link: return _LinkWidget(url: value, text: value); - case CustomProfileFieldType.choice: + case .choice: final choiceFieldData = _tryDecode(CustomProfileFieldChoiceDataItem.parseFieldDataChoices, realmField.fieldData); if (choiceFieldData == null) return null; final choiceItem = choiceFieldData[value]; return (choiceItem == null) ? null : _TextWidget(text: choiceItem.text); - case CustomProfileFieldType.externalAccount: + case .externalAccount: final externalAccountFieldData = _tryDecode(CustomProfileFieldExternalAccountData.fromJson, realmField.fieldData); if (externalAccountFieldData == null) return null; final urlPattern = externalAccountFieldData.urlPattern ?? @@ -380,7 +380,7 @@ class _ProfileDataTable extends StatelessWidget { final url = urlPattern.replaceFirst('%(username)s', value); return _LinkWidget(url: url, text: value); - case CustomProfileFieldType.user: + case .user: // TODO(server): This is completely undocumented. The key to // reverse-engineering it was: // https://github.com/zulip/zulip/blob/18230fcd9/static/js/settings_account.js#L247 @@ -391,21 +391,21 @@ class _ProfileDataTable extends StatelessWidget { return Column( children: userIds.map((userId) => _UserWidget(userId: userId)).toList()); - case CustomProfileFieldType.date: + case .date: // TODO(server): The value's format is undocumented, but empirically // it's a date in ISO format, like 2000-01-01. // That's readable as is, but: // TODO(i18n) format this date using user's locale. return _TextWidget(text: value); - case CustomProfileFieldType.shortText: - case CustomProfileFieldType.longText: - case CustomProfileFieldType.pronouns: + case .shortText: + case .longText: + case .pronouns: // The web client appears to treat `longText` identically to `shortText`; // `pronouns` is explicitly meant to display the same as `shortText`. return _TextWidget(text: value); - case CustomProfileFieldType.unknown: + case .unknown: return null; } } diff --git a/test/model/realm_test.dart b/test/model/realm_test.dart index 404d67446..7e2804197 100644 --- a/test/model/realm_test.dart +++ b/test/model/realm_test.dart @@ -206,14 +206,14 @@ void main() { test('update clobbers old list', () async { final store = eg.store(initialSnapshot: eg.initialSnapshot( customProfileFields: [ - eg.customProfileField(0, CustomProfileFieldType.shortText), - eg.customProfileField(1, CustomProfileFieldType.shortText), + eg.customProfileField(0, .shortText), + eg.customProfileField(1, .shortText), ])); check(store.customProfileFields.map((f) => f.id)).deepEquals([0, 1]); await store.handleEvent(CustomProfileFieldsEvent(id: 0, fields: [ - eg.customProfileField(0, CustomProfileFieldType.shortText), - eg.customProfileField(2, CustomProfileFieldType.shortText), + eg.customProfileField(0, .shortText), + eg.customProfileField(2, .shortText), ])); check(store.customProfileFields.map((f) => f.id)).deepEquals([0, 2]); }); @@ -222,22 +222,22 @@ void main() { // Sorts both the data from the initial snapshot… final store = eg.store(initialSnapshot: eg.initialSnapshot( customProfileFields: [ - eg.customProfileField(0, CustomProfileFieldType.shortText, + eg.customProfileField(0, .shortText, displayInProfileSummary: false), - eg.customProfileField(1, CustomProfileFieldType.shortText, + eg.customProfileField(1, .shortText, displayInProfileSummary: true), - eg.customProfileField(2, CustomProfileFieldType.shortText, + eg.customProfileField(2, .shortText, displayInProfileSummary: false), ])); check(store.customProfileFields.map((f) => f.id)).deepEquals([1, 0, 2]); // … and from an event. await store.handleEvent(CustomProfileFieldsEvent(id: 0, fields: [ - eg.customProfileField(0, CustomProfileFieldType.shortText, + eg.customProfileField(0, .shortText, displayInProfileSummary: false), - eg.customProfileField(1, CustomProfileFieldType.shortText, + eg.customProfileField(1, .shortText, displayInProfileSummary: false), - eg.customProfileField(2, CustomProfileFieldType.shortText, + eg.customProfileField(2, .shortText, displayInProfileSummary: true), ])); check(store.customProfileFields.map((f) => f.id)).deepEquals([2, 0, 1]); diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index c21361909..3209c11d6 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -219,16 +219,16 @@ void main() { ], pageUserId: 1, customProfileFields: [ - eg.customProfileField(0, CustomProfileFieldType.shortText), - eg.customProfileField(1, CustomProfileFieldType.longText), - eg.customProfileField(2, CustomProfileFieldType.choice, + eg.customProfileField(0, .shortText), + eg.customProfileField(1, .longText), + eg.customProfileField(2, .choice, fieldData: '{"x": {"text": "choiceValue", "order": "1"}}'), - eg.customProfileField(3, CustomProfileFieldType.date), - eg.customProfileField(4, CustomProfileFieldType.link), - eg.customProfileField(5, CustomProfileFieldType.user), - eg.customProfileField(6, CustomProfileFieldType.externalAccount, + eg.customProfileField(3, .date), + eg.customProfileField(4, .link), + eg.customProfileField(5, .user), + eg.customProfileField(6, .externalAccount, fieldData: '{"subtype": "external1"}'), - eg.customProfileField(7, CustomProfileFieldType.pronouns), + eg.customProfileField(7, .pronouns), ], realmDefaultExternalAccounts: { 'external1': RealmDefaultExternalAccount( name: 'external1', @@ -236,15 +236,15 @@ void main() { hint: '', urlPattern: 'https://example/%(username)s')}); - final testCases = [ - (find.text('field0'), find.text('shortTextValue'), CustomProfileFieldType.shortText), - (find.text('field1'), find.text('longTextValue'), CustomProfileFieldType.longText), - (find.text('field2'), find.text('choiceValue'), CustomProfileFieldType.choice), - (find.text('field3'), find.text('dateValue'), CustomProfileFieldType.date), - (find.text('field4'), find.text('http://example/linkValue'), CustomProfileFieldType.link), - (find.text('field5'), find.text('userValue'), CustomProfileFieldType.user), - (find.text('field6'), find.text('externalValue'), CustomProfileFieldType.externalAccount), - (find.text('field7'), find.text('pronounsValue'), CustomProfileFieldType.pronouns), + final testCases = <(Finder, Finder, CustomProfileFieldType)>[ + (find.text('field0'), find.text('shortTextValue'), .shortText), + (find.text('field1'), find.text('longTextValue'), .longText), + (find.text('field2'), find.text('choiceValue'), .choice), + (find.text('field3'), find.text('dateValue'), .date), + (find.text('field4'), find.text('http://example/linkValue'), .link), + (find.text('field5'), find.text('userValue'), .user), + (find.text('field6'), find.text('externalValue'), .externalAccount), + (find.text('field7'), find.text('pronounsValue'), .pronouns), ]; for (final testCase in testCases) { Finder labelFinder = testCase.$1; @@ -273,7 +273,7 @@ void main() { await setupPage(tester, users: [user], pageUserId: user.userId, - customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.link)], + customProfileFields: [eg.customProfileField(0, .link)], ); await tester.tap(find.text(testUrl)); @@ -292,7 +292,7 @@ void main() { users: [user], pageUserId: user.userId, customProfileFields: [ - eg.customProfileField(0, CustomProfileFieldType.externalAccount, + eg.customProfileField(0, .externalAccount, fieldData: '{"subtype": "external1"}') ], realmDefaultExternalAccounts: { @@ -324,7 +324,7 @@ void main() { await setupPage(tester, users: users, pageUserId: 1, - customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.user)], + customProfileFields: [eg.customProfileField(0, .user)], navigatorObserver: testNavObserver, ); @@ -345,7 +345,7 @@ void main() { await setupPage(tester, users: users, pageUserId: 1, - customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.user)], + customProfileFields: [eg.customProfileField(0, .user)], ); final textFinder = find.text('(unknown user)'); @@ -376,7 +376,7 @@ void main() { users: users, mutedUserIds: [2], pageUserId: 1, - customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.user)]); + customProfileFields: [eg.customProfileField(0, .user)]); check(find.text('Muted user')).findsOne(); check(mutedAvatarFinder(2)).findsOne(); @@ -401,7 +401,7 @@ void main() { await setupPage(tester, users: users, pageUserId: 1, - customProfileFields: [eg.customProfileField(0, CustomProfileFieldType.user)], + customProfileFields: [eg.customProfileField(0, .user)], ); final avatars = tester.widgetList(find.byType(Avatar)); @@ -424,16 +424,16 @@ void main() { await setupPage(tester, users: [user, user2], pageUserId: user.userId, customProfileFields: [ - eg.customProfileField(0, CustomProfileFieldType.shortText), - eg.customProfileField(1, CustomProfileFieldType.longText), - eg.customProfileField(2, CustomProfileFieldType.choice, + eg.customProfileField(0, .shortText), + eg.customProfileField(1, .longText), + eg.customProfileField(2, .choice, fieldData: '{"x": {"text": "$longString", "order": "1"}}'), // no [CustomProfileFieldType.date] because those can't be made long - eg.customProfileField(3, CustomProfileFieldType.link), - eg.customProfileField(4, CustomProfileFieldType.user), - eg.customProfileField(5, CustomProfileFieldType.externalAccount, + eg.customProfileField(3, .link), + eg.customProfileField(4, .user), + eg.customProfileField(5, .externalAccount, fieldData: '{"subtype": "external1"}'), - eg.customProfileField(6, CustomProfileFieldType.pronouns), + eg.customProfileField(6, .pronouns), ], realmDefaultExternalAccounts: { 'external1': RealmDefaultExternalAccount( name: 'external1', From 5499a6cdee48cde0355c5e9293837ace88830813 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 10 Jun 2026 10:44:26 +0430 Subject: [PATCH 18/29] dart [nfc]: Use dot shorthands for UserSettingName --- lib/api/model/events.dart | 12 ++++++------ lib/api/route/settings.dart | 12 ++++++------ lib/model/store.dart | 12 ++++++------ lib/widgets/profile.dart | 2 +- test/api/route/settings_test.dart | 14 +++++++------- test/model/store_test.dart | 6 ++---- test/widgets/autocomplete_test.dart | 2 +- test/widgets/emoji_reaction_test.dart | 4 ++-- test/widgets/profile_test.dart | 6 +++--- 9 files changed, 34 insertions(+), 36 deletions(-) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index b5154fff6..4e2faa225 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -261,15 +261,15 @@ class UserSettingsUpdateEvent extends Event { static Object? _readValue(Map json, String key) { final value = json['value']; switch (UserSettingName.fromRawString(json['property'] as String)) { - case UserSettingName.twentyFourHourTime: + case .twentyFourHourTime: return TwentyFourHourTimeMode.fromApiValue(value as bool?); - case UserSettingName.starredMessageCounts: - case UserSettingName.displayEmojiReactionUsers: + case .starredMessageCounts: + case .displayEmojiReactionUsers: return value as bool; - case UserSettingName.emojiset: + case .emojiset: return Emojiset.fromRawString(value as String); - case UserSettingName.webInboxShowChannelFolders: - case UserSettingName.presenceEnabled: + case .webInboxShowChannelFolders: + case .presenceEnabled: return value as bool; case null: return null; diff --git a/lib/api/route/settings.dart b/lib/api/route/settings.dart index 141994cb2..40e03d50c 100644 --- a/lib/api/route/settings.dart +++ b/lib/api/route/settings.dart @@ -11,18 +11,18 @@ Future updateSettings(ApiConnection connection, { final valueRaw = entry.value; final Object? value; switch (name) { - case UserSettingName.twentyFourHourTime: + case .twentyFourHourTime: final mode = (valueRaw as TwentyFourHourTimeMode); // TODO(server-future) allow localeDefault for servers that support it assert(mode != TwentyFourHourTimeMode.localeDefault); value = mode.toJson(); - case UserSettingName.starredMessageCounts: - case UserSettingName.displayEmojiReactionUsers: + case .starredMessageCounts: + case .displayEmojiReactionUsers: value = valueRaw as bool; - case UserSettingName.emojiset: + case .emojiset: value = RawParameter((valueRaw as Emojiset).toJson()); - case UserSettingName.webInboxShowChannelFolders: - case UserSettingName.presenceEnabled: + case .webInboxShowChannelFolders: + case .presenceEnabled: value = valueRaw as bool; } params[name.toJson()] = value; diff --git a/lib/model/store.dart b/lib/model/store.dart index 5b554bf64..799fb1937 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -909,17 +909,17 @@ class PerAccountStore extends PerAccountStoreBase with return; } switch (event.property!) { - case UserSettingName.twentyFourHourTime: + case .twentyFourHourTime: userSettings.twentyFourHourTime = event.value as TwentyFourHourTimeMode; - case UserSettingName.starredMessageCounts: + case .starredMessageCounts: userSettings.starredMessageCounts = event.value as bool; - case UserSettingName.displayEmojiReactionUsers: + case .displayEmojiReactionUsers: userSettings.displayEmojiReactionUsers = event.value as bool; - case UserSettingName.emojiset: + case .emojiset: userSettings.emojiset = event.value as Emojiset; - case UserSettingName.webInboxShowChannelFolders: + case .webInboxShowChannelFolders: userSettings.webInboxShowChannelFolders = event.value as bool; - case UserSettingName.presenceEnabled: + case .presenceEnabled: userSettings.presenceEnabled = event.value as bool; } notifyListeners(); diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index ee9f505a0..07997f800 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -297,7 +297,7 @@ class _InvisibleModeToggle extends StatelessWidget { return RemoteSettingBuilder( findValueInStore: (store) => !store.userSettings.presenceEnabled, sendValueToServer: (value) => updateSettings(store.connection, - newSettings: {UserSettingName.presenceEnabled: !value}), + newSettings: {.presenceEnabled: !value}), // TODO(#741) interpret API errors for user onError: (e, requestedValue) => reportErrorToUserBriefly( requestedValue diff --git a/test/api/route/settings_test.dart b/test/api/route/settings_test.dart index a89faaa99..fdfb75969 100644 --- a/test/api/route/settings_test.dart +++ b/test/api/route/settings_test.dart @@ -16,22 +16,22 @@ void main() { final expectedBodyFields = {}; for (final name in UserSettingName.values) { switch (name) { - case UserSettingName.twentyFourHourTime: + case .twentyFourHourTime: newSettings[name] = TwentyFourHourTimeMode.twelveHour; expectedBodyFields['twenty_four_hour_time'] = 'false'; - case UserSettingName.starredMessageCounts: + case .starredMessageCounts: newSettings[name] = false; expectedBodyFields['starred_message_counts'] = 'false'; - case UserSettingName.displayEmojiReactionUsers: + case .displayEmojiReactionUsers: newSettings[name] = false; expectedBodyFields['display_emoji_reaction_users'] = 'false'; - case UserSettingName.emojiset: + case .emojiset: newSettings[name] = Emojiset.googleBlob; expectedBodyFields['emojiset'] = 'google-blob'; - case UserSettingName.webInboxShowChannelFolders: + case .webInboxShowChannelFolders: newSettings[name] = false; expectedBodyFields['web_inbox_show_channel_folders'] = 'false'; - case UserSettingName.presenceEnabled: + case .presenceEnabled: newSettings[name] = true; expectedBodyFields['presence_enabled'] = 'true'; } @@ -52,7 +52,7 @@ void main() { // TODO(server-future) instead, check for twenty_four_hour_time: null // (could be an error-prone part of the JSONification) check(() => updateSettings(connection, - newSettings: {UserSettingName.twentyFourHourTime: TwentyFourHourTimeMode.localeDefault}) + newSettings: {.twentyFourHourTime: TwentyFourHourTimeMode.localeDefault}) ).throws(); }); }); diff --git a/test/model/store_test.dart b/test/model/store_test.dart index d4852153c..98d88dad5 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -817,8 +817,7 @@ void main() { check(store.userSettings.twentyFourHourTime) .equals(TwentyFourHourTimeMode.twelveHour); connection.prepare(json: GetEventsResult(events: [ - UserSettingsUpdateEvent(id: 2, - property: UserSettingName.twentyFourHourTime, value: true), + UserSettingsUpdateEvent(id: 2, property: .twentyFourHourTime, value: true), ], queueId: null).toJson()); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); @@ -856,8 +855,7 @@ void main() { check(store.userSettings.twentyFourHourTime) .equals(TwentyFourHourTimeMode.twelveHour); connection.prepare(json: GetEventsResult(events: [ - UserSettingsUpdateEvent(id: 2, - property: UserSettingName.twentyFourHourTime, value: true), + UserSettingsUpdateEvent(id: 2, property: .twentyFourHourTime, value: true), ], queueId: null).toJson()); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 5dd47fc50..38ff30023 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -486,7 +486,7 @@ void main() { final composeInputFinder = await setupToComposeInput(tester); final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.handleEvent(UserSettingsUpdateEvent(id: 1, - property: UserSettingName.emojiset, value: Emojiset.text)); + property: .emojiset, value: Emojiset.text)); // TODO(#226): Remove this extra edit when this bug is fixed. await tester.enterText(composeInputFinder, 'hi :'); diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index 72ccac41a..d7ad3344c 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -190,10 +190,10 @@ void main() { await store.handleEvent(RealmEmojiUpdateEvent(id: 1, realmEmoji: realmEmoji)); await store.handleEvent(UserSettingsUpdateEvent(id: 1, - property: UserSettingName.displayEmojiReactionUsers, + property: .displayEmojiReactionUsers, value: displayEmojiReactionUsers)); await store.handleEvent(UserSettingsUpdateEvent(id: 1, - property: UserSettingName.emojiset, + property: .emojiset, value: emojiset)); // This does mean that all image emoji will look the same… diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 3209c11d6..801188c34 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -565,7 +565,7 @@ void main() { void scheduleEventAfter(Duration duration, bool newInvisibleModeValue) async { await Future.delayed(duration); await store.handleEvent(UserSettingsUpdateEvent(id: 1, - property: UserSettingName.presenceEnabled, value: !newInvisibleModeValue)); + property: .presenceEnabled, value: !newInvisibleModeValue)); } void checkRequest(bool requestedInvisibleModeValue) { @@ -619,7 +619,7 @@ void main() { checkAppearsActive(tester, false); await store.handleEvent(UserSettingsUpdateEvent(id: 1, - property: UserSettingName.presenceEnabled, value: false)); + property: .presenceEnabled, value: false)); await tester.pump(); checkAppearsActive(tester, true); @@ -654,7 +654,7 @@ void main() { await setupPage(tester, pageUserId: eg.selfUser.userId); await store.handleEvent(UserSettingsUpdateEvent(id: 1, - property: UserSettingName.presenceEnabled, value: false)); + property: .presenceEnabled, value: false)); await tester.pump(); checkAppearsActive(tester, true); From 1f30d238b4ea2c6f5d4e3ae7a94252fd63e7a2ca Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 10 Jun 2026 12:32:22 +0430 Subject: [PATCH 19/29] api: Add UserSettingName.unknown We were already handling unrecognized setting names in UserSettingsUpdateEvent by making the corresponding field nullable. To follow our usual pattern for handling unknown values from the Zulip server API, added an "unknown" value to UserSettingName and made the field non-nullable. --- lib/api/model/events.dart | 10 +++++----- lib/api/model/events.g.dart | 5 +++-- lib/api/model/model.dart | 11 +++++++++-- lib/api/model/model.g.dart | 1 + lib/api/route/settings.dart | 2 ++ lib/model/store.dart | 9 ++++++--- test/api/model/events_checks.dart | 5 +++++ test/api/model/events_test.dart | 20 ++++++++++++++++++++ test/api/model/model_test.dart | 10 ++++++++++ test/api/route/settings_test.dart | 2 ++ 10 files changed, 63 insertions(+), 12 deletions(-) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 4e2faa225..767500385 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -244,11 +244,11 @@ class UserSettingsUpdateEvent extends Event { @JsonKey(includeToJson: true) String get op => 'update'; - /// The name of the setting, or null if we don't recognize it. - @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) - final UserSettingName? property; + /// The name of the setting, or [UserSettingName.unknown] if we don't recognize it. + @JsonKey(unknownEnumValue: UserSettingName.unknown) + final UserSettingName property; - /// The new value, or null if we don't recognize the setting. + /// The new value, or null if we don't recognize the setting ([UserSettingName.unknown]). /// /// This will have the type appropriate for [property]; for example, /// if the setting is boolean, then `value is bool` will always be true. @@ -271,7 +271,7 @@ class UserSettingsUpdateEvent extends Event { case .webInboxShowChannelFolders: case .presenceEnabled: return value as bool; - case null: + case .unknown: return null; } } diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index b7ef64d9e..90fe91e7e 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -85,10 +85,10 @@ UserSettingsUpdateEvent _$UserSettingsUpdateEventFromJson( Map json, ) => UserSettingsUpdateEvent( id: (json['id'] as num).toInt(), - property: $enumDecodeNullable( + property: $enumDecode( _$UserSettingNameEnumMap, json['property'], - unknownValue: JsonKey.nullForUndefinedEnumValue, + unknownValue: UserSettingName.unknown, ), value: UserSettingsUpdateEvent._readValue(json, 'value'), ); @@ -110,6 +110,7 @@ const _$UserSettingNameEnumMap = { UserSettingName.emojiset: 'emojiset', UserSettingName.webInboxShowChannelFolders: 'web_inbox_show_channel_folders', UserSettingName.presenceEnabled: 'presence_enabled', + UserSettingName.unknown: 'unknown', }; DeviceAddEvent _$DeviceAddEventFromJson(Map json) => diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 7f5a4a33d..fdf9421cb 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -341,13 +341,20 @@ enum UserSettingName { emojiset, webInboxShowChannelFolders, presenceEnabled, + // TODO: add more as needed + + /// A user setting (name) that: + /// - The server sends, but we currently don't support. + /// - The server may start sending in the future. + unknown, ; - /// Get a [UserSettingName] from a raw, snake-case string we recognize, else null. + /// Get a [UserSettingName] from a raw, snake-case string we recognize, + /// else [UserSettingName.unknown]. /// /// Example: /// 'display_emoji_reaction_users' -> UserSettingName.displayEmojiReactionUsers - static UserSettingName? fromRawString(String raw) => _byRawString[raw]; + static UserSettingName fromRawString(String raw) => _byRawString[raw] ?? unknown; // _$…EnumMap is thanks to `alwaysCreate: true` and `fieldRename: FieldRename.snake` static final _byRawString = _$UserSettingNameEnumMap diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 0921f0840..e563a8d34 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -576,6 +576,7 @@ const _$UserSettingNameEnumMap = { UserSettingName.emojiset: 'emojiset', UserSettingName.webInboxShowChannelFolders: 'web_inbox_show_channel_folders', UserSettingName.presenceEnabled: 'presence_enabled', + UserSettingName.unknown: 'unknown', }; const _$EmojisetEnumMap = { diff --git a/lib/api/route/settings.dart b/lib/api/route/settings.dart index 40e03d50c..b5a5d0b2d 100644 --- a/lib/api/route/settings.dart +++ b/lib/api/route/settings.dart @@ -24,6 +24,8 @@ Future updateSettings(ApiConnection connection, { case .webInboxShowChannelFolders: case .presenceEnabled: value = valueRaw as bool; + case .unknown: + continue; } params[name.toJson()] = value; } diff --git a/lib/model/store.dart b/lib/model/store.dart index 799fb1937..8508e1248 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -903,12 +903,12 @@ class PerAccountStore extends PerAccountStoreBase with // We don't yet store this data, so there's nothing to update. case UserSettingsUpdateEvent(): - assert(debugLog("server event: user_settings/update ${event.property?.name ?? '[unrecognized]'}")); - if (event.property == null) { + assert(debugLog("server event: user_settings/update ${event.property.name}")); + if (event.property == .unknown) { // unrecognized setting; do nothing return; } - switch (event.property!) { + switch (event.property) { case .twentyFourHourTime: userSettings.twentyFourHourTime = event.value as TwentyFourHourTimeMode; case .starredMessageCounts: @@ -921,6 +921,9 @@ class PerAccountStore extends PerAccountStoreBase with userSettings.webInboxShowChannelFolders = event.value as bool; case .presenceEnabled: userSettings.presenceEnabled = event.value as bool; + case .unknown: + // Shouldn't reach here because of the early return. + assert(false); } notifyListeners(); diff --git a/test/api/model/events_checks.dart b/test/api/model/events_checks.dart index 3cb819d45..4bbe97c3d 100644 --- a/test/api/model/events_checks.dart +++ b/test/api/model/events_checks.dart @@ -15,6 +15,11 @@ extension AlertWordsEventChecks on Subject { Subject> get alertWords => has((e) => e.alertWords, 'alertWords'); } +extension UserSettingsUpdateEventChecks on Subject { + Subject get property => has((e) => e.property, 'property'); + Subject get value => has((e) => e.value, 'value'); +} + extension DeviceUpdateEventChecks on Subject { Subject?> get pushKeyId => has((e) => e.pushKeyId, 'pushKeyId'); Subject?> get pushTokenId => has((e) => e.pushTokenId, 'pushTokenId'); diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart index 6e6c4643d..d59351b13 100644 --- a/test/api/model/events_test.dart +++ b/test/api/model/events_test.dart @@ -30,6 +30,26 @@ void main() { ).isEmpty(); }); + test('user_settings/update: unknown property', () { + final json = Map.unmodifiable({ + 'id': 1, + 'type': 'user_settings', + 'op': 'update', + 'property': 'twenty_four_hour_time', + 'value': true, + }); + + check(UserSettingsUpdateEvent.fromJson(json)) + ..property.equals(.twentyFourHourTime) + ..value.equals(TwentyFourHourTimeMode.twentyFourHour); + + for (final unknown in ['unknown_user_setting_name', '']) { + check(UserSettingsUpdateEvent.fromJson({...json, 'property': unknown})) + ..property.equals(.unknown) + ..value.equals(null); + } + }); + group('device/update', () { final baseJson = {'id': 1, 'type': 'device', 'op': 'update', 'device_id': 3 }; diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index 49df045e7..e666c0fbc 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -91,6 +91,16 @@ void main() { expected: (OptionNone(), OptionNone())); }); + test('UserSettingName.fromRawString handles unknown values', () { + check(UserSettingName.fromRawString('twenty_four_hour_time')) + .equals(.twentyFourHourTime); + + for (final unknown in ['unknown_user_setting_name', '']) { + check(UserSettingName.fromRawString(unknown)) + .equals(.unknown); + } + }); + group('User', () { final Map baseJson = Map.unmodifiable({ 'user_id': 123, diff --git a/test/api/route/settings_test.dart b/test/api/route/settings_test.dart index fdfb75969..5f2f70e45 100644 --- a/test/api/route/settings_test.dart +++ b/test/api/route/settings_test.dart @@ -34,6 +34,8 @@ void main() { case .presenceEnabled: newSettings[name] = true; expectedBodyFields['presence_enabled'] = 'true'; + case .unknown: + continue; } } From f51631d23f5f6da6f247a174309666bb71e4b37c Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 10 Jun 2026 22:55:44 +0430 Subject: [PATCH 20/29] dart [nfc]: Use dot shorthands for TwentyFourHourTimeMode --- lib/api/route/settings.dart | 2 +- lib/widgets/content.dart | 6 +++--- lib/widgets/message_list.dart | 12 ++++++------ test/example_data.dart | 2 +- test/model/store_test.dart | 9 ++++----- test/widgets/content_test.dart | 8 ++++---- test/widgets/message_list_test.dart | 6 +++--- 7 files changed, 22 insertions(+), 23 deletions(-) diff --git a/lib/api/route/settings.dart b/lib/api/route/settings.dart index b5a5d0b2d..858fa9af4 100644 --- a/lib/api/route/settings.dart +++ b/lib/api/route/settings.dart @@ -14,7 +14,7 @@ Future updateSettings(ApiConnection connection, { case .twentyFourHourTime: final mode = (valueRaw as TwentyFourHourTimeMode); // TODO(server-future) allow localeDefault for servers that support it - assert(mode != TwentyFourHourTimeMode.localeDefault); + assert(mode != .localeDefault); value = mode.toJson(); case .starredMessageCounts: case .displayEmojiReactionUsers: diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index a6dcc5fe9..be3082c85 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1439,9 +1439,9 @@ class GlobalTime extends StatelessWidget { // see zulip:web/styles/rendered_markdown.css . // TODO(i18n): localize; see plan with ffi in #45 final format = switch (twentyFourHourTimeMode) { - TwentyFourHourTimeMode.twelveHour => _format12, - TwentyFourHourTimeMode.twentyFourHour => _format24, - TwentyFourHourTimeMode.localeDefault => _formatLocaleDefault, + .twelveHour => _format12, + .twentyFourHour => _format24, + .localeDefault => _formatLocaleDefault, }; final text = format.format(node.datetime.toLocal()); final contentTheme = ContentTheme.of(context); diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 602e7c992..dfd3dff20 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2257,15 +2257,15 @@ enum MessageTimestampStyle { static final _timeFormatLocaleDefaultWithSeconds = DateFormat('jms'); static DateFormat _resolveTimeFormat(TwentyFourHourTimeMode mode) => switch (mode) { - TwentyFourHourTimeMode.twelveHour => _timeFormat12, - TwentyFourHourTimeMode.twentyFourHour => _timeFormat24, - TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefault, + .twelveHour => _timeFormat12, + .twentyFourHour => _timeFormat24, + .localeDefault => _timeFormatLocaleDefault, }; static DateFormat _resolveTimeFormatWithSeconds(TwentyFourHourTimeMode mode) => switch (mode) { - TwentyFourHourTimeMode.twelveHour => _timeFormat12WithSeconds, - TwentyFourHourTimeMode.twentyFourHour => _timeFormat24WithSeconds, - TwentyFourHourTimeMode.localeDefault => _timeFormatLocaleDefaultWithSeconds, + .twelveHour => _timeFormat12WithSeconds, + .twentyFourHour => _timeFormat24WithSeconds, + .localeDefault => _timeFormatLocaleDefaultWithSeconds, }; /// Format a [Message.timestamp] for this mode. diff --git a/test/example_data.dart b/test/example_data.dart index 92c39c082..889ec1c3f 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1393,7 +1393,7 @@ UserSettings userSettings({ bool? presenceEnabled, }) { return UserSettings( - twentyFourHourTime: twentyFourHourTime ?? TwentyFourHourTimeMode.twelveHour, + twentyFourHourTime: twentyFourHourTime ?? .twelveHour, starredMessageCounts: true, displayEmojiReactionUsers: displayEmojiReactionUsers ?? true, emojiset: emojiset ?? Emojiset.google, diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 98d88dad5..e5476ec05 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -12,7 +12,6 @@ import 'package:zulip/api/core.dart'; import 'package:zulip/api/exception.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; -import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/events.dart'; import 'package:zulip/api/route/realm.dart'; import 'package:zulip/log.dart'; @@ -815,14 +814,14 @@ void main() { // Pick some arbitrary event and check it gets processed on the store. check(store.userSettings.twentyFourHourTime) - .equals(TwentyFourHourTimeMode.twelveHour); + .equals(.twelveHour); connection.prepare(json: GetEventsResult(events: [ UserSettingsUpdateEvent(id: 2, property: .twentyFourHourTime, value: true), ], queueId: null).toJson()); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); check(store.userSettings.twentyFourHourTime) - .equals(TwentyFourHourTimeMode.twentyFourHour); + .equals(.twentyFourHour); })); void checkReload(FutureOr Function() prepareError, { @@ -853,14 +852,14 @@ void main() { updateMachine.debugPauseLoop(); updateMachine.poll(); check(store.userSettings.twentyFourHourTime) - .equals(TwentyFourHourTimeMode.twelveHour); + .equals(.twelveHour); connection.prepare(json: GetEventsResult(events: [ UserSettingsUpdateEvent(id: 2, property: .twentyFourHourTime, value: true), ], queueId: null).toJson()); updateMachine.debugAdvanceLoop(); async.elapse(Duration.zero); check(store.userSettings.twentyFourHourTime) - .equals(TwentyFourHourTimeMode.twentyFourHour); + .equals(.twentyFourHour); }); } diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 77eaadecb..7f9e4002f 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -1594,7 +1594,7 @@ void main() { Future prepare( WidgetTester tester, - [TwentyFourHourTimeMode twentyFourHourTimeMode = TwentyFourHourTimeMode.localeDefault] + [TwentyFourHourTimeMode twentyFourHourTimeMode = .localeDefault] ) async { final initialSnapshot = eg.initialSnapshot() ..userSettings.twentyFourHourTime = twentyFourHourTimeMode; @@ -1611,17 +1611,17 @@ void main() { }); testWidgets('TwentyFourHourTimeMode.twelveHour', (tester) async { - await prepare(tester, TwentyFourHourTimeMode.twelveHour); + await prepare(tester, .twelveHour); check(find.textContaining(renderedTextRegexpTwelveHour)).findsOne(); }); testWidgets('TwentyFourHourTimeMode.twentyFourHour', (tester) async { - await prepare(tester, TwentyFourHourTimeMode.twentyFourHour); + await prepare(tester, .twentyFourHour); check(find.textContaining(renderedTextRegexpTwentyFourHour)).findsOne(); }); testWidgets('TwentyFourHourTimeMode.localeDefault', (tester) async { - await prepare(tester, TwentyFourHourTimeMode.localeDefault); + await prepare(tester, .localeDefault); // This expectation holds as long as we're always formatting in en_US, // the default locale, which uses the twelve-hour format. // TODO(#1727) follow the actual locale; test with different locales diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index fe60e28a2..afa6b451a 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -2167,12 +2167,12 @@ void main() { for (final (timestampStr, expectedTwelveHour, expectedTwentyFourHour) in cases) { for (final mode in TwentyFourHourTimeMode.values) { final expected = switch (mode) { - TwentyFourHourTimeMode.twelveHour => expectedTwelveHour, - TwentyFourHourTimeMode.twentyFourHour => expectedTwentyFourHour, + .twelveHour => expectedTwelveHour, + .twentyFourHour => expectedTwentyFourHour, // This expectation will hold as long as we're always using the // default locale, en_US, which uses the twelve-hour format. // TODO(#1727) test with other locales - TwentyFourHourTimeMode.localeDefault => expectedTwelveHour, + .localeDefault => expectedTwelveHour, }; test('${style.name} in ${mode.name}: $timestampStr returns $expected', () { From d3ee40718cfa5e173985d45d390a6dc64979201c Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 10 Jun 2026 22:45:37 +0430 Subject: [PATCH 21/29] api [nfc]: Explain why TwentyFourHourTimeMode cannot be unknown --- lib/api/model/model.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index fdf9421cb..19c1e8f36 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -375,6 +375,12 @@ enum TwentyFourHourTimeMode { // TODO(server-future) Write down what server N starts sending null; // adjust the comment; leave a TODO(server-N) to delete the comment localeDefault(apiValue: null), + + // We can't have an unknown value because all values permitted by the + // server API (true, false, and null) are represented above. + // If the server ever sends a different kind of value, that would indicate + // a change to the API and we should update this model accordingly. + // unknown(apiValue: null), ; const TwentyFourHourTimeMode({required this.apiValue}); From a2b6829712e853b65371c12954b76f1a438d0f59 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 10 Jun 2026 23:12:35 +0430 Subject: [PATCH 22/29] dart [nfc]: Use dot shorthands for Emojiset --- lib/model/emoji.dart | 2 +- test/api/model/initial_snapshot_test.dart | 2 +- test/example_data.dart | 2 +- test/widgets/emoji_reaction_test.dart | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart index 7338c50c8..959ab356d 100644 --- a/lib/model/emoji.dart +++ b/lib/model/emoji.dart @@ -20,7 +20,7 @@ sealed class EmojiDisplay { EmojiDisplay resolve(UserSettings userSettings) { if (this is TextEmojiDisplay) return this; - if (userSettings.emojiset == Emojiset.text) { + if (userSettings.emojiset == .text) { return TextEmojiDisplay(emojiName: emojiName); } return this; diff --git a/test/api/model/initial_snapshot_test.dart b/test/api/model/initial_snapshot_test.dart index 2dff367d2..918099032 100644 --- a/test/api/model/initial_snapshot_test.dart +++ b/test/api/model/initial_snapshot_test.dart @@ -69,7 +69,7 @@ void main() { for (final unknownValue in unknownValues) { final json = eg.userSettings().toJson()..['emojiset'] = unknownValue; final settings = UserSettings.fromJson(json); - check(settings.emojiset).equals(Emojiset.unknown); + check(settings.emojiset).equals(.unknown); } }); } diff --git a/test/example_data.dart b/test/example_data.dart index 889ec1c3f..91680175e 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1396,7 +1396,7 @@ UserSettings userSettings({ twentyFourHourTime: twentyFourHourTime ?? .twelveHour, starredMessageCounts: true, displayEmojiReactionUsers: displayEmojiReactionUsers ?? true, - emojiset: emojiset ?? Emojiset.google, + emojiset: emojiset ?? .google, webInboxShowChannelFolders: webInboxShowChannelFolders ?? true, presenceEnabled: presenceEnabled ?? true, ); diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index d7ad3344c..233b964f5 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -143,7 +143,7 @@ void main() { group('ReactionChipsList', () { // Smoke tests under various conditions. for (final displayEmojiReactionUsers in [true, false]) { - for (final emojiset in [Emojiset.text, Emojiset.google]) { + for (final emojiset in [.text, .google]) { for (final textDirection in TextDirection.values) { for (final textScaleFactor in kTextScaleFactors) { void runSmokeTest( From b82124bd96d2c094070f9d0b2a6e7b0292da2657 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 11 Jun 2026 11:33:18 +0430 Subject: [PATCH 23/29] api [nfc]: Correct the dartdoc of Emojiset.fromRawString --- lib/api/model/model.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 19c1e8f36..579838a3b 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -407,7 +407,7 @@ enum Emojiset { text, unknown; - /// Get an [Emojiset] from a raw string. Throws if the string is unrecognized. + /// Get an [Emojiset] from a raw string we recognize, else [Emojiset.unknown]. /// /// Example: /// 'google-blob' -> Emojiset.googleBlob From 6f848e18288fd8bc13167d8a0b9f0ac56de248b6 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 10 Jun 2026 23:25:12 +0430 Subject: [PATCH 24/29] dart [nfc]: Use dot shorthands for UserRole --- lib/model/channel.dart | 10 +++--- lib/model/message.dart | 10 +++--- lib/model/realm.dart | 12 +++---- lib/widgets/home.dart | 3 +- lib/widgets/profile.dart | 12 +++---- lib/widgets/subscription_list.dart | 2 +- test/example_data.dart | 2 +- test/model/channel_test.dart | 54 ++++++++++++++--------------- test/model/message_test.dart | 14 ++++---- test/model/realm_test.dart | 28 +++++++-------- test/widgets/action_sheet_test.dart | 6 ++-- test/widgets/compose_box_test.dart | 14 ++++---- test/widgets/home_test.dart | 4 +-- 13 files changed, 85 insertions(+), 86 deletions(-) diff --git a/lib/model/channel.dart b/lib/model/channel.dart index 4f994cede..abe4a8426 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -236,7 +236,7 @@ mixin ChannelStore on UserStore { if (channel is Subscription) return true; // Here web calls has_metadata_access... but that always returns true, // as its comment says. - if (selfUser.role == UserRole.guest) return false; + if (selfUser.role == .guest) return false; if (!channel.inviteOnly) return true; return _selfHasContentAccessViaGroupPermissions(channel); } @@ -288,14 +288,14 @@ mixin ChannelStore on UserStore { switch (inChannel.channelPostPolicy!) { case ChannelPostPolicy.any: return true; case ChannelPostPolicy.fullMembers: { - if (!role.isAtLeast(UserRole.member)) return false; - if (role == UserRole.member) { + if (!role.isAtLeast(.member)) return false; + if (role == .member) { return selfHasPassedWaitingPeriod(byDate: atDate); } return true; } - case ChannelPostPolicy.moderators: return role.isAtLeast(UserRole.moderator); - case ChannelPostPolicy.administrators: return role.isAtLeast(UserRole.administrator); + case ChannelPostPolicy.moderators: return role.isAtLeast(.moderator); + case ChannelPostPolicy.administrators: return role.isAtLeast(.administrator); case ChannelPostPolicy.unknown: return true; } } diff --git a/lib/model/message.dart b/lib/model/message.dart index ba28b7863..a375b2a44 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -187,18 +187,18 @@ mixin MessageStore on ChannelStore { case .everyone: return true; case .members: - return role.isAtLeast(UserRole.member); + return role.isAtLeast(.member); case .fullMembers: { - if (!role.isAtLeast(UserRole.member)) return false; - if (role == UserRole.member) { + if (!role.isAtLeast(.member)) return false; + if (role == .member) { return selfHasPassedWaitingPeriod(byDate: atDate); } return true; } case .moderators: - return role.isAtLeast(UserRole.moderator); + return role.isAtLeast(.moderator); case .admins: - return role.isAtLeast(UserRole.administrator); + return role.isAtLeast(.administrator); } } } diff --git a/lib/model/realm.dart b/lib/model/realm.dart index 06155b880..2691b79ef 100644 --- a/lib/model/realm.dart +++ b/lib/model/realm.dart @@ -317,7 +317,7 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { final config = _groupSettingConfig(type, name); - if (_selfUserRole == UserRole.guest && !config.allowEveryoneGroup) { + if (_selfUserRole == .guest && !config.allowEveryoneGroup) { return false; } @@ -348,7 +348,7 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { case SystemGroupName.everyone: return true; case SystemGroupName.members: - return _selfUserRole.isAtLeast(UserRole.member); + return _selfUserRole.isAtLeast(.member); case SystemGroupName.fullMembers: // There aren't any permissions where this is the default, and we // probably won't add any. So for now we skip the complication of @@ -356,13 +356,13 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore { assert(() { throw UnimplementedError(); }()); - return _selfUserRole.isAtLeast(UserRole.member); + return _selfUserRole.isAtLeast(.member); case SystemGroupName.moderators: - return _selfUserRole.isAtLeast(UserRole.moderator); + return _selfUserRole.isAtLeast(.moderator); case SystemGroupName.administrators: - return _selfUserRole.isAtLeast(UserRole.administrator); + return _selfUserRole.isAtLeast(.administrator); case SystemGroupName.owners: - return _selfUserRole.isAtLeast(UserRole.owner); + return _selfUserRole.isAtLeast(.owner); case SystemGroupName.nobody: return false; } diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index 79ce45f4e..52c4c1a15 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import '../api/core.dart'; -import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/narrow.dart'; import 'about_zulip.dart'; @@ -871,7 +870,7 @@ class _ServerCompatBanner extends StatelessWidget { final showCompatBanner = store.zulipFeatureLevel < kMinSupportedZulipFeatureLevel && !globalStore.getServerCompatBannerDismissed(store.accountId); - final isAtLeastAdmin = store.selfUser.role.isAtLeast(UserRole.administrator); + final isAtLeastAdmin = store.selfUser.role.isAtLeast(.administrator); final label = isAtLeastAdmin ? zulipLocalizations.serverCompatBannerAdminMessage( store.realmUrl.toString(), store.zulipVersion) diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index 07997f800..c78c94e87 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -334,12 +334,12 @@ class _ProfileErrorPage extends StatelessWidget { String roleToLabel(UserRole role, ZulipLocalizations zulipLocalizations) { return switch (role) { - UserRole.owner => zulipLocalizations.userRoleOwner, - UserRole.administrator => zulipLocalizations.userRoleAdministrator, - UserRole.moderator => zulipLocalizations.userRoleModerator, - UserRole.member => zulipLocalizations.userRoleMember, - UserRole.guest => zulipLocalizations.userRoleGuest, - UserRole.unknown => zulipLocalizations.userRoleUnknown, + .owner => zulipLocalizations.userRoleOwner, + .administrator => zulipLocalizations.userRoleAdministrator, + .moderator => zulipLocalizations.userRoleModerator, + .member => zulipLocalizations.userRoleMember, + .guest => zulipLocalizations.userRoleGuest, + .unknown => zulipLocalizations.userRoleUnknown, }; } diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index 5143ab6c6..1ecc88195 100644 --- a/lib/widgets/subscription_list.dart +++ b/lib/widgets/subscription_list.dart @@ -114,7 +114,7 @@ class _SubscriptionListPageBodyState extends State wit // > Guests can never subscribe themselves to a channel. // (Web also hides the corresponding link for guests; // see web/templates/left_sidebar.hbs.) - && store.selfUser.role.isAtLeast(UserRole.member); + && store.selfUser.role.isAtLeast(.member); final List pinned = []; final List unpinned = []; diff --git a/test/example_data.dart b/test/example_data.dart index 91680175e..f63900cc4 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -309,7 +309,7 @@ User user({ isBot: isBot ?? false, botType: null, botOwnerId: botOwnerId, - role: role ?? UserRole.member, + role: role ?? .member, timezone: 'UTC', avatarUrl: avatarUrl, avatarVersion: 0, diff --git a/test/model/channel_test.dart b/test/model/channel_test.dart index 9c0369524..6a671e311 100644 --- a/test/model/channel_test.dart +++ b/test/model/channel_test.dart @@ -587,33 +587,33 @@ void main() { }); group('selfCanSendMessage, legacy', () { - final testCases = [ - (ChannelPostPolicy.unknown, UserRole.guest, true), - (ChannelPostPolicy.unknown, UserRole.member, true), - (ChannelPostPolicy.unknown, UserRole.moderator, true), - (ChannelPostPolicy.unknown, UserRole.administrator, true), - (ChannelPostPolicy.unknown, UserRole.owner, true), - (ChannelPostPolicy.any, UserRole.guest, true), - (ChannelPostPolicy.any, UserRole.member, true), - (ChannelPostPolicy.any, UserRole.moderator, true), - (ChannelPostPolicy.any, UserRole.administrator, true), - (ChannelPostPolicy.any, UserRole.owner, true), - (ChannelPostPolicy.fullMembers, UserRole.guest, false), + final testCases = <(ChannelPostPolicy, UserRole, bool)>[ + (ChannelPostPolicy.unknown, .guest, true), + (ChannelPostPolicy.unknown, .member, true), + (ChannelPostPolicy.unknown, .moderator, true), + (ChannelPostPolicy.unknown, .administrator, true), + (ChannelPostPolicy.unknown, .owner, true), + (ChannelPostPolicy.any, .guest, true), + (ChannelPostPolicy.any, .member, true), + (ChannelPostPolicy.any, .moderator, true), + (ChannelPostPolicy.any, .administrator, true), + (ChannelPostPolicy.any, .owner, true), + (ChannelPostPolicy.fullMembers, .guest, false), // The fullMembers/member case gets its own tests further below. - // (ChannelPostPolicy.fullMembers, UserRole.member, /* complicated */), - (ChannelPostPolicy.fullMembers, UserRole.moderator, true), - (ChannelPostPolicy.fullMembers, UserRole.administrator, true), - (ChannelPostPolicy.fullMembers, UserRole.owner, true), - (ChannelPostPolicy.moderators, UserRole.guest, false), - (ChannelPostPolicy.moderators, UserRole.member, false), - (ChannelPostPolicy.moderators, UserRole.moderator, true), - (ChannelPostPolicy.moderators, UserRole.administrator, true), - (ChannelPostPolicy.moderators, UserRole.owner, true), - (ChannelPostPolicy.administrators, UserRole.guest, false), - (ChannelPostPolicy.administrators, UserRole.member, false), - (ChannelPostPolicy.administrators, UserRole.moderator, false), - (ChannelPostPolicy.administrators, UserRole.administrator, true), - (ChannelPostPolicy.administrators, UserRole.owner, true), + // (ChannelPostPolicy.fullMembers, .member, /* complicated */), + (ChannelPostPolicy.fullMembers, .moderator, true), + (ChannelPostPolicy.fullMembers, .administrator, true), + (ChannelPostPolicy.fullMembers, .owner, true), + (ChannelPostPolicy.moderators, .guest, false), + (ChannelPostPolicy.moderators, .member, false), + (ChannelPostPolicy.moderators, .moderator, true), + (ChannelPostPolicy.moderators, .administrator, true), + (ChannelPostPolicy.moderators, .owner, true), + (ChannelPostPolicy.administrators, .guest, false), + (ChannelPostPolicy.administrators, .member, false), + (ChannelPostPolicy.administrators, .moderator, false), + (ChannelPostPolicy.administrators, .administrator, true), + (ChannelPostPolicy.administrators, .owner, true), ]; for (final (ChannelPostPolicy policy, UserRole role, bool canPost) in testCases) { @@ -643,7 +643,7 @@ void main() { realmUsers: [selfUser])); User memberUser({required String dateJoined}) => eg.user( - role: UserRole.member, dateJoined: dateJoined); + role: .member, dateJoined: dateJoined); test('a "full" member -> can post in the channel', () { final store = localStore( diff --git a/test/model/message_test.dart b/test/model/message_test.dart index f3374acc3..af1b37edd 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -1366,7 +1366,7 @@ void main() { inRealmCanDeleteAnyMessageGroup: false, isChannelArchived: false, realmDeleteOwnMessagePolicy: .everyone, - selfUserRole: UserRole.member, + selfUserRole: .member, ))) ..equals(await evaluate( CanDeleteMessageParams.pre407( @@ -1386,7 +1386,7 @@ void main() { inRealmCanDeleteAnyMessageGroup: false, isChannelArchived: false, realmDeleteOwnMessagePolicy: .admins, - selfUserRole: UserRole.administrator, + selfUserRole: .administrator, ))) ..equals(await evaluate( CanDeleteMessageParams.pre407( @@ -1406,7 +1406,7 @@ void main() { inRealmCanDeleteAnyMessageGroup: false, isChannelArchived: false, realmDeleteOwnMessagePolicy: .admins, - selfUserRole: UserRole.moderator, + selfUserRole: .moderator, )))..equals(await evaluate( CanDeleteMessageParams.pre407( senderConfig: CanDeleteMessageSenderConfig.self, @@ -1430,7 +1430,7 @@ void main() { timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, isChannelArchived: false, realmDeleteOwnMessagePolicy: .everyone, - selfUserRole: UserRole.member, + selfUserRole: .member, )))..equals(await evaluate( CanDeleteMessageParams.pre291( senderConfig: CanDeleteMessageSenderConfig.otherHuman, @@ -1438,7 +1438,7 @@ void main() { inRealmCanDeleteAnyMessageGroup: false, isChannelArchived: false, realmDeleteOwnMessagePolicy: .everyone, - selfUserRole: UserRole.member))) + selfUserRole: .member))) ..isFalse(); }); @@ -1449,7 +1449,7 @@ void main() { timeLimitConfig: CanDeleteMessageTimeLimitConfig.notLimited, isChannelArchived: false, realmDeleteOwnMessagePolicy: .everyone, - selfUserRole: UserRole.administrator, + selfUserRole: .administrator, )))..equals(await evaluate( CanDeleteMessageParams.pre291( senderConfig: CanDeleteMessageSenderConfig.otherHuman, @@ -1457,7 +1457,7 @@ void main() { inRealmCanDeleteAnyMessageGroup: true, isChannelArchived: false, realmDeleteOwnMessagePolicy: .everyone, - selfUserRole: UserRole.administrator))) + selfUserRole: .administrator))) ..isTrue(); }); }); diff --git a/test/model/realm_test.dart b/test/model/realm_test.dart index 7e2804197..c9c395031 100644 --- a/test/model/realm_test.dart +++ b/test/model/realm_test.dart @@ -94,21 +94,21 @@ void main() { }); test('guest -> no permission, despite group', () { - final selfUser = eg.user(role: UserRole.guest); + final selfUser = eg.user(role: .guest); final group = eg.userGroup(members: [selfUser.userId]); check(hasPermission(selfUser, group, 'can_subscribe_group')) .isFalse(); }); test('guest -> still has permission, if allowEveryoneGroup', () { - final selfUser = eg.user(role: UserRole.guest); + final selfUser = eg.user(role: .guest); final group = eg.userGroup(members: [selfUser.userId]); check(hasPermission(selfUser, group, 'can_send_message_group')) .isTrue(); }); test('guest not in group -> no permission, even if allowEveryoneGroup', () { - final selfUser = eg.user(role: UserRole.guest); + final selfUser = eg.user(role: .guest); final group = eg.userGroup(members: []); check(hasPermission(selfUser, group, 'can_send_message_group')) .isFalse(); @@ -141,16 +141,16 @@ void main() { break; case SystemGroupName.everyone: test('everyone', () { - prepare(selfUserRole: UserRole.guest); + prepare(selfUserRole: .guest); doCheck(GroupSettingType.realm, 'can_access_all_users_group', true); }); case SystemGroupName.members: test('members, is guest', () { - prepare(selfUserRole: UserRole.guest); + prepare(selfUserRole: .guest); doCheck(GroupSettingType.realm, 'can_add_custom_emoji_group', false); }); test('members, is member', () { - prepare(selfUserRole: UserRole.member); + prepare(selfUserRole: .member); doCheck(GroupSettingType.realm, 'can_add_custom_emoji_group', true); }); case SystemGroupName.fullMembers: @@ -158,34 +158,34 @@ void main() { break; case SystemGroupName.moderators: test('moderators, is member', () { - prepare(selfUserRole: UserRole.member); + prepare(selfUserRole: .member); doCheck(GroupSettingType.realm, 'can_set_delete_message_policy_group', false); }); test('moderators, is moderator', () { - prepare(selfUserRole: UserRole.moderator); + prepare(selfUserRole: .moderator); doCheck(GroupSettingType.realm, 'can_set_delete_message_policy_group', true); }); case SystemGroupName.administrators: test('administrators, is moderator', () { - prepare(selfUserRole: UserRole.moderator); + prepare(selfUserRole: .moderator); doCheck(GroupSettingType.stream, 'can_remove_subscribers_group', false); }); test('administrators, is administrator', () { - prepare(selfUserRole: UserRole.administrator); + prepare(selfUserRole: .administrator); doCheck(GroupSettingType.stream, 'can_remove_subscribers_group', true); }); case SystemGroupName.owners: test('owners, is administrator', () { - prepare(selfUserRole: UserRole.administrator); + prepare(selfUserRole: .administrator); doCheck(GroupSettingType.realm, 'can_create_web_public_channel_group', false); }); test('owners, is owner', () { - prepare(selfUserRole: UserRole.owner); + prepare(selfUserRole: .owner); doCheck(GroupSettingType.realm, 'can_create_web_public_channel_group', true); }); case SystemGroupName.nobody: test('nobody', () { - prepare(selfUserRole: UserRole.owner); + prepare(selfUserRole: .owner); doCheck(GroupSettingType.stream, 'can_delete_own_message_group', false); }); } @@ -194,7 +194,7 @@ void main() { test('throw on unknown name', () { // We should know about all the permissions we're trying to implement, // even the ones old servers don't know about. - prepare(selfUserRole: UserRole.member); + prepare(selfUserRole: .member); check(() => store.selfHasPermissionForGroupSetting(null, GroupSettingType.realm, 'example_future_permission_name'), ).throws(); diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 519715ddd..f1439137b 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -81,7 +81,7 @@ Future setupToMessageActionSheet(WidgetTester tester, { assert(narrow.containsMessage(message)!); selfUser ??= eg.selfUser; - assert(!(hasDeletePermission && selfUser.role == UserRole.guest)); + assert(!(hasDeletePermission && selfUser.role == .guest)); final selfAccount = eg.account(user: selfUser, zulipFeatureLevel: zulipFeatureLevel); await testBinding.globalStore.add( @@ -2159,14 +2159,14 @@ void main() { }); testWidgets('no error if user lost posting permission after action sheet opened', (tester) async { - final selfUser = eg.user(role: UserRole.member); + final selfUser = eg.user(role: .member); final stream = eg.stream(); final message = eg.streamMessage(stream: stream); await setupToMessageActionSheet(tester, selfUser: selfUser, message: message, narrow: TopicNarrow.ofMessage(message)); await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: selfUser.userId, - role: UserRole.guest)); + role: .guest)); await store.handleEvent(eg.channelUpdateEvent(stream, property: ChannelPropertyName.channelPostPolicy, value: ChannelPostPolicy.administrators)); diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 05d7b5668..e4033eb24 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -1734,7 +1734,7 @@ void main() { await prepareComposeBox(tester, narrow: narrow, selfUser: eg.user( - role: canSend ? UserRole.administrator : UserRole.member), + role: canSend ? .administrator : .member), streams: [channel], subscriptions: isChannelSubscribed ? [eg.subscription(channel)] : []); checkComposeBoxIsShown(expected, @@ -1840,7 +1840,7 @@ void main() { testRefreshSubscribeButtons(narrow: topicNarrow, canSendMessages: true); testWidgets('user loses privilege -> compose box is replaced with the banner', (tester) async { - final selfUser = eg.user(role: UserRole.administrator); + final selfUser = eg.user(role: .administrator); await prepareComposeBox(tester, narrow: const ChannelNarrow(1), selfUser: selfUser, @@ -1849,13 +1849,13 @@ void main() { checkComposeBox(isShown: true); await store.handleEvent(RealmUserUpdateEvent(id: 1, - userId: selfUser.userId, role: UserRole.moderator)); + userId: selfUser.userId, role: .moderator)); await tester.pump(); checkComposeBox(isShown: false); }); testWidgets('user gains privilege -> banner is replaced with the compose box', (tester) async { - final selfUser = eg.user(role: UserRole.guest); + final selfUser = eg.user(role: .guest); await prepareComposeBox(tester, narrow: const ChannelNarrow(1), selfUser: selfUser, @@ -1864,13 +1864,13 @@ void main() { checkComposeBox(isShown: false); await store.handleEvent(RealmUserUpdateEvent(id: 1, - userId: selfUser.userId, role: UserRole.administrator)); + userId: selfUser.userId, role: .administrator)); await tester.pump(); checkComposeBox(isShown: true); }); testWidgets('channel policy becomes stricter -> compose box is replaced with the banner', (tester) async { - final selfUser = eg.user(role: UserRole.guest); + final selfUser = eg.user(role: .guest); final channel = eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.any); @@ -1888,7 +1888,7 @@ void main() { }); testWidgets('channel policy becomes less strict -> banner is replaced with the compose box', (tester) async { - final selfUser = eg.user(role: UserRole.moderator); + final selfUser = eg.user(role: .moderator); final channel = eg.stream(streamId: 1, channelPostPolicy: ChannelPostPolicy.administrators); diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 31f2f4a78..ae49fa47e 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -760,7 +760,7 @@ void main () { await prepareBanner(tester, zulipFeatureLevel: unsupportedFeatureLevel, zulipVersion: unsupportedVersion, - selfUser: eg.user(role: UserRole.administrator)); + selfUser: eg.user(role: .administrator)); check(find.text(zulipLocalizations.serverCompatBannerAdminMessage( sampleRealmUrl, unsupportedVersion))).findsOne(); }); @@ -769,7 +769,7 @@ void main () { await prepareBanner(tester, zulipFeatureLevel: unsupportedFeatureLevel, zulipVersion: unsupportedVersion, - selfUser: eg.user(role: UserRole.owner)); + selfUser: eg.user(role: .owner)); check(find.text(zulipLocalizations.serverCompatBannerAdminMessage( sampleRealmUrl, unsupportedVersion))).findsOne(); }); From b34b43a7f29b0ab8405ebaba456511e165e6badc Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 10 Jun 2026 23:29:33 +0430 Subject: [PATCH 25/29] dart [nfc]: Use dot shorthands for PresenceStatus --- lib/model/presence.dart | 10 +++++----- lib/widgets/profile.dart | 4 ++-- lib/widgets/user.dart | 4 ++-- test/api/route/users_test.dart | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/model/presence.dart b/lib/model/presence.dart index 882f12031..8772c33c2 100644 --- a/lib/model/presence.dart +++ b/lib/model/presence.dart @@ -76,14 +76,14 @@ class Presence extends HasRealmStore with ChangeNotifier { // when the user closes the app completely. result = await updatePresence(connection, pingOnly: pingOnly, - status: PresenceStatus.idle, + status: .idle, newUserInput: false); case AppLifecycleState.resumed: // > […] the default running mode for a running application that has // > input focus and is visible. result = await updatePresence(connection, pingOnly: pingOnly, - status: PresenceStatus.active, + status: .active, newUserInput: true); case AppLifecycleState.inactive: // > At least one view of the application is visible, but none have @@ -92,7 +92,7 @@ class Presence extends HasRealmStore with ChangeNotifier { // to upload. result = await updatePresence(connection, pingOnly: pingOnly, - status: PresenceStatus.active, + status: .active, newUserInput: false); } if (!pingOnly) { @@ -141,12 +141,12 @@ class Presence extends HasRealmStore with ChangeNotifier { final PerUserPresence(:activeTimestamp, :idleTimestamp) = perUserPresence; if (now - activeTimestamp <= serverPresenceOfflineThresholdSeconds) { - return PresenceStatus.active; + return .active; } else if (now - idleTimestamp <= serverPresenceOfflineThresholdSeconds) { // The API doc is kind of confusing, but this seems correct: // https://chat.zulip.org/#narrow/channel/378-api-design/topic/presence.3A.20.22potentially.20present.22.3F/near/2202431 // TODO clarify that API doc - return PresenceStatus.idle; + return .idle; } else { return null; } diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index c78c94e87..7bd49162d 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -191,8 +191,8 @@ class _LastActiveTimeState extends State<_LastActiveTime> with PerAccountStoreAw final status = model!.presenceStatusForUser(widget.userId, utcNow: nowDate); switch (status) { - case PresenceStatus.active: return zulipLocalizations.userActiveNow; - case PresenceStatus.idle: return zulipLocalizations.userIdle; + case .active: return zulipLocalizations.userActiveNow; + case .idle: return zulipLocalizations.userIdle; case null: break; // handle below } diff --git a/lib/widgets/user.dart b/lib/widgets/user.dart index 013dfcea0..83d9d7279 100644 --- a/lib/widgets/user.dart +++ b/lib/widgets/user.dart @@ -263,9 +263,9 @@ class _PresenceCircleState extends State with PerAccountStoreAwa } else { return SizedBox.square(dimension: widget.size); } - case PresenceStatus.active: + case .active: color = designVariables.statusOnline; - case PresenceStatus.idle: + case .idle: gradient = LinearGradient( begin: AlignmentDirectional.centerStart, end: AlignmentDirectional.centerEnd, diff --git a/test/api/route/users_test.dart b/test/api/route/users_test.dart index 16975bbc2..deb33532c 100644 --- a/test/api/route/users_test.dart +++ b/test/api/route/users_test.dart @@ -41,7 +41,7 @@ void main() { historyLimitDays: 21, newUserInput: false, pingOnly: false, - status: PresenceStatus.active, + status: .active, ); check(connection.takeRequests()).single.isA() ..method.equals('POST') From dede1ad51c8c7673fec22b8980f3f6996b40e17b Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 11 Jun 2026 00:23:12 +0430 Subject: [PATCH 26/29] api [nfc]: Explain why PresenceStatus cannot be unknown --- lib/api/model/model.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 579838a3b..f3789ae4f 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -626,7 +626,13 @@ class PerUserPresence { @JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true) enum PresenceStatus { active, - idle; + idle, + + // We don't accept an unknown value. The [PerClientPresence] format is + // considered legacy and is expected to be removed in a future server release, + // making additions to this enum unlikely. + // unknown, + ; String toJson() => _$PresenceStatusEnumMap[this]!; } From e246559424bdc0655684b43a10abcf204b42b559 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 11 Jun 2026 15:09:12 +0430 Subject: [PATCH 27/29] api [nfc]: Rename ChannelPropertyName to ChannelProperty To make its name consistent with SubscriptionProperty. --- lib/api/model/events.dart | 36 +++++++++++++-------------- lib/api/model/events.g.dart | 38 ++++++++++++++--------------- lib/api/model/model.dart | 12 ++++----- lib/api/model/model.g.dart | 34 +++++++++++++------------- lib/model/channel.dart | 32 ++++++++++++------------ test/example_data.dart | 34 +++++++++++++------------- test/model/channel_test.dart | 4 +-- test/model/test_store.dart | 2 +- test/widgets/action_sheet_test.dart | 8 +++--- test/widgets/compose_box_test.dart | 12 ++++----- 10 files changed, 106 insertions(+), 106 deletions(-) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 767500385..d77268890 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -820,7 +820,7 @@ class ChannelUpdateEvent extends ChannelEvent { /// The name of the channel property, or null if we don't recognize it. @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) - final ChannelPropertyName? property; + final ChannelProperty? property; /// The new value, or null if we don't recognize the property. /// @@ -849,34 +849,34 @@ class ChannelUpdateEvent extends ChannelEvent { /// (e.g., `value as bool`). static Object? _readValue(Map json, String key) { final value = json['value']; - switch (ChannelPropertyName.fromRawString(json['property'] as String)) { - case ChannelPropertyName.name: + switch (ChannelProperty.fromRawString(json['property'] as String)) { + case ChannelProperty.name: return value as String; - case ChannelPropertyName.isArchived: + case ChannelProperty.isArchived: return value as bool; - case ChannelPropertyName.description: + case ChannelProperty.description: return value as String; - case ChannelPropertyName.firstMessageId: + case ChannelProperty.firstMessageId: return value as int?; - case ChannelPropertyName.inviteOnly: + case ChannelProperty.inviteOnly: return value as bool; - case ChannelPropertyName.messageRetentionDays: + case ChannelProperty.messageRetentionDays: return value as int?; - case ChannelPropertyName.topicsPolicy: + case ChannelProperty.topicsPolicy: return ChannelTopicsPolicy.fromApiValue(value as String); - case ChannelPropertyName.channelPostPolicy: + case ChannelProperty.channelPostPolicy: return ChannelPostPolicy.fromApiValue(value as int); - case ChannelPropertyName.folderId: + case ChannelProperty.folderId: return value as int?; - case ChannelPropertyName.canAddSubscribersGroup: - case ChannelPropertyName.canDeleteAnyMessageGroup: - case ChannelPropertyName.canDeleteOwnMessageGroup: - case ChannelPropertyName.canSendMessageGroup: - case ChannelPropertyName.canSubscribeGroup: + case ChannelProperty.canAddSubscribersGroup: + case ChannelProperty.canDeleteAnyMessageGroup: + case ChannelProperty.canDeleteOwnMessageGroup: + case ChannelProperty.canSendMessageGroup: + case ChannelProperty.canSubscribeGroup: return GroupSettingValue.fromJson(value); - case ChannelPropertyName.isRecentlyActive: + case ChannelProperty.isRecentlyActive: return value as bool; - case ChannelPropertyName.streamWeeklyTraffic: + case ChannelProperty.streamWeeklyTraffic: return value as int?; case null: return null; diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 90fe91e7e..471b9dcde 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -566,7 +566,7 @@ ChannelUpdateEvent _$ChannelUpdateEventFromJson(Map json) => streamId: (json['stream_id'] as num).toInt(), name: json['name'] as String, property: $enumDecodeNullable( - _$ChannelPropertyNameEnumMap, + _$ChannelPropertyEnumMap, json['property'], unknownValue: JsonKey.nullForUndefinedEnumValue, ), @@ -584,30 +584,30 @@ Map _$ChannelUpdateEventToJson(ChannelUpdateEvent instance) => 'op': instance.op, 'stream_id': instance.streamId, 'name': instance.name, - 'property': _$ChannelPropertyNameEnumMap[instance.property], + 'property': _$ChannelPropertyEnumMap[instance.property], 'value': instance.value, 'rendered_description': instance.renderedDescription, 'history_public_to_subscribers': instance.historyPublicToSubscribers, 'is_web_public': instance.isWebPublic, }; -const _$ChannelPropertyNameEnumMap = { - ChannelPropertyName.name: 'name', - ChannelPropertyName.isArchived: 'is_archived', - ChannelPropertyName.description: 'description', - ChannelPropertyName.firstMessageId: 'first_message_id', - ChannelPropertyName.inviteOnly: 'invite_only', - ChannelPropertyName.messageRetentionDays: 'message_retention_days', - ChannelPropertyName.topicsPolicy: 'topics_policy', - ChannelPropertyName.channelPostPolicy: 'stream_post_policy', - ChannelPropertyName.folderId: 'folder_id', - ChannelPropertyName.canAddSubscribersGroup: 'can_add_subscribers_group', - ChannelPropertyName.canDeleteAnyMessageGroup: 'can_delete_any_message_group', - ChannelPropertyName.canDeleteOwnMessageGroup: 'can_delete_own_message_group', - ChannelPropertyName.canSendMessageGroup: 'can_send_message_group', - ChannelPropertyName.canSubscribeGroup: 'can_subscribe_group', - ChannelPropertyName.isRecentlyActive: 'is_recently_active', - ChannelPropertyName.streamWeeklyTraffic: 'stream_weekly_traffic', +const _$ChannelPropertyEnumMap = { + ChannelProperty.name: 'name', + ChannelProperty.isArchived: 'is_archived', + ChannelProperty.description: 'description', + ChannelProperty.firstMessageId: 'first_message_id', + ChannelProperty.inviteOnly: 'invite_only', + ChannelProperty.messageRetentionDays: 'message_retention_days', + ChannelProperty.topicsPolicy: 'topics_policy', + ChannelProperty.channelPostPolicy: 'stream_post_policy', + ChannelProperty.folderId: 'folder_id', + ChannelProperty.canAddSubscribersGroup: 'can_add_subscribers_group', + ChannelProperty.canDeleteAnyMessageGroup: 'can_delete_any_message_group', + ChannelProperty.canDeleteOwnMessageGroup: 'can_delete_own_message_group', + ChannelProperty.canSendMessageGroup: 'can_send_message_group', + ChannelProperty.canSubscribeGroup: 'can_subscribe_group', + ChannelProperty.isRecentlyActive: 'is_recently_active', + ChannelProperty.streamWeeklyTraffic: 'stream_weekly_traffic', }; SubscriptionAddEvent _$SubscriptionAddEventFromJson( diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index f3789ae4f..6b89e1971 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -670,7 +670,7 @@ class SavedSnippet { @JsonSerializable(fieldRename: FieldRename.snake) class ZulipStream { // When adding a field to this class: - // * Add it to [ChannelPropertyName] too, or add a comment there explaining + // * Add it to [ChannelProperty] too, or add a comment there explaining // why there isn't a corresponding value in that enum. // * If the field can never change for a given Zulip stream, mark it final. // Otherwise, make sure it gets updated on [ChannelUpdateEvent]. @@ -784,7 +784,7 @@ class ZulipStream { /// we switch exhaustively on a value of this type /// to ensure that every property in [ZulipStream] responds to the event. @JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true) -enum ChannelPropertyName { +enum ChannelProperty { // streamId is immutable name, isArchived, @@ -808,14 +808,14 @@ enum ChannelPropertyName { isRecentlyActive, streamWeeklyTraffic; - /// Get a [ChannelPropertyName] from a raw, snake-case string we recognize, else null. + /// Get a [ChannelProperty] from a raw, snake-case string we recognize, else null. /// /// Example: - /// 'invite_only' -> ChannelPropertyName.inviteOnly - static ChannelPropertyName? fromRawString(String raw) => _byRawString[raw]; + /// 'invite_only' -> ChannelProperty.inviteOnly + static ChannelProperty? fromRawString(String raw) => _byRawString[raw]; // _$…EnumMap is thanks to `alwaysCreate: true` and `fieldRename: FieldRename.snake` - static final _byRawString = _$ChannelPropertyNameEnumMap + static final _byRawString = _$ChannelPropertyEnumMap .map((key, value) => MapEntry(value, key)); } diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index e563a8d34..dc4454aa7 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -592,23 +592,23 @@ const _$PresenceStatusEnumMap = { PresenceStatus.idle: 'idle', }; -const _$ChannelPropertyNameEnumMap = { - ChannelPropertyName.name: 'name', - ChannelPropertyName.isArchived: 'is_archived', - ChannelPropertyName.description: 'description', - ChannelPropertyName.firstMessageId: 'first_message_id', - ChannelPropertyName.inviteOnly: 'invite_only', - ChannelPropertyName.messageRetentionDays: 'message_retention_days', - ChannelPropertyName.topicsPolicy: 'topics_policy', - ChannelPropertyName.channelPostPolicy: 'stream_post_policy', - ChannelPropertyName.folderId: 'folder_id', - ChannelPropertyName.canAddSubscribersGroup: 'can_add_subscribers_group', - ChannelPropertyName.canDeleteAnyMessageGroup: 'can_delete_any_message_group', - ChannelPropertyName.canDeleteOwnMessageGroup: 'can_delete_own_message_group', - ChannelPropertyName.canSendMessageGroup: 'can_send_message_group', - ChannelPropertyName.canSubscribeGroup: 'can_subscribe_group', - ChannelPropertyName.isRecentlyActive: 'is_recently_active', - ChannelPropertyName.streamWeeklyTraffic: 'stream_weekly_traffic', +const _$ChannelPropertyEnumMap = { + ChannelProperty.name: 'name', + ChannelProperty.isArchived: 'is_archived', + ChannelProperty.description: 'description', + ChannelProperty.firstMessageId: 'first_message_id', + ChannelProperty.inviteOnly: 'invite_only', + ChannelProperty.messageRetentionDays: 'message_retention_days', + ChannelProperty.topicsPolicy: 'topics_policy', + ChannelProperty.channelPostPolicy: 'stream_post_policy', + ChannelProperty.folderId: 'folder_id', + ChannelProperty.canAddSubscribersGroup: 'can_add_subscribers_group', + ChannelProperty.canDeleteAnyMessageGroup: 'can_delete_any_message_group', + ChannelProperty.canDeleteOwnMessageGroup: 'can_delete_own_message_group', + ChannelProperty.canSendMessageGroup: 'can_send_message_group', + ChannelProperty.canSubscribeGroup: 'can_subscribe_group', + ChannelProperty.isRecentlyActive: 'is_recently_active', + ChannelProperty.streamWeeklyTraffic: 'stream_weekly_traffic', }; const _$SubscriptionPropertyEnumMap = { diff --git a/lib/model/channel.dart b/lib/model/channel.dart index abe4a8426..c6d12597f 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -547,42 +547,42 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore { return; } switch (event.property!) { - case ChannelPropertyName.name: + case ChannelProperty.name: final streamName = stream.name; assert(streamName == event.name); assert(identical(streams[stream.streamId], streamsByName[streamName])); stream.name = event.value as String; streamsByName.remove(streamName); streamsByName[stream.name] = stream; - case ChannelPropertyName.isArchived: + case ChannelProperty.isArchived: stream.isArchived = event.value as bool; - case ChannelPropertyName.description: + case ChannelProperty.description: stream.description = event.value as String; - case ChannelPropertyName.firstMessageId: + case ChannelProperty.firstMessageId: stream.firstMessageId = event.value as int?; - case ChannelPropertyName.inviteOnly: + case ChannelProperty.inviteOnly: stream.inviteOnly = event.value as bool; - case ChannelPropertyName.messageRetentionDays: + case ChannelProperty.messageRetentionDays: stream.messageRetentionDays = event.value as int?; - case ChannelPropertyName.topicsPolicy: + case ChannelProperty.topicsPolicy: stream.topicsPolicy = event.value as ChannelTopicsPolicy; - case ChannelPropertyName.channelPostPolicy: + case ChannelProperty.channelPostPolicy: stream.channelPostPolicy = event.value as ChannelPostPolicy; - case ChannelPropertyName.folderId: + case ChannelProperty.folderId: stream.folderId = event.value as int?; - case ChannelPropertyName.canAddSubscribersGroup: + case ChannelProperty.canAddSubscribersGroup: stream.canAddSubscribersGroup = event.value as GroupSettingValue; - case ChannelPropertyName.canDeleteAnyMessageGroup: + case ChannelProperty.canDeleteAnyMessageGroup: stream.canDeleteAnyMessageGroup = event.value as GroupSettingValue; - case ChannelPropertyName.canDeleteOwnMessageGroup: + case ChannelProperty.canDeleteOwnMessageGroup: stream.canDeleteOwnMessageGroup = event.value as GroupSettingValue; - case ChannelPropertyName.canSendMessageGroup: + case ChannelProperty.canSendMessageGroup: stream.canSendMessageGroup = event.value as GroupSettingValue; - case ChannelPropertyName.canSubscribeGroup: + case ChannelProperty.canSubscribeGroup: stream.canSubscribeGroup = event.value as GroupSettingValue; - case ChannelPropertyName.isRecentlyActive: + case ChannelProperty.isRecentlyActive: stream.isRecentlyActive = event.value as bool; - case ChannelPropertyName.streamWeeklyTraffic: + case ChannelProperty.streamWeeklyTraffic: stream.streamWeeklyTraffic = event.value as int?; } } diff --git a/test/example_data.dart b/test/example_data.dart index f63900cc4..8df6123a1 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1320,37 +1320,37 @@ ReactionEvent reactionEvent(Reaction reaction, ReactionOp op, int messageId) { ChannelUpdateEvent channelUpdateEvent( ZulipStream stream, { - required ChannelPropertyName property, + required ChannelProperty property, required Object? value, }) { switch (property) { - case ChannelPropertyName.name: + case ChannelProperty.name: assert(value is String); - case ChannelPropertyName.isArchived: + case ChannelProperty.isArchived: assert(value is bool); - case ChannelPropertyName.description: + case ChannelProperty.description: assert(value is String); - case ChannelPropertyName.firstMessageId: + case ChannelProperty.firstMessageId: assert(value is int?); - case ChannelPropertyName.inviteOnly: + case ChannelProperty.inviteOnly: assert(value is bool); - case ChannelPropertyName.messageRetentionDays: + case ChannelProperty.messageRetentionDays: assert(value is int?); - case ChannelPropertyName.topicsPolicy: + case ChannelProperty.topicsPolicy: assert(value is ChannelTopicsPolicy); - case ChannelPropertyName.channelPostPolicy: + case ChannelProperty.channelPostPolicy: assert(value is ChannelPostPolicy); - case ChannelPropertyName.folderId: + case ChannelProperty.folderId: assert(value is int?); - case ChannelPropertyName.canAddSubscribersGroup: - case ChannelPropertyName.canDeleteAnyMessageGroup: - case ChannelPropertyName.canDeleteOwnMessageGroup: - case ChannelPropertyName.canSendMessageGroup: - case ChannelPropertyName.canSubscribeGroup: + case ChannelProperty.canAddSubscribersGroup: + case ChannelProperty.canDeleteAnyMessageGroup: + case ChannelProperty.canDeleteOwnMessageGroup: + case ChannelProperty.canSendMessageGroup: + case ChannelProperty.canSubscribeGroup: assert(value is GroupSettingValue); - case ChannelPropertyName.isRecentlyActive: + case ChannelProperty.isRecentlyActive: assert(value is bool); - case ChannelPropertyName.streamWeeklyTraffic: + case ChannelProperty.streamWeeklyTraffic: assert(value is int?); } return ChannelUpdateEvent( diff --git a/test/model/channel_test.dart b/test/model/channel_test.dart index 6a671e311..3a598ba68 100644 --- a/test/model/channel_test.dart +++ b/test/model/channel_test.dart @@ -63,12 +63,12 @@ void main() { checkUnified(store); await store.handleEvent(eg.channelUpdateEvent(store.streams[stream1.streamId]!, - property: ChannelPropertyName.name, value: 'new stream', + property: ChannelProperty.name, value: 'new stream', )); checkUnified(store); await store.handleEvent(eg.channelUpdateEvent(store.streams[stream1.streamId]!, - property: ChannelPropertyName.channelPostPolicy, + property: ChannelProperty.channelPostPolicy, value: ChannelPostPolicy.administrators, )); checkUnified(store); diff --git a/test/model/test_store.dart b/test/model/test_store.dart index ef75659c0..47dc93b44 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -366,7 +366,7 @@ extension PerAccountStoreTestExtension on PerAccountStore { Future updateChannel( int channelId, - ChannelPropertyName property, + ChannelProperty property, Object? value, ) async { await handleEvent(ChannelUpdateEvent( diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index f1439137b..ffdb5e359 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -349,7 +349,7 @@ void main() { testWidgets('private channel', (tester) async { await prepare(); await store.handleEvent(eg.channelUpdateEvent(someChannel, - property: ChannelPropertyName.inviteOnly, value: true)); + property: ChannelProperty.inviteOnly, value: true)); check(store.streams[someChannel.streamId]).isNotNull() ..inviteOnly.isTrue()..isWebPublic.isFalse(); await showFromInbox(tester); @@ -433,9 +433,9 @@ void main() { await prepare(); await store.addStream(privateChannel); await store.updateChannel(privateChannel.streamId, - ChannelPropertyName.canSubscribeGroup, eg.groupSetting(members: [])); + ChannelProperty.canSubscribeGroup, eg.groupSetting(members: [])); await store.updateChannel(privateChannel.streamId, - ChannelPropertyName.canAddSubscribersGroup, eg.groupSetting(members: [])); + ChannelProperty.canAddSubscribersGroup, eg.groupSetting(members: [])); final narrow = ChannelNarrow(privateChannel.streamId); check(store.selfHasContentAccess(privateChannel)).isFalse(); await showFromMsglistAppBar(tester, @@ -2168,7 +2168,7 @@ void main() { await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: selfUser.userId, role: .guest)); await store.handleEvent(eg.channelUpdateEvent(stream, - property: ChannelPropertyName.channelPostPolicy, + property: ChannelProperty.channelPostPolicy, value: ChannelPostPolicy.administrators)); await tester.pump(); diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index e4033eb24..6971efc04 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -759,7 +759,7 @@ void main() { Future changePolicy(ChannelTopicsPolicy value) async { await store.handleEvent(eg.channelUpdateEvent(store.streams[channel.streamId]!, - property: ChannelPropertyName.topicsPolicy, value: value)); + property: ChannelProperty.topicsPolicy, value: value)); await tester.pump(); } @@ -789,7 +789,7 @@ void main() { .topic.text.equals('some topic'); await store.handleEvent(eg.channelUpdateEvent(store.streams[channel.streamId]!, - property: ChannelPropertyName.topicsPolicy, + property: ChannelProperty.topicsPolicy, value: ChannelTopicsPolicy.emptyTopicOnly)); await tester.pump(Duration.zero); check(state).controller.isA() @@ -1881,7 +1881,7 @@ void main() { checkComposeBox(isShown: true); await store.handleEvent(eg.channelUpdateEvent(channel, - property: ChannelPropertyName.channelPostPolicy, + property: ChannelProperty.channelPostPolicy, value: ChannelPostPolicy.fullMembers)); await tester.pump(); checkComposeBox(isShown: false); @@ -1899,7 +1899,7 @@ void main() { checkComposeBox(isShown: false); await store.handleEvent(eg.channelUpdateEvent(channel, - property: ChannelPropertyName.channelPostPolicy, + property: ChannelProperty.channelPostPolicy, value: ChannelPostPolicy.moderators)); await tester.pump(); checkComposeBox(isShown: true); @@ -2062,7 +2062,7 @@ void main() { .topic.text.equals('some topic'); await newStore.handleEvent(eg.channelUpdateEvent(newStore.streams[channel.streamId]!, - property: ChannelPropertyName.topicsPolicy, + property: ChannelProperty.topicsPolicy, value: ChannelTopicsPolicy.emptyTopicOnly)); await tester.pump(Duration.zero); check(state).controller.isA() @@ -2238,7 +2238,7 @@ void main() { ..content.text.isNotNull().isEmpty(); await store.handleEvent(eg.channelUpdateEvent(store.streams[channel.streamId]!, - property: ChannelPropertyName.topicsPolicy, + property: ChannelProperty.topicsPolicy, value: ChannelTopicsPolicy.emptyTopicOnly)); await tester.pump(Duration.zero); From 93518407a854f47b22cec169aa5363940652be6c Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 11 Jun 2026 09:40:42 +0430 Subject: [PATCH 28/29] dart [nfc]: Use dot shorthands for ChannelProperty --- lib/api/model/events.dart | 32 ++++++++++++++--------------- lib/model/channel.dart | 32 ++++++++++++++--------------- test/example_data.dart | 32 ++++++++++++++--------------- test/model/channel_test.dart | 4 ++-- test/widgets/action_sheet_test.dart | 8 ++++---- test/widgets/compose_box_test.dart | 12 +++++------ 6 files changed, 60 insertions(+), 60 deletions(-) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index d77268890..8f62209e5 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -850,33 +850,33 @@ class ChannelUpdateEvent extends ChannelEvent { static Object? _readValue(Map json, String key) { final value = json['value']; switch (ChannelProperty.fromRawString(json['property'] as String)) { - case ChannelProperty.name: + case .name: return value as String; - case ChannelProperty.isArchived: + case .isArchived: return value as bool; - case ChannelProperty.description: + case .description: return value as String; - case ChannelProperty.firstMessageId: + case .firstMessageId: return value as int?; - case ChannelProperty.inviteOnly: + case .inviteOnly: return value as bool; - case ChannelProperty.messageRetentionDays: + case .messageRetentionDays: return value as int?; - case ChannelProperty.topicsPolicy: + case .topicsPolicy: return ChannelTopicsPolicy.fromApiValue(value as String); - case ChannelProperty.channelPostPolicy: + case .channelPostPolicy: return ChannelPostPolicy.fromApiValue(value as int); - case ChannelProperty.folderId: + case .folderId: return value as int?; - case ChannelProperty.canAddSubscribersGroup: - case ChannelProperty.canDeleteAnyMessageGroup: - case ChannelProperty.canDeleteOwnMessageGroup: - case ChannelProperty.canSendMessageGroup: - case ChannelProperty.canSubscribeGroup: + case .canAddSubscribersGroup: + case .canDeleteAnyMessageGroup: + case .canDeleteOwnMessageGroup: + case .canSendMessageGroup: + case .canSubscribeGroup: return GroupSettingValue.fromJson(value); - case ChannelProperty.isRecentlyActive: + case .isRecentlyActive: return value as bool; - case ChannelProperty.streamWeeklyTraffic: + case .streamWeeklyTraffic: return value as int?; case null: return null; diff --git a/lib/model/channel.dart b/lib/model/channel.dart index c6d12597f..9d857ad7b 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -547,42 +547,42 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore { return; } switch (event.property!) { - case ChannelProperty.name: + case .name: final streamName = stream.name; assert(streamName == event.name); assert(identical(streams[stream.streamId], streamsByName[streamName])); stream.name = event.value as String; streamsByName.remove(streamName); streamsByName[stream.name] = stream; - case ChannelProperty.isArchived: + case .isArchived: stream.isArchived = event.value as bool; - case ChannelProperty.description: + case .description: stream.description = event.value as String; - case ChannelProperty.firstMessageId: + case .firstMessageId: stream.firstMessageId = event.value as int?; - case ChannelProperty.inviteOnly: + case .inviteOnly: stream.inviteOnly = event.value as bool; - case ChannelProperty.messageRetentionDays: + case .messageRetentionDays: stream.messageRetentionDays = event.value as int?; - case ChannelProperty.topicsPolicy: + case .topicsPolicy: stream.topicsPolicy = event.value as ChannelTopicsPolicy; - case ChannelProperty.channelPostPolicy: + case .channelPostPolicy: stream.channelPostPolicy = event.value as ChannelPostPolicy; - case ChannelProperty.folderId: + case .folderId: stream.folderId = event.value as int?; - case ChannelProperty.canAddSubscribersGroup: + case .canAddSubscribersGroup: stream.canAddSubscribersGroup = event.value as GroupSettingValue; - case ChannelProperty.canDeleteAnyMessageGroup: + case .canDeleteAnyMessageGroup: stream.canDeleteAnyMessageGroup = event.value as GroupSettingValue; - case ChannelProperty.canDeleteOwnMessageGroup: + case .canDeleteOwnMessageGroup: stream.canDeleteOwnMessageGroup = event.value as GroupSettingValue; - case ChannelProperty.canSendMessageGroup: + case .canSendMessageGroup: stream.canSendMessageGroup = event.value as GroupSettingValue; - case ChannelProperty.canSubscribeGroup: + case .canSubscribeGroup: stream.canSubscribeGroup = event.value as GroupSettingValue; - case ChannelProperty.isRecentlyActive: + case .isRecentlyActive: stream.isRecentlyActive = event.value as bool; - case ChannelProperty.streamWeeklyTraffic: + case .streamWeeklyTraffic: stream.streamWeeklyTraffic = event.value as int?; } } diff --git a/test/example_data.dart b/test/example_data.dart index 8df6123a1..9db49bcb5 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1324,33 +1324,33 @@ ChannelUpdateEvent channelUpdateEvent( required Object? value, }) { switch (property) { - case ChannelProperty.name: + case .name: assert(value is String); - case ChannelProperty.isArchived: + case .isArchived: assert(value is bool); - case ChannelProperty.description: + case .description: assert(value is String); - case ChannelProperty.firstMessageId: + case .firstMessageId: assert(value is int?); - case ChannelProperty.inviteOnly: + case .inviteOnly: assert(value is bool); - case ChannelProperty.messageRetentionDays: + case .messageRetentionDays: assert(value is int?); - case ChannelProperty.topicsPolicy: + case .topicsPolicy: assert(value is ChannelTopicsPolicy); - case ChannelProperty.channelPostPolicy: + case .channelPostPolicy: assert(value is ChannelPostPolicy); - case ChannelProperty.folderId: + case .folderId: assert(value is int?); - case ChannelProperty.canAddSubscribersGroup: - case ChannelProperty.canDeleteAnyMessageGroup: - case ChannelProperty.canDeleteOwnMessageGroup: - case ChannelProperty.canSendMessageGroup: - case ChannelProperty.canSubscribeGroup: + case .canAddSubscribersGroup: + case .canDeleteAnyMessageGroup: + case .canDeleteOwnMessageGroup: + case .canSendMessageGroup: + case .canSubscribeGroup: assert(value is GroupSettingValue); - case ChannelProperty.isRecentlyActive: + case .isRecentlyActive: assert(value is bool); - case ChannelProperty.streamWeeklyTraffic: + case .streamWeeklyTraffic: assert(value is int?); } return ChannelUpdateEvent( diff --git a/test/model/channel_test.dart b/test/model/channel_test.dart index 3a598ba68..e99a9017a 100644 --- a/test/model/channel_test.dart +++ b/test/model/channel_test.dart @@ -63,12 +63,12 @@ void main() { checkUnified(store); await store.handleEvent(eg.channelUpdateEvent(store.streams[stream1.streamId]!, - property: ChannelProperty.name, value: 'new stream', + property: .name, value: 'new stream', )); checkUnified(store); await store.handleEvent(eg.channelUpdateEvent(store.streams[stream1.streamId]!, - property: ChannelProperty.channelPostPolicy, + property: .channelPostPolicy, value: ChannelPostPolicy.administrators, )); checkUnified(store); diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index ffdb5e359..2a6637810 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -349,7 +349,7 @@ void main() { testWidgets('private channel', (tester) async { await prepare(); await store.handleEvent(eg.channelUpdateEvent(someChannel, - property: ChannelProperty.inviteOnly, value: true)); + property: .inviteOnly, value: true)); check(store.streams[someChannel.streamId]).isNotNull() ..inviteOnly.isTrue()..isWebPublic.isFalse(); await showFromInbox(tester); @@ -433,9 +433,9 @@ void main() { await prepare(); await store.addStream(privateChannel); await store.updateChannel(privateChannel.streamId, - ChannelProperty.canSubscribeGroup, eg.groupSetting(members: [])); + .canSubscribeGroup, eg.groupSetting(members: [])); await store.updateChannel(privateChannel.streamId, - ChannelProperty.canAddSubscribersGroup, eg.groupSetting(members: [])); + .canAddSubscribersGroup, eg.groupSetting(members: [])); final narrow = ChannelNarrow(privateChannel.streamId); check(store.selfHasContentAccess(privateChannel)).isFalse(); await showFromMsglistAppBar(tester, @@ -2168,7 +2168,7 @@ void main() { await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: selfUser.userId, role: .guest)); await store.handleEvent(eg.channelUpdateEvent(stream, - property: ChannelProperty.channelPostPolicy, + property: .channelPostPolicy, value: ChannelPostPolicy.administrators)); await tester.pump(); diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 6971efc04..d47eb555e 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -759,7 +759,7 @@ void main() { Future changePolicy(ChannelTopicsPolicy value) async { await store.handleEvent(eg.channelUpdateEvent(store.streams[channel.streamId]!, - property: ChannelProperty.topicsPolicy, value: value)); + property: .topicsPolicy, value: value)); await tester.pump(); } @@ -789,7 +789,7 @@ void main() { .topic.text.equals('some topic'); await store.handleEvent(eg.channelUpdateEvent(store.streams[channel.streamId]!, - property: ChannelProperty.topicsPolicy, + property: .topicsPolicy, value: ChannelTopicsPolicy.emptyTopicOnly)); await tester.pump(Duration.zero); check(state).controller.isA() @@ -1881,7 +1881,7 @@ void main() { checkComposeBox(isShown: true); await store.handleEvent(eg.channelUpdateEvent(channel, - property: ChannelProperty.channelPostPolicy, + property: .channelPostPolicy, value: ChannelPostPolicy.fullMembers)); await tester.pump(); checkComposeBox(isShown: false); @@ -1899,7 +1899,7 @@ void main() { checkComposeBox(isShown: false); await store.handleEvent(eg.channelUpdateEvent(channel, - property: ChannelProperty.channelPostPolicy, + property: .channelPostPolicy, value: ChannelPostPolicy.moderators)); await tester.pump(); checkComposeBox(isShown: true); @@ -2062,7 +2062,7 @@ void main() { .topic.text.equals('some topic'); await newStore.handleEvent(eg.channelUpdateEvent(newStore.streams[channel.streamId]!, - property: ChannelProperty.topicsPolicy, + property: .topicsPolicy, value: ChannelTopicsPolicy.emptyTopicOnly)); await tester.pump(Duration.zero); check(state).controller.isA() @@ -2238,7 +2238,7 @@ void main() { ..content.text.isNotNull().isEmpty(); await store.handleEvent(eg.channelUpdateEvent(store.streams[channel.streamId]!, - property: ChannelProperty.topicsPolicy, + property: .topicsPolicy, value: ChannelTopicsPolicy.emptyTopicOnly)); await tester.pump(Duration.zero); From d02b70b1b137b8b7dd443f7813b44fbdcd5f1364 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 11 Jun 2026 11:14:44 +0430 Subject: [PATCH 29/29] api: Add ChannelProperty.unknown We were already handling unrecognized property names in ChannelUpdateEvent by making the corresponding field nullable. To follow our usual pattern for handling unknown values from the Zulip server API, added an "unknown" value to ChannelProperty and made the field non-nullable. --- lib/api/model/events.dart | 11 ++++++----- lib/api/model/events.g.dart | 7 ++++--- lib/api/model/model.dart | 14 +++++++++++--- lib/api/model/model.g.dart | 1 + lib/model/channel.dart | 7 +++++-- test/api/model/events_checks.dart | 5 +++++ test/api/model/events_test.dart | 22 ++++++++++++++++++++++ test/api/model/model_test.dart | 8 ++++++++ test/example_data.dart | 2 ++ test/widgets/action_sheet_test.dart | 2 +- 10 files changed, 65 insertions(+), 14 deletions(-) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 8f62209e5..b1789e3ce 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -818,11 +818,12 @@ class ChannelUpdateEvent extends ChannelEvent { final int streamId; final String name; - /// The name of the channel property, or null if we don't recognize it. - @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) - final ChannelProperty? property; + /// The name of the channel property, + /// or [ChannelProperty.unknown] if we don't recognize it. + @JsonKey(unknownEnumValue: ChannelProperty.unknown) + final ChannelProperty property; - /// The new value, or null if we don't recognize the property. + /// The new value, or null if we don't recognize the property ([ChannelProperty.unknown]). /// /// This will have the type appropriate for [property]; for example, /// if the property is boolean, then `value is bool` will always be true. @@ -878,7 +879,7 @@ class ChannelUpdateEvent extends ChannelEvent { return value as bool; case .streamWeeklyTraffic: return value as int?; - case null: + case .unknown: return null; } } diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 471b9dcde..08b5b36ab 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -565,10 +565,10 @@ ChannelUpdateEvent _$ChannelUpdateEventFromJson(Map json) => id: (json['id'] as num).toInt(), streamId: (json['stream_id'] as num).toInt(), name: json['name'] as String, - property: $enumDecodeNullable( + property: $enumDecode( _$ChannelPropertyEnumMap, json['property'], - unknownValue: JsonKey.nullForUndefinedEnumValue, + unknownValue: ChannelProperty.unknown, ), value: ChannelUpdateEvent._readValue(json, 'value'), renderedDescription: json['rendered_description'] as String?, @@ -584,7 +584,7 @@ Map _$ChannelUpdateEventToJson(ChannelUpdateEvent instance) => 'op': instance.op, 'stream_id': instance.streamId, 'name': instance.name, - 'property': _$ChannelPropertyEnumMap[instance.property], + 'property': _$ChannelPropertyEnumMap[instance.property]!, 'value': instance.value, 'rendered_description': instance.renderedDescription, 'history_public_to_subscribers': instance.historyPublicToSubscribers, @@ -608,6 +608,7 @@ const _$ChannelPropertyEnumMap = { ChannelProperty.canSubscribeGroup: 'can_subscribe_group', ChannelProperty.isRecentlyActive: 'is_recently_active', ChannelProperty.streamWeeklyTraffic: 'stream_weekly_traffic', + ChannelProperty.unknown: 'unknown', }; SubscriptionAddEvent _$SubscriptionAddEventFromJson( diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 6b89e1971..365ad42dd 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -806,13 +806,21 @@ enum ChannelProperty { canSendMessageGroup, canSubscribeGroup, isRecentlyActive, - streamWeeklyTraffic; + streamWeeklyTraffic, + // TODO: add more as needed + + /// A channel property name that: + /// - The server sends, but we currently don't support. + /// - The server may start sending in the future. + unknown, + ; - /// Get a [ChannelProperty] from a raw, snake-case string we recognize, else null. + /// Get a [ChannelProperty] from a raw, snake-case string we recognize, + /// else [ChannelProperty.unknown]. /// /// Example: /// 'invite_only' -> ChannelProperty.inviteOnly - static ChannelProperty? fromRawString(String raw) => _byRawString[raw]; + static ChannelProperty fromRawString(String raw) => _byRawString[raw] ?? unknown; // _$…EnumMap is thanks to `alwaysCreate: true` and `fieldRename: FieldRename.snake` static final _byRawString = _$ChannelPropertyEnumMap diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index dc4454aa7..bf015ca40 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -609,6 +609,7 @@ const _$ChannelPropertyEnumMap = { ChannelProperty.canSubscribeGroup: 'can_subscribe_group', ChannelProperty.isRecentlyActive: 'is_recently_active', ChannelProperty.streamWeeklyTraffic: 'stream_weekly_traffic', + ChannelProperty.unknown: 'unknown', }; const _$SubscriptionPropertyEnumMap = { diff --git a/lib/model/channel.dart b/lib/model/channel.dart index 9d857ad7b..bd40ba643 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -542,11 +542,11 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore { stream.isWebPublic = event.isWebPublic!; } - if (event.property == null) { + if (event.property == .unknown) { // unrecognized property; do nothing return; } - switch (event.property!) { + switch (event.property) { case .name: final streamName = stream.name; assert(streamName == event.name); @@ -584,6 +584,9 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore { stream.isRecentlyActive = event.value as bool; case .streamWeeklyTraffic: stream.streamWeeklyTraffic = event.value as int?; + case .unknown: + // Shouldn't reach here because of the early return. + assert(false); } } } diff --git a/test/api/model/events_checks.dart b/test/api/model/events_checks.dart index 4bbe97c3d..64cbb614e 100644 --- a/test/api/model/events_checks.dart +++ b/test/api/model/events_checks.dart @@ -41,6 +41,11 @@ extension RealmUserUpdateEventChecks on Subject { Subject?> get deliveryEmail => has((e) => e.deliveryEmail, 'deliveryEmail'); } +extension ChannelUpdateEventChecks on Subject { + Subject get property => has((e) => e.property, 'property'); + Subject get value => has((e) => e.value, 'value'); +} + extension SubscriptionRemoveEventChecks on Subject { Subject> get channelIds => has((e) => e.channelIds, 'channelIds'); } diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart index d59351b13..518ea4592 100644 --- a/test/api/model/events_test.dart +++ b/test/api/model/events_test.dart @@ -106,6 +106,28 @@ void main() { }); }); + test('stream/update: unknown property', () { + final json = Map.unmodifiable({ + 'id': 1, + 'type': 'stream', + 'op': 'update', + 'stream_id': 1, + 'name': 'channel name', + 'property': 'is_recently_active', + 'value': true, + }); + + check(ChannelUpdateEvent.fromJson(json)) + ..property.equals(.isRecentlyActive) + ..value.equals(true); + + for (final unknown in ['unknown_channel_property', '']) { + check(ChannelUpdateEvent.fromJson({...json, 'property': unknown})) + ..property.equals(.unknown) + ..value.equals(null); + } + }); + test('subscription/remove: deserialize stream_ids correctly', () { check(Event.fromJson({ 'id': 1, diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index e666c0fbc..2eed4d251 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -141,6 +141,14 @@ void main() { }); }); + test('ChannelProperty.fromRawString handles unknown values', () { + check(ChannelProperty.fromRawString('is_recently_active')).equals(.isRecentlyActive); + + for (final unknown in ['unknown_channel_property', '']) { + check(ChannelProperty.fromRawString(unknown)).equals(.unknown); + } + }); + group('Subscription', () { test('converts color to int', () { Subscription subWithColor(String color) { diff --git a/test/example_data.dart b/test/example_data.dart index 9db49bcb5..ba9a72140 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1352,6 +1352,8 @@ ChannelUpdateEvent channelUpdateEvent( assert(value is bool); case .streamWeeklyTraffic: assert(value is int?); + case .unknown: + assert(value == null); } return ChannelUpdateEvent( id: 1, diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 2a6637810..fe3bb1c51 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -334,7 +334,7 @@ void main() { await store.handleEvent(ChannelUpdateEvent(id: 1, streamId: someChannel.streamId, name: someChannel.name, - property: null, value: null, + property: .unknown, value: null, // (Ideally we'd use `property` and `value` but I'm not sure if // modern servers actually do that or if they still use this // separate field.)