11import 'package:json_annotation/json_annotation.dart' ;
22
3+ import '../../model/algorithms.dart' ;
34import 'events.dart' ;
45import 'initial_snapshot.dart' ;
56import 'reaction.dart' ;
@@ -531,6 +532,111 @@ String? tryParseEmojiCodeToUnicode(String emojiCode) {
531532 }
532533}
533534
535+ /// The name of a Zulip topic.
536+ // TODO(dart): Can we forbid calling Object members on this extension type?
537+ // (The lack of "implements Object" ought to do that, but doesn't.)
538+ // In particular an interpolation "foo > $topic" is a bug we'd like to catch.
539+ // TODO(dart): Can we forbid using this extension type as a key in a Map?
540+ // (The lack of "implements Object" arguably should do that, but doesn't.)
541+ // Using as a Map key is almost certainly a bug because it won't case-fold;
542+ // see for example #739, #980, #1205.
543+ extension type const TopicName (String _value) {
544+ /// The canonical form of the resolved-topic prefix.
545+ // This is RESOLVED_TOPIC_PREFIX in web:
546+ // https://github.com/zulip/zulip/blob/1fac99733/web/shared/src/resolved_topic.ts
547+ static const resolvedTopicPrefix = '✔ ' ;
548+
549+ /// Pattern for an arbitrary resolved-topic prefix.
550+ ///
551+ /// These always begin with [resolvedTopicPrefix]
552+ /// but can be weird and go on longer, like "✔ ✔✔ ".
553+ // This is RESOLVED_TOPIC_PREFIX_RE in web:
554+ // https://github.com/zulip/zulip/blob/1fac99733/web/shared/src/resolved_topic.ts#L4-L12
555+ static final resolvedTopicPrefixRegexp = RegExp (r'^✔ [ ✔]*' );
556+
557+ /// The string this topic is identified by in the Zulip API.
558+ ///
559+ /// This should be used in constructing HTTP requests to the server,
560+ /// but rarely for other purposes. See [displayName] and [canonicalize] .
561+ String get apiName => _value;
562+
563+ /// The string this topic is displayed as to the user in our UI.
564+ ///
565+ /// At the moment this always equals [apiName] .
566+ /// In the future this will become null for the "general chat" topic (#1250),
567+ /// so that UI code can identify when it needs to represent the topic
568+ /// specially in the way prescribed for "general chat".
569+ // TODO(#1250) carry out that plan
570+ String get displayName => _value;
571+
572+ /// The key to use for "same topic as" comparisons.
573+ String canonicalize () => apiName.toLowerCase ();
574+
575+ /// Whether the topic starts with [resolvedTopicPrefix] .
576+ bool get isResolved => _value.startsWith (resolvedTopicPrefix);
577+
578+ /// This [TopicName] plus the [resolvedTopicPrefix] prefix.
579+ TopicName resolve () => TopicName (resolvedTopicPrefix + _value);
580+
581+ /// A [TopicName] with [resolvedTopicPrefixRegexp] stripped if present.
582+ TopicName unresolve () =>
583+ TopicName (_value.replaceFirst (resolvedTopicPrefixRegexp, '' ));
584+
585+ /// Whether [this] and [other] have the same canonical form,
586+ /// using [canonicalize] .
587+ bool isSameAs (TopicName other) => canonicalize () == other.canonicalize ();
588+
589+ TopicName .fromJson (this ._value);
590+
591+ String toJson () => apiName;
592+ }
593+
594+ /// As in [StreamMessage.conversation] and [DmMessage.conversation] .
595+ ///
596+ /// Different from [MessageDestination] , this information comes from
597+ /// [getMessages] or [getEvents] , identifying the conversation that contains a
598+ /// message.
599+ sealed class Conversation {}
600+
601+ /// The conversation a stream message is in.
602+ @JsonSerializable (fieldRename: FieldRename .snake, createToJson: false )
603+ class StreamConversation extends Conversation {
604+ StreamConversation (this .streamId, this .topic);
605+
606+ /// The name of the stream, found on stream message objects from the server.
607+ ///
608+ /// This is not updated when its name changes. Consider using [streamId]
609+ /// instead to lookup stream name from the store.
610+ @JsonKey (
611+ // Make sure that this isn't nullable API-wise. If a message moves across
612+ // channels, [displayRecipient] can still refer to the original channel
613+ // and has to be invalidated.
614+ required : true , disallowNullValue: true
615+ )
616+ String ? displayRecipient;
617+
618+ int streamId;
619+
620+ @JsonKey (name: 'subject' )
621+ TopicName topic;
622+
623+ factory StreamConversation .fromJson (Map <String , dynamic > json) =>
624+ _$StreamConversationFromJson (json);
625+ }
626+
627+ /// The conversation a DM message is in.
628+ class DmConversation extends Conversation {
629+ DmConversation ({required this .allRecipientIds})
630+ : assert (isSortedWithoutDuplicates (allRecipientIds.toList ()));
631+
632+ /// The user IDs of all users in the conversation, sorted numerically.
633+ ///
634+ /// This lists the sender as well as all (other) recipients, and it
635+ /// lists each user just once. In particular the self-user is always
636+ /// included.
637+ final List <int > allRecipientIds;
638+ }
639+
534640/// As in the get-messages response.
535641///
536642/// https://zulip.com/api/get-messages#response
@@ -655,85 +761,31 @@ enum MessageFlag {
655761 String toJson () => _$MessageFlagEnumMap [this ]! ;
656762}
657763
658- /// The name of a Zulip topic.
659- // TODO(dart): Can we forbid calling Object members on this extension type?
660- // (The lack of "implements Object" ought to do that, but doesn't.)
661- // In particular an interpolation "foo > $topic" is a bug we'd like to catch.
662- // TODO(dart): Can we forbid using this extension type as a key in a Map?
663- // (The lack of "implements Object" arguably should do that, but doesn't.)
664- // Using as a Map key is almost certainly a bug because it won't case-fold;
665- // see for example #739, #980, #1205.
666- extension type const TopicName (String _value) {
667- /// The canonical form of the resolved-topic prefix.
668- // This is RESOLVED_TOPIC_PREFIX in web:
669- // https://github.com/zulip/zulip/blob/1fac99733/web/shared/src/resolved_topic.ts
670- static const resolvedTopicPrefix = '✔ ' ;
671-
672- /// Pattern for an arbitrary resolved-topic prefix.
673- ///
674- /// These always begin with [resolvedTopicPrefix]
675- /// but can be weird and go on longer, like "✔ ✔✔ ".
676- // This is RESOLVED_TOPIC_PREFIX_RE in web:
677- // https://github.com/zulip/zulip/blob/1fac99733/web/shared/src/resolved_topic.ts#L4-L12
678- static final resolvedTopicPrefixRegexp = RegExp (r'^✔ [ ✔]*' );
679-
680- /// The string this topic is identified by in the Zulip API.
681- ///
682- /// This should be used in constructing HTTP requests to the server,
683- /// but rarely for other purposes. See [displayName] and [canonicalize] .
684- String get apiName => _value;
685-
686- /// The string this topic is displayed as to the user in our UI.
687- ///
688- /// At the moment this always equals [apiName] .
689- /// In the future this will become null for the "general chat" topic (#1250),
690- /// so that UI code can identify when it needs to represent the topic
691- /// specially in the way prescribed for "general chat".
692- // TODO(#1250) carry out that plan
693- String get displayName => _value;
694-
695- /// The key to use for "same topic as" comparisons.
696- String canonicalize () => apiName.toLowerCase ();
697-
698- /// Whether the topic starts with [resolvedTopicPrefix] .
699- bool get isResolved => _value.startsWith (resolvedTopicPrefix);
700-
701- /// This [TopicName] plus the [resolvedTopicPrefix] prefix.
702- TopicName resolve () => TopicName (resolvedTopicPrefix + _value);
703-
704- /// A [TopicName] with [resolvedTopicPrefixRegexp] stripped if present.
705- TopicName unresolve () =>
706- TopicName (_value.replaceFirst (resolvedTopicPrefixRegexp, '' ));
707-
708- /// Whether [this] and [other] have the same canonical form,
709- /// using [canonicalize] .
710- bool isSameAs (TopicName other) => canonicalize () == other.canonicalize ();
711-
712- TopicName .fromJson (this ._value);
713-
714- String toJson () => apiName;
715- }
716-
717764@JsonSerializable (fieldRename: FieldRename .snake)
718765class StreamMessage extends Message {
719766 @override
720767 @JsonKey (includeToJson: true )
721768 String get type => 'stream' ;
722769
723- // This is not nullable API-wise, but if the message moves across channels,
724- // [displayRecipient] still refers to the original channel and it has to be
725- // invalidated.
726- @JsonKey (required : true , disallowNullValue: true )
727- String ? displayRecipient;
770+ @JsonKey (includeToJson: true )
771+ String ? get displayRecipient => conversation.displayRecipient;
728772
729- int streamId;
773+ @JsonKey (includeToJson: true )
774+ int get streamId => conversation.streamId;
730775
731776 // The topic/subject is documented to be present on DMs too, just empty.
732777 // We ignore it on DMs; if a future server introduces distinct topics in DMs,
733778 // that will need new UI that we'll design then as part of that feature,
734779 // and ignoring the topics seems as good a fallback behavior as any.
735- @JsonKey (name: 'subject' )
736- TopicName topic;
780+ @JsonKey (name: 'subject' , includeToJson: true )
781+ TopicName get topic => conversation.topic;
782+
783+ @JsonKey (readValue: _readConversation, includeToJson: false )
784+ StreamConversation conversation;
785+
786+ static Map <String , dynamic > _readConversation (Map <dynamic , dynamic > json, String key) {
787+ return json as Map <String , dynamic >;
788+ }
737789
738790 StreamMessage ({
739791 required super .client,
@@ -753,9 +805,7 @@ class StreamMessage extends Message {
753805 required super .flags,
754806 required super .matchContent,
755807 required super .matchTopic,
756- required this .displayRecipient,
757- required this .streamId,
758- required this .topic,
808+ required this .conversation,
759809 });
760810
761811 factory StreamMessage .fromJson (Map <String , dynamic > json) =>
@@ -781,20 +831,23 @@ class DmMessage extends Message {
781831 /// included.
782832 // TODO(server): Document that it's all users. That statement is based on
783833 // reverse-engineering notes in zulip-mobile:src/api/modelTypes.js at PmMessage.
784- @JsonKey (name: 'display_recipient' , fromJson : _allRecipientIdsFromJson, toJson : _allRecipientIdsToJson )
785- final List <int > allRecipientIds;
834+ @JsonKey (name: 'display_recipient' , toJson : _allRecipientIdsToJson, includeToJson : true )
835+ List <int > get allRecipientIds => conversation. allRecipientIds;
786836
787- static List <int > _allRecipientIdsFromJson (Object ? json) {
788- return (json as List <dynamic >).map (
789- (element) => ((element as Map <String , dynamic >)['id' ] as num ).toInt ()
790- ).toList (growable: false )
791- ..sort ();
792- }
837+ @JsonKey (name: 'display_recipient' , fromJson: _conversationFromJson, includeToJson: false )
838+ final DmConversation conversation;
793839
794840 static List <Map <String , dynamic >> _allRecipientIdsToJson (List <int > allRecipientIds) {
795841 return allRecipientIds.map ((element) => {'id' : element}).toList ();
796842 }
797843
844+ static DmConversation _conversationFromJson (List <dynamic > json) {
845+ return DmConversation (allRecipientIds: json.map (
846+ (element) => ((element as Map <String , dynamic >)['id' ] as num ).toInt ()
847+ ).toList (growable: false )
848+ ..sort ());
849+ }
850+
798851 DmMessage ({
799852 required super .client,
800853 required super .content,
@@ -813,7 +866,7 @@ class DmMessage extends Message {
813866 required super .flags,
814867 required super .matchContent,
815868 required super .matchTopic,
816- required this .allRecipientIds ,
869+ required this .conversation ,
817870 });
818871
819872 factory DmMessage .fromJson (Map <String , dynamic > json) =>
0 commit comments