Skip to content

Commit 2ae3c2f

Browse files
committed
api [nfc]: Pull out Conversation
This will help support outbox messages in MessageListView later. We extracted Conversation instead of using MessageDestination because they are foundamentally different. Conversation is the identifier for the conversation that contains the message from, for example, get-messages or message events, but MessageDestination is specifically for send-message.
1 parent 87588b0 commit 2ae3c2f

File tree

8 files changed

+210
-146
lines changed

8 files changed

+210
-146
lines changed

lib/api/model/model.dart

+132-79
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:json_annotation/json_annotation.dart';
22

3+
import '../../model/algorithms.dart';
34
import 'events.dart';
45
import 'initial_snapshot.dart';
56
import '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)
718765
class 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

Comments
 (0)