diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 614a784511..c98fea005c 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -481,7 +481,6 @@ sealed class Message { final String senderRealmStr; @JsonKey(name: 'subject') String topic; - // final List submessages; // TODO handle final int timestamp; String get type; @@ -493,14 +492,14 @@ sealed class Message { @JsonKey(name: 'match_subject') final String? matchTopic; - static MessageEditState _messageEditStateFromJson(dynamic json) { + static MessageEditState _messageEditStateFromJson(Object? json) { // This is a no-op so that [MessageEditState._readFromMessage] // can return the enum value directly. return json as MessageEditState; } - static Reactions? _reactionsFromJson(dynamic json) { - final list = (json as List); + static Reactions? _reactionsFromJson(Object? json) { + final list = (json as List); return list.isNotEmpty ? Reactions.fromJson(list) : null; } @@ -508,8 +507,8 @@ sealed class Message { return value ?? []; } - static List _flagsFromJson(dynamic json) { - final list = json as List; + static List _flagsFromJson(Object? json) { + final list = json as List; return list.map((raw) => MessageFlag.fromRawString(raw as String)).toList(); } diff --git a/lib/api/model/submessage.dart b/lib/api/model/submessage.dart new file mode 100644 index 0000000000..900f8dbbde --- /dev/null +++ b/lib/api/model/submessage.dart @@ -0,0 +1,315 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'submessage.g.dart'; + +/// Data used for Zulip "widgets" within messages, like polls and todo lists. +/// +/// For docs, see: +/// https://zulip.com/api/get-messages#response (search for "submessage") +/// https://zulip.readthedocs.io/en/latest/subsystems/widgets.html +/// +/// This is an underdocumented part of the Zulip Server API. +/// So in addition to docs, see other clients: +/// https://github.com/zulip/zulip-mobile/blob/2217c858e/src/api/modelTypes.js#L800-L861 +/// https://github.com/zulip/zulip-mobile/blob/2217c858e/src/webview/html/message.js#L118-L192 +/// https://github.com/zulip/zulip/blob/40f59a05c/web/src/submessage.ts +/// https://github.com/zulip/zulip/blob/40f59a05c/web/shared/src/poll_data.ts +@JsonSerializable(fieldRename: FieldRename.snake) +class Submessage { + const Submessage({ + required this.senderId, + required this.msgType, + required this.content, + }); + + // TODO(server): should we be sorting a message's submessages by ID? Web seems to: + // https://github.com/zulip/zulip/blob/40f59a05c55e0e4f26ca87d2bca646770e94bff0/web/src/submessage.ts#L88 + // final int id; // ignored because we don't use it + + /// The sender of this submessage (not necessarily of the [Message] it's on). + final int senderId; + + // final int messageId; // ignored; redundant with [Message.id] + + @JsonKey(unknownEnumValue: SubmessageType.unknown) + final SubmessageType msgType; + + /// A JSON encoding of a [SubmessageData]. + // We cannot parse the String into one of the [SubmessageData] classes because + // information from other submessages are required. Specifically, we need: + // * the index of this submessage in [Message.submessages]; + // * the [WidgetType] of the first [Message.submessages]. + final String content; + + factory Submessage.fromJson(Map json) => + _$SubmessageFromJson(json); + + Map toJson() => _$SubmessageToJson(this); +} + +/// As in [Submessage.msgType]. +/// +/// The only type of submessage that actually exists in Zulip (as of 2024, +/// and since this "submessages" subsystem was created in 2017–2018) +/// is [SubmessageType.widget]. +enum SubmessageType { + widget, + unknown, +} + +/// The data encoded in a submessage at [Submessage.content]. +/// +/// For widgets (the only existing use of submessages), the submessages +/// on a [Message] consist of: +/// * One submessage with content [WidgetData]; then +/// * Zero or more submessages with content [PollEventSubmessage] if the +/// message is a poll (i.e. if the first submessage was a [PollWidgetData]), +/// and similarly for other types of widgets. +sealed class SubmessageData {} + +/// The data encoded in a submessage to make the message a Zulip widget. +/// +/// Expected from the first [Submessage.content] in the "submessages" field on +/// the message when there is an widget. +/// +/// See https://zulip.readthedocs.io/en/latest/subsystems/widgets.html +sealed class WidgetData extends SubmessageData { + WidgetType get widgetType; + + WidgetData(); + + factory WidgetData.fromJson(Object? json) { + final map = json as Map; + final rawWidgetType = map['widget_type'] as String; + return switch (WidgetType.fromRawString(rawWidgetType)) { + WidgetType.poll => PollWidgetData.fromJson(map), + WidgetType.unknown => UnsupportedWidgetData.fromJson(map), + }; + } + + Object? toJson(); +} + +/// As in [WidgetData.widgetType]. +@JsonEnum(alwaysCreate: true) +enum WidgetType { + poll, + // todo, // TODO(#882) + // zform, // This exists in web but is more a demo than a real feature. + unknown; + + static WidgetType fromRawString(String raw) => _byRawString[raw] ?? unknown; + + static final _byRawString = _$WidgetTypeEnumMap + .map((key, value) => MapEntry(value, key)); +} + +/// The data in the first submessage on a poll widget message. +/// +/// Subsequent submessages on the same message will be [PollEventSubmessage]. +@JsonSerializable(fieldRename: FieldRename.snake) +class PollWidgetData extends WidgetData { + @override + @JsonKey(includeToJson: true) + WidgetType get widgetType => WidgetType.poll; + + /// The initial question and options on the poll. + final PollWidgetExtraData extraData; + + PollWidgetData({required this.extraData}); + + factory PollWidgetData.fromJson(Map json) => + _$PollWidgetDataFromJson(json); + + @override + Map toJson() => _$PollWidgetDataToJson(this); +} + +/// As in [PollWidgetData.extraData]. +@JsonSerializable(fieldRename: FieldRename.snake) +class PollWidgetExtraData { + // The [question] and [options] fields seem to be always present. + // But both web and zulip-mobile accept them as optional, with default values: + // https://github.com/zulip/zulip-flutter/pull/823#discussion_r1697656896 + // https://github.com/zulip/zulip/blob/40f59a05c55e0e4f26ca87d2bca646770e94bff0/web/src/poll_widget.ts#L29 + // And the server doesn't really enforce any structure on submessage data. + // So match the web and zulip-mobile behavior. + @JsonKey(defaultValue: "") + final String question; + @JsonKey(defaultValue: []) + final List options; + + const PollWidgetExtraData({required this.question, required this.options}); + + factory PollWidgetExtraData.fromJson(Map json) => + _$PollWidgetExtraDataFromJson(json); + + Map toJson() => _$PollWidgetExtraDataToJson(this); +} + +class UnsupportedWidgetData extends WidgetData { + @override + @JsonKey(includeToJson: true) + WidgetType get widgetType => WidgetType.unknown; + + final Object? json; + + UnsupportedWidgetData.fromJson(this.json); + + @override + Object? toJson() => json; +} + +/// The data in a submessage that acts on a poll. +/// +/// The first submessage on the message should be a [PollWidgetData]. +sealed class PollEventSubmessage extends SubmessageData { + PollEventSubmessageType get type; + + PollEventSubmessage(); + + /// The key for identifying the [idx]'th option added by user + /// [senderId] to a poll. + /// + /// For options that are a part of the initial [PollWidgetData], the + /// [senderId] should be `null`. + static String optionKey({required int? senderId, required int idx}) => + // "canned" is a canonical constant coined by the web client: + // https://github.com/zulip/zulip/blob/40f59a05c/web/shared/src/poll_data.ts#L238 + '${senderId ?? 'canned'},$idx'; + + factory PollEventSubmessage.fromJson(Map json) { + final rawPollEventType = json['type'] as String; + switch (PollEventSubmessageType.fromRawString(rawPollEventType)) { + case PollEventSubmessageType.newOption: return PollNewOptionEventSubmessage.fromJson(json); + case PollEventSubmessageType.question: return PollQuestionEventSubmessage.fromJson(json); + case PollEventSubmessageType.vote: return PollVoteEventSubmessage.fromJson(json); + case PollEventSubmessageType.unknown: return UnknownPollEventSubmessage.fromJson(json); + } + } + + Map toJson(); +} + +/// As in [PollEventSubmessage.type]. +@JsonEnum(fieldRename: FieldRename.snake) +enum PollEventSubmessageType { + newOption, + question, + vote, + unknown; + + static PollEventSubmessageType fromRawString(String raw) => _byRawString[raw]!; + + static final _byRawString = _$PollEventSubmessageTypeEnumMap + .map((key, value) => MapEntry(value, key)); +} + +/// A poll event when an option is added. +/// +/// See: https://github.com/zulip/zulip/blob/40f59a05c/web/shared/src/poll_data.ts#L112-L159 +@JsonSerializable(fieldRename: FieldRename.snake) +class PollNewOptionEventSubmessage extends PollEventSubmessage { + @override + @JsonKey(includeToJson: true) + PollEventSubmessageType get type => PollEventSubmessageType.newOption; + + final String option; + /// A sequence number for this option, among options added to this poll + /// by this [Submessage.senderId]. + /// + /// See [PollEventSubmessage.optionKey]. + final int idx; + + PollNewOptionEventSubmessage({required this.option, required this.idx}); + + @override + factory PollNewOptionEventSubmessage.fromJson(Map json) => + _$PollNewOptionEventSubmessageFromJson(json); + + @override + Map toJson() => _$PollNewOptionEventSubmessageToJson(this); +} + +/// A poll event when the question has been edited. +/// +/// See: https://github.com/zulip/zulip/blob/40f59a05c/web/shared/src/poll_data.ts#L161-186 +@JsonSerializable(fieldRename: FieldRename.snake) +class PollQuestionEventSubmessage extends PollEventSubmessage { + @override + @JsonKey(includeToJson: true) + PollEventSubmessageType get type => PollEventSubmessageType.question; + + final String question; + + PollQuestionEventSubmessage({required this.question}); + + @override + factory PollQuestionEventSubmessage.fromJson(Map json) => + _$PollQuestionEventSubmessageFromJson(json); + + @override + Map toJson() => _$PollQuestionEventSubmessageToJson(this); +} + +/// A poll event when a vote has been cast or removed. +/// +/// See: https://github.com/zulip/zulip/blob/40f59a05c/web/shared/src/poll_data.ts#L188-234 +@JsonSerializable(fieldRename: FieldRename.snake) +class PollVoteEventSubmessage extends PollEventSubmessage { + @override + @JsonKey(includeToJson: true) + PollEventSubmessageType get type => PollEventSubmessageType.vote; + + /// The key of the affected option. + /// + /// See [PollEventSubmessage.optionKey]. + final String key; + @JsonKey(name: 'vote', unknownEnumValue: PollVoteOp.unknown) + final PollVoteOp op; + + PollVoteEventSubmessage({required this.key, required this.op}); + + @override + factory PollVoteEventSubmessage.fromJson(Map json) { + final result = _$PollVoteEventSubmessageFromJson(json); + // Crunchy-shell validation + final segments = result.key.split(','); + final [senderId, idx] = segments; + if (senderId != 'canned') { + int.parse(senderId, radix: 10); + } + int.parse(idx, radix: 10); + return result; + } + + @override + Map toJson() => _$PollVoteEventSubmessageToJson(this); +} + +/// As in [PollVoteEventSubmessage.op]. +@JsonEnum(valueField: 'apiValue') +enum PollVoteOp { + add(apiValue: 1), + remove(apiValue: -1), + unknown(apiValue: null); + + const PollVoteOp({required this.apiValue}); + + final int? apiValue; + + int? toJson() => apiValue; +} + +class UnknownPollEventSubmessage extends PollEventSubmessage { + @override + @JsonKey(includeToJson: true) + PollEventSubmessageType get type => PollEventSubmessageType.unknown; + + final Map json; + + UnknownPollEventSubmessage.fromJson(this.json); + + @override + Map toJson() => json; +} diff --git a/lib/api/model/submessage.g.dart b/lib/api/model/submessage.g.dart new file mode 100644 index 0000000000..a7ffaebc05 --- /dev/null +++ b/lib/api/model/submessage.g.dart @@ -0,0 +1,118 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: constant_identifier_names, unnecessary_cast + +part of 'submessage.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Submessage _$SubmessageFromJson(Map json) => Submessage( + senderId: (json['sender_id'] as num).toInt(), + msgType: $enumDecode(_$SubmessageTypeEnumMap, json['msg_type'], + unknownValue: SubmessageType.unknown), + content: json['content'] as String, + ); + +Map _$SubmessageToJson(Submessage instance) => + { + 'sender_id': instance.senderId, + 'msg_type': _$SubmessageTypeEnumMap[instance.msgType]!, + 'content': instance.content, + }; + +const _$SubmessageTypeEnumMap = { + SubmessageType.widget: 'widget', + SubmessageType.unknown: 'unknown', +}; + +PollWidgetData _$PollWidgetDataFromJson(Map json) => + PollWidgetData( + extraData: PollWidgetExtraData.fromJson( + json['extra_data'] as Map), + ); + +Map _$PollWidgetDataToJson(PollWidgetData instance) => + { + 'widget_type': _$WidgetTypeEnumMap[instance.widgetType]!, + 'extra_data': instance.extraData, + }; + +const _$WidgetTypeEnumMap = { + WidgetType.poll: 'poll', + WidgetType.unknown: 'unknown', +}; + +PollWidgetExtraData _$PollWidgetExtraDataFromJson(Map json) => + PollWidgetExtraData( + question: json['question'] as String? ?? '', + options: (json['options'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + ); + +Map _$PollWidgetExtraDataToJson( + PollWidgetExtraData instance) => + { + 'question': instance.question, + 'options': instance.options, + }; + +PollNewOptionEventSubmessage _$PollNewOptionEventSubmessageFromJson( + Map json) => + PollNewOptionEventSubmessage( + option: json['option'] as String, + idx: (json['idx'] as num).toInt(), + ); + +Map _$PollNewOptionEventSubmessageToJson( + PollNewOptionEventSubmessage instance) => + { + 'type': _$PollEventSubmessageTypeEnumMap[instance.type]!, + 'option': instance.option, + 'idx': instance.idx, + }; + +const _$PollEventSubmessageTypeEnumMap = { + PollEventSubmessageType.newOption: 'new_option', + PollEventSubmessageType.question: 'question', + PollEventSubmessageType.vote: 'vote', + PollEventSubmessageType.unknown: 'unknown', +}; + +PollQuestionEventSubmessage _$PollQuestionEventSubmessageFromJson( + Map json) => + PollQuestionEventSubmessage( + question: json['question'] as String, + ); + +Map _$PollQuestionEventSubmessageToJson( + PollQuestionEventSubmessage instance) => + { + 'type': _$PollEventSubmessageTypeEnumMap[instance.type]!, + 'question': instance.question, + }; + +PollVoteEventSubmessage _$PollVoteEventSubmessageFromJson( + Map json) => + PollVoteEventSubmessage( + key: json['key'] as String, + op: $enumDecode(_$PollVoteOpEnumMap, json['vote'], + unknownValue: PollVoteOp.unknown), + ); + +Map _$PollVoteEventSubmessageToJson( + PollVoteEventSubmessage instance) => + { + 'type': _$PollEventSubmessageTypeEnumMap[instance.type]!, + 'key': instance.key, + 'vote': instance.op, + }; + +const _$PollVoteOpEnumMap = { + PollVoteOp.add: 1, + PollVoteOp.remove: -1, + PollVoteOp.unknown: null, +}; diff --git a/test/api/model/submessage_checks.dart b/test/api/model/submessage_checks.dart new file mode 100644 index 0000000000..a234f8cc56 --- /dev/null +++ b/test/api/model/submessage_checks.dart @@ -0,0 +1,39 @@ + +import 'package:checks/checks.dart'; +import 'package:zulip/api/model/submessage.dart'; + +extension SubmessageChecks on Subject { + Subject get senderId => has((e) => e.senderId, 'senderId'); + Subject get msgType => has((e) => e.msgType, 'msgType'); + Subject get content => has((e) => e.content, 'content'); +} + +extension WidgetDataChecks on Subject { + Subject get widgetType => has((e) => e.widgetType, 'widgetType'); +} + +extension PollWidgetDataChecks on Subject { + Subject get extraData => has((e) => e.extraData, 'extraData'); +} + +extension PollWidgetExtraDataChecks on Subject { + Subject get question => has((e) => e.question, 'question'); + Subject> get options => has((e) => e.options, 'options'); +} + +extension PollEventChecks on Subject { + Subject get type => has((e) => e.type, 'type'); +} + +extension PollOptionEventChecks on Subject { + Subject get option => has((e) => e.option, 'option'); +} + +extension PollQuestionEventChecks on Subject { + Subject get question => has((e) => e.question, 'question'); +} + +extension PollVoteEventChecks on Subject { + Subject get key => has((e) => e.key, 'key'); + Subject get op => has((e) => e.op, 'op'); +} diff --git a/test/api/model/submessage_test.dart b/test/api/model/submessage_test.dart new file mode 100644 index 0000000000..a42e4cf1cb --- /dev/null +++ b/test/api/model/submessage_test.dart @@ -0,0 +1,114 @@ +import 'package:checks/checks.dart'; +import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/submessage.dart'; + +import '../../example_data.dart' as eg; +import 'submessage_checks.dart'; + +void main() { + group('Message.submessages', () { + test('no crash on unrecognized submessage type', () { + final baseJson = { + 'id': 1, + 'sender_id': eg.selfUser.userId, + 'message_id': 123, + 'content': '[]', + }; + + check(Submessage.fromJson({ + ...baseJson, + 'msg_type': 'widget', + })).msgType.equals(SubmessageType.widget); + + check(Submessage.fromJson({ + ...baseJson, + 'msg_type': 'unknown_widget', + })).msgType.equals(SubmessageType.unknown); + }); + }); + + test('smoke WidgetData', () { + check(WidgetData.fromJson(eg.pollWidgetDataFavoriteLetter)).isA() + ..widgetType.equals(WidgetType.poll) + ..extraData.which((x) => x + ..question.equals('favorite letter') + ..options.deepEquals(['A', 'B', 'C']) + ); + }); + + test('invalid widget_type -> UnsupportedWidgetData/throw', () { + check(WidgetData.fromJson({ + ...eg.pollWidgetDataFavoriteLetter, + 'widget_type': 'unknown_foo', + })).isA(); + + check(() => WidgetData.fromJson({ + ...eg.pollWidgetDataFavoriteLetter, + 'widget_type': 123, + })).throws(); + }); + + test('smoke PollEventSubmessage', () { + check(PollEventSubmessage.fromJson({ + 'type': 'new_option', + 'option': 'new option', + 'idx': 0, + })).isA() + ..type.equals(PollEventSubmessageType.newOption) + ..option.equals('new option'); + + check(PollEventSubmessage.fromJson({ + 'type': 'question', + 'question': 'new question', + })).isA() + ..type.equals(PollEventSubmessageType.question) + ..question.equals('new question'); + + check(PollEventSubmessage.fromJson({ + 'type': 'vote', + 'vote': 1, + 'key': PollEventSubmessage.optionKey(senderId: null, idx: 0), + })).isA() + ..type.equals(PollEventSubmessageType.vote) + ..op.equals(PollVoteOp.add) + ..key.equals('canned,0'); + }); + + test('handle unknown poll event', () { + check(() => PollEventSubmessage.fromJson({ + 'type': 'foo', + })).throws(); + }); + + test('crash on poll vote key', () { + final voteData = {'type': 'vote', 'vote': 1}; + + check(() => PollEventSubmessage.fromJson({...voteData, + 'key': PollEventSubmessage.optionKey(senderId: null, idx: 0) + })).returnsNormally(); + check(() => PollEventSubmessage.fromJson({ ...voteData, + 'key': PollEventSubmessage.optionKey(senderId: 5, idx: 0) + })).returnsNormally(); + + check(() => PollEventSubmessage.fromJson({ ...voteData, + 'key': 'foo,0', + })).throws(); + check(() => PollEventSubmessage.fromJson({ ...voteData, + 'key': 'canned,bar', + })).throws(); + check(() => PollEventSubmessage.fromJson({ ...voteData, + 'key': 'canned,0xdeadbeef', + })).throws(); + check(() => PollEventSubmessage.fromJson({ ...voteData, + 'key': '0xdeadbeef,0', + })).throws(); + }); + + test('handle unknown poll vote op', () { + check(PollEventSubmessage.fromJson({ + 'type': 'vote', + 'vote': 'invalid', + 'key': PollEventSubmessage.optionKey(senderId: null, idx: 0) + })).isA().op.equals(PollVoteOp.unknown); + }); +} diff --git a/test/example_data.dart b/test/example_data.dart index 5ff4bc80ef..c7820326ce 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -408,6 +408,14 @@ DmMessage dmMessage({ }) as Map); } +const pollWidgetDataFavoriteLetter = { + 'widget_type': 'poll', + 'extra_data': { + 'question': 'favorite letter', + 'options': ['A', 'B', 'C'], + } +}; + //////////////////////////////////////////////////////////////// // Aggregate data structures. //