Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

local echo (n/n): Support simplified version of local echo #1453

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,10 @@
"@messageIsMovedLabel": {
"description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)"
},
"messageIsntSentLabel": "MESSAGE ISN'T SENT. CHECK YOUR CONNECTION.",
"@messageIsntSentLabel": {
"description": "Label for a message that isn't sent. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)"
},
"pollVoterNames": "({voterNames})",
"@pollVoterNames": {
"description": "The list of people who voted for a poll option, wrapped in parentheses.",
Expand Down
2 changes: 1 addition & 1 deletion lib/api/model/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,7 @@ class MessageEvent extends Event {
// events and in the get-messages results is that `matchContent` and
// `matchTopic` are absent here. Already [Message.matchContent] and
// [Message.matchTopic] are optional, so no action is needed on that.
@JsonKey(readValue: _readMessageValue, includeToJson: false)
@JsonKey(readValue: _readMessageValue, fromJson: Message.fromJson, includeToJson: false)
final Message message;

// When present, this equals the "local_id" parameter
Expand Down
179 changes: 110 additions & 69 deletions lib/api/model/model.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:json_annotation/json_annotation.dart';

import '../../model/algorithms.dart';
import '../route/messages.dart';
import 'events.dart';
import 'initial_snapshot.dart';
import 'reaction.dart';
Expand Down Expand Up @@ -531,10 +533,75 @@ String? tryParseEmojiCodeToUnicode(String emojiCode) {
}
}

/// As in [MessageBase.recipient].
///
/// Different from [MessageDestination], this information comes from
/// [getMessages] or [getEvents], identifying the conversation that contains a
/// message.
sealed class Recipient {}

/// The recipient of a stream message.
@JsonSerializable(fieldRename: FieldRename.snake, createToJson: false)
class StreamRecipient extends Recipient {
StreamRecipient(this.streamId, this.topic);

int streamId;

@JsonKey(name: 'subject')
TopicName topic;

factory StreamRecipient.fromJson(Map<String, dynamic> json) =>
_$StreamRecipientFromJson(json);
}

/// The recipient of a DM message.
class DmRecipient extends Recipient {
DmRecipient({required this.allRecipientIds})
: assert(isSortedWithoutDuplicates(allRecipientIds.toList()));

factory DmRecipient.fromDmDestination(DmDestination destination, {
required int selfUserId,
}) {
assert(destination.userIds.contains(selfUserId));
return DmRecipient(allRecipientIds: destination.userIds);
}

/// The user IDs of all users in the thread, sorted numerically.
///
/// This lists the sender as well as all (other) recipients, and it
/// lists each user just once. In particular the self-user is always
/// included.
///
/// This is required to have an efficient `length`.
final List<int> allRecipientIds;
}

/// A message or message-like object, for showing in a message list.
///
/// Other than [Message], we use this for "outbox messages",
/// representing outstanding [sendMessage] requests.
abstract class MessageBase<T extends Recipient> {
/// The Zulip message ID.
///
/// If null, the message doesn't have an ID acknowledged by the server
/// (e.g.: a locally-echoed message).
int? get id;

int get senderId;
int get timestamp;

/// The recipient of this message.
// When implementing this, the return type should be either [StreamRecipient]
// or [DmRecipient]; it should never be [Recipient], because we
// expect a concrete subclass of [MessageBase] to represent either
// a channel message or a DM message, not both.
T get recipient;
}

/// As in the get-messages response.
///
/// https://zulip.com/api/get-messages#response
sealed class Message {
sealed class Message<T extends Recipient> implements MessageBase<T> {
// final String? avatarUrl; // Use [User.avatarUrl] instead; will live-update
final String client;
String content;
Expand All @@ -544,6 +611,7 @@ sealed class Message {
@JsonKey(readValue: MessageEditState._readFromMessage, fromJson: Message._messageEditStateFromJson)
MessageEditState editState;

@override
final int id;
bool isMeMessage;
int? lastEditTimestamp;
Expand All @@ -554,13 +622,15 @@ sealed class Message {
final int recipientId;
final String senderEmail;
final String senderFullName;
@override
final int senderId;
final String senderRealmStr;

/// Poll data if "submessages" describe a poll, `null` otherwise.
@JsonKey(name: 'submessages', readValue: _readPoll, fromJson: Poll.fromJson, toJson: Poll.toJson)
Poll? poll;

@override
final int timestamp;
String get type;

Expand Down Expand Up @@ -619,7 +689,9 @@ sealed class Message {
required this.matchTopic,
});

factory Message.fromJson(Map<String, dynamic> json) {
// 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<String, dynamic> json) {
final type = json['type'] as String;
if (type == 'stream') return StreamMessage.fromJson(json);
if (type == 'private') return DmMessage.fromJson(json);
Expand Down Expand Up @@ -715,7 +787,7 @@ extension type const TopicName(String _value) {
}

@JsonSerializable(fieldRename: FieldRename.snake)
class StreamMessage extends Message {
class StreamMessage extends Message<StreamRecipient> {
@override
@JsonKey(includeToJson: true)
String get type => 'stream';
Expand All @@ -726,14 +798,23 @@ class StreamMessage extends Message {
@JsonKey(required: true, disallowNullValue: true)
String? displayRecipient;

int streamId;
@JsonKey(includeToJson: true)
int get streamId => recipient.streamId;

// The topic/subject is documented to be present on DMs too, just empty.
// We ignore it on DMs; if a future server introduces distinct topics in DMs,
// that will need new UI that we'll design then as part of that feature,
// and ignoring the topics seems as good a fallback behavior as any.
@JsonKey(name: 'subject')
TopicName topic;
@JsonKey(name: 'subject', includeToJson: true)
TopicName get topic => recipient.topic;

@override
@JsonKey(readValue: _readRecipient, includeToJson: false)
StreamRecipient recipient;

static Map<String, dynamic> _readRecipient(Map<dynamic, dynamic> json, String key) {
return {'stream_id': json['stream_id'], 'subject': json['subject']};
}

StreamMessage({
required super.client,
Expand All @@ -754,8 +835,7 @@ class StreamMessage extends Message {
required super.matchContent,
required super.matchTopic,
required this.displayRecipient,
required this.streamId,
required this.topic,
required this.recipient,
});

factory StreamMessage.fromJson(Map<String, dynamic> json) =>
Expand All @@ -766,77 +846,38 @@ class StreamMessage extends Message {
}

@JsonSerializable(fieldRename: FieldRename.snake)
class DmRecipient {
final int id;
final String email;
final String fullName;

// final String? shortName; // obsolete, ignore
// final bool? isMirrorDummy; // obsolete, ignore

DmRecipient({required this.id, required this.email, required this.fullName});

factory DmRecipient.fromJson(Map<String, dynamic> json) =>
_$DmRecipientFromJson(json);

Map<String, dynamic> toJson() => _$DmRecipientToJson(this);

@override
String toString() => 'DmRecipient(id: $id, email: $email, fullName: $fullName)';

@override
bool operator ==(Object other) {
if (other is! DmRecipient) return false;
return other.id == id && other.email == email && other.fullName == fullName;
}

@override
int get hashCode => Object.hash('DmRecipient', id, email, fullName);
}

class DmRecipientListConverter extends JsonConverter<List<DmRecipient>, List<dynamic>> {
const DmRecipientListConverter();

@override
List<DmRecipient> fromJson(List<dynamic> json) {
return json.map((e) => DmRecipient.fromJson(e as Map<String, dynamic>))
.toList(growable: false)
..sort((a, b) => a.id.compareTo(b.id));
}

@override
List<dynamic> toJson(List<DmRecipient> object) => object;
}

@JsonSerializable(fieldRename: FieldRename.snake)
class DmMessage extends Message {
class DmMessage extends Message<DmRecipient> {
@override
@JsonKey(includeToJson: true)
String get type => 'private';

/// The `display_recipient` from the server, sorted by user ID numerically.
/// The user IDs of all users in the thread, sorted numerically, as in
/// the `display_recipient from the server.
///
/// The other fields on `display_recipient` are ignored and won't roundtrip.
///
/// This lists the sender as well as all (other) recipients, and it
/// lists each user just once. In particular the self-user is always
/// included.
///
/// Note the data here is not updated on changes to the users, so everything
/// other than the user IDs may be stale.
/// Consider using [allRecipientIds] instead, and getting user details
/// from the store.
// TODO(server): Document that it's all users. That statement is based on
// reverse-engineering notes in zulip-mobile:src/api/modelTypes.js at PmMessage.
@DmRecipientListConverter()
final List<DmRecipient> displayRecipient;
@JsonKey(name: 'display_recipient', toJson: _allRecipientIdsToJson, includeToJson: true)
List<int> get allRecipientIds => recipient.allRecipientIds;

/// The user IDs of all users in the thread, sorted numerically.
///
/// This lists the sender as well as all (other) recipients, and it
/// lists each user just once. In particular the self-user is always
/// included.
///
/// This is a result of [List.map], so it has an efficient `length`.
Iterable<int> get allRecipientIds => displayRecipient.map((e) => e.id);
@override
@JsonKey(name: 'display_recipient', fromJson: _recipientFromJson, includeToJson: false)
final DmRecipient recipient;

static List<Map<String, dynamic>> _allRecipientIdsToJson(List<int> allRecipientIds) {
return allRecipientIds.map((element) => {'id': element}).toList();
}

static DmRecipient _recipientFromJson(List<dynamic> json) {
return DmRecipient(allRecipientIds: json.map(
(element) => ((element as Map<String, dynamic>)['id'] as num).toInt()
).toList(growable: false)
..sort());
}

DmMessage({
required super.client,
Expand All @@ -856,7 +897,7 @@ class DmMessage extends Message {
required super.flags,
required super.matchContent,
required super.matchTopic,
required this.displayRecipient,
required this.recipient,
});

factory DmMessage.fromJson(Map<String, dynamic> json) =>
Expand Down
32 changes: 12 additions & 20 deletions lib/api/model/model.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions lib/api/route/messages.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Future<GetMessageResult> getMessage(ApiConnection connection, {
@JsonSerializable(fieldRename: FieldRename.snake)
class GetMessageResult {
// final String rawContent; // deprecated; ignore
@JsonKey(fromJson: Message.fromJson)
final Message message;

GetMessageResult({
Expand Down Expand Up @@ -138,6 +139,7 @@ class GetMessagesResult {
final bool foundOldest;
final bool foundAnchor;
final bool historyLimited;
@JsonKey(fromJson: _messagesFromJson)
final List<Message> messages;

GetMessagesResult({
Expand All @@ -149,6 +151,12 @@ class GetMessagesResult {
required this.messages,
});

static List<Message> _messagesFromJson(Object json) {
return (json as List<dynamic>)
.map((e) => Message.fromJson(e as Map<String, dynamic>))
.toList();
}

factory GetMessagesResult.fromJson(Map<String, dynamic> json) =>
_$GetMessagesResultFromJson(json);

Expand Down
Loading