1
1
import 'package:json_annotation/json_annotation.dart' ;
2
2
3
+ import '../../model/algorithms.dart' ;
3
4
import 'events.dart' ;
4
5
import 'initial_snapshot.dart' ;
5
6
import 'reaction.dart' ;
@@ -531,6 +532,111 @@ String? tryParseEmojiCodeToUnicode(String emojiCode) {
531
532
}
532
533
}
533
534
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
+ int streamId;
607
+
608
+ /// The name of the stream, found on stream message objects from the server.
609
+ ///
610
+ /// This is not updated when its name changes. Consider using [streamId]
611
+ /// instead to lookup stream name from the store.
612
+ @JsonKey (
613
+ // Make sure that this isn't nullable API-wise. If a message moves across
614
+ // channels, [displayRecipient] can still refer to the original channel
615
+ // and has to be invalidated.
616
+ required : true , disallowNullValue: true
617
+ )
618
+ String ? displayRecipient;
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
+
534
640
/// As in the get-messages response.
535
641
///
536
642
/// https://zulip.com/api/get-messages#response
@@ -655,85 +761,28 @@ enum MessageFlag {
655
761
String toJson () => _$MessageFlagEnumMap [this ]! ;
656
762
}
657
763
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
-
717
764
@JsonSerializable (fieldRename: FieldRename .snake)
718
765
class StreamMessage extends Message {
719
766
@override
720
767
@JsonKey (includeToJson: true )
721
768
String get type => 'stream' ;
722
769
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;
728
-
729
- int streamId;
770
+ @JsonKey (includeToJson: true )
771
+ int get streamId => conversation.streamId;
730
772
731
773
// The topic/subject is documented to be present on DMs too, just empty.
732
774
// We ignore it on DMs; if a future server introduces distinct topics in DMs,
733
775
// that will need new UI that we'll design then as part of that feature,
734
776
// and ignoring the topics seems as good a fallback behavior as any.
735
- @JsonKey (name: 'subject' )
736
- TopicName topic;
777
+ @JsonKey (name: 'subject' , includeToJson: true )
778
+ TopicName get topic => conversation.topic;
779
+
780
+ @JsonKey (readValue: _readConversation, includeToJson: false )
781
+ StreamConversation conversation;
782
+
783
+ static Map <String , dynamic > _readConversation (Map <dynamic , dynamic > json, String key) {
784
+ return json as Map <String , dynamic >;
785
+ }
737
786
738
787
StreamMessage ({
739
788
required super .client,
@@ -753,9 +802,7 @@ class StreamMessage extends Message {
753
802
required super .flags,
754
803
required super .matchContent,
755
804
required super .matchTopic,
756
- required this .displayRecipient,
757
- required this .streamId,
758
- required this .topic,
805
+ required this .conversation,
759
806
});
760
807
761
808
factory StreamMessage .fromJson (Map <String , dynamic > json) =>
@@ -781,20 +828,23 @@ class DmMessage extends Message {
781
828
/// included.
782
829
// TODO(server): Document that it's all users. That statement is based on
783
830
// 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;
831
+ @JsonKey (name: 'display_recipient' , toJson : _allRecipientIdsToJson, includeToJson : true )
832
+ List <int > get allRecipientIds => conversation. allRecipientIds;
786
833
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
- }
834
+ @JsonKey (name: 'display_recipient' , fromJson: _conversationFromJson, includeToJson: false )
835
+ final DmConversation conversation;
793
836
794
837
static List <Map <String , dynamic >> _allRecipientIdsToJson (List <int > allRecipientIds) {
795
838
return allRecipientIds.map ((element) => {'id' : element}).toList ();
796
839
}
797
840
841
+ static DmConversation _conversationFromJson (List <dynamic > json) {
842
+ return DmConversation (allRecipientIds: json.map (
843
+ (element) => ((element as Map <String , dynamic >)['id' ] as num ).toInt ()
844
+ ).toList (growable: false )
845
+ ..sort ());
846
+ }
847
+
798
848
DmMessage ({
799
849
required super .client,
800
850
required super .content,
@@ -813,7 +863,7 @@ class DmMessage extends Message {
813
863
required super .flags,
814
864
required super .matchContent,
815
865
required super .matchTopic,
816
- required this .allRecipientIds ,
866
+ required this .conversation ,
817
867
});
818
868
819
869
factory DmMessage .fromJson (Map <String , dynamic > json) =>
0 commit comments