Skip to content

Commit d7cd703

Browse files
PIG208gnprice
authored andcommitted
submessage: Add SubmessageData classes for polls.
The sealed class `SubmessageData` is not actually in use, we could potentially implement a discriminator utilizing the sealed class to deserialize individual submessage content, but it is far easier to do so when we have access to the full list of submessages. `SubmessageData` is there for self-documentation. It is also worth noting that much of these class definitions are based on previous reverse engineering effort and the web implementation. See: - https://github.com/zulip/zulip/blob/304d948416465c1a085122af5d752f03d6797003/web/shared/src/poll_data.ts - https://github.com/zulip/zulip-mobile/blob/2217c858e207f9f092651dd853051843c3f04422/src/api/modelTypes.js#L800-L861 Due to the flexibility of the submessage API, these classes tend to be intentionally defensive against unknown or invalid values. Signed-off-by: Zixuan James Li <[email protected]>
1 parent bd4a7ca commit d7cd703

File tree

4 files changed

+434
-0
lines changed

4 files changed

+434
-0
lines changed

lib/api/model/submessage.dart

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ class Submessage {
1818

1919
@JsonKey(unknownEnumValue: SubmessageType.unknown)
2020
final SubmessageType msgType;
21+
/// [SubmessageData] encoded in JSON.
22+
// We cannot parse the String into one of the [SubmessageData] classes because
23+
// information from other submessages are required. Specifically, we need:
24+
// * the index of this submessage in [Message.submessages];
25+
// * the [WidgetType] of the first [Message.submessages].
2126
final String content;
2227
// final int messageId; // ignored; redundant with [Message.id]
2328
final int senderId;
@@ -34,3 +39,229 @@ enum SubmessageType {
3439
widget,
3540
unknown,
3641
}
42+
43+
sealed class SubmessageData {}
44+
45+
/// The data encoded in a submessage to make the message a Zulip widget.
46+
///
47+
/// Expected from the first [Submessage.content] in the "submessages" field on
48+
/// the message when there is an widget.
49+
///
50+
/// See https://zulip.readthedocs.io/en/latest/subsystems/widgets.html
51+
sealed class WidgetData extends SubmessageData {
52+
WidgetType get widgetType;
53+
54+
WidgetData();
55+
56+
factory WidgetData.fromJson(Object? json) {
57+
final map = json as Map<String, Object?>;
58+
final rawWidgetType = map['widget_type'] as String;
59+
return switch (WidgetType.fromRawString(rawWidgetType)) {
60+
WidgetType.poll => PollWidgetData.fromJson(map),
61+
WidgetType.unknown => UnsupportedWidgetData(json: map),
62+
};
63+
}
64+
65+
Object? toJson();
66+
}
67+
68+
/// As in [WidgetData.widgetType].
69+
@JsonEnum(alwaysCreate: true)
70+
enum WidgetType {
71+
poll,
72+
unknown;
73+
74+
static WidgetType fromRawString(String raw) => _byRawString[raw] ?? unknown;
75+
76+
static final _byRawString = _$WidgetTypeEnumMap
77+
.map((key, value) => MapEntry(value, key));
78+
}
79+
80+
/// The data encoded in a submessage to make the message a poll widget.
81+
@JsonSerializable(fieldRename: FieldRename.snake)
82+
class PollWidgetData extends WidgetData {
83+
@override
84+
@JsonKey(includeToJson: true)
85+
WidgetType get widgetType => WidgetType.poll;
86+
87+
/// The initial question and options on the poll.
88+
final PollWidgetExtraData extraData;
89+
90+
PollWidgetData({required this.extraData});
91+
92+
factory PollWidgetData.fromJson(Map<String, Object?> json) =>
93+
_$PollWidgetDataFromJson(json);
94+
95+
@override
96+
Map<String, Object?> toJson() => _$PollWidgetDataToJson(this);
97+
}
98+
99+
/// As in [PollWidgetData.extraData].
100+
@JsonSerializable(fieldRename: FieldRename.snake)
101+
class PollWidgetExtraData {
102+
final String question;
103+
final List<String> options;
104+
105+
const PollWidgetExtraData({required this.question, required this.options});
106+
107+
factory PollWidgetExtraData.fromJson(Map<String, Object?> json) =>
108+
_$PollWidgetExtraDataFromJson(json);
109+
110+
Map<String, Object?> toJson() => _$PollWidgetExtraDataToJson(this);
111+
}
112+
113+
class UnsupportedWidgetData extends WidgetData {
114+
@override
115+
@JsonKey(includeToJson: true)
116+
WidgetType get widgetType => WidgetType.unknown;
117+
118+
UnsupportedWidgetData({required this.json});
119+
120+
final Object? json;
121+
122+
@override
123+
Object? toJson() => json;
124+
}
125+
126+
/// The data encoded in a submessage that acts on a poll.
127+
sealed class PollEventSubmessage extends SubmessageData {
128+
PollEventSubmessageType get type;
129+
130+
PollEventSubmessage();
131+
132+
/// The key for identifying the [optionIndex]'th option added by user
133+
/// [senderId] to a poll.
134+
///
135+
/// For options that are a part of the initial [PollWidgetData], the
136+
/// [senderId] should be `null`.
137+
static String optionKey({required int? senderId, required int optionIndex}) =>
138+
// "canned" is a canonical constant coined by the web client.
139+
'${senderId ?? 'canned'},$optionIndex';
140+
141+
factory PollEventSubmessage.fromJson(Map<String, Object?> json) {
142+
final rawPollEventType = json['type'] as String;
143+
switch (PollEventSubmessageType.fromRawString(rawPollEventType)) {
144+
case PollEventSubmessageType.newOption: return PollNewOptionEventSubmessage.fromJson(json);
145+
case PollEventSubmessageType.question: return PollQuestionEventSubmessage.fromJson(json);
146+
case PollEventSubmessageType.vote: return PollVoteEventSubmessage.fromJson(json);
147+
case PollEventSubmessageType.unknown: return UnknownPollEventSubmessage(json: json);
148+
}
149+
}
150+
151+
Map<String, Object?> toJson();
152+
}
153+
154+
/// A poll event when an option is added.
155+
@JsonSerializable(fieldRename: FieldRename.snake)
156+
class PollNewOptionEventSubmessage extends PollEventSubmessage {
157+
@override
158+
@JsonKey(includeToJson: true)
159+
PollEventSubmessageType get type => PollEventSubmessageType.newOption;
160+
161+
final String option;
162+
/// The index of last [option] added by the sender.
163+
@JsonKey(name: 'idx')
164+
final int latestOptionIndex;
165+
166+
PollNewOptionEventSubmessage({required this.option, required this.latestOptionIndex});
167+
168+
@override
169+
factory PollNewOptionEventSubmessage.fromJson(Map<String, Object?> json) =>
170+
_$PollNewOptionEventSubmessageFromJson(json);
171+
172+
@override
173+
Map<String, Object?> toJson() => _$PollNewOptionEventSubmessageToJson(this);
174+
}
175+
176+
/// A poll event when the question has been edited.
177+
@JsonSerializable(fieldRename: FieldRename.snake)
178+
class PollQuestionEventSubmessage extends PollEventSubmessage {
179+
@override
180+
@JsonKey(includeToJson: true)
181+
PollEventSubmessageType get type => PollEventSubmessageType.question;
182+
183+
final String question;
184+
185+
PollQuestionEventSubmessage({required this.question});
186+
187+
@override
188+
factory PollQuestionEventSubmessage.fromJson(Map<String, Object?> json) =>
189+
_$PollQuestionEventSubmessageFromJson(json);
190+
191+
@override
192+
Map<String, Object?> toJson() => _$PollQuestionEventSubmessageToJson(this);
193+
}
194+
195+
/// A poll event when a vote has been cast or removed.
196+
@JsonSerializable(fieldRename: FieldRename.snake)
197+
class PollVoteEventSubmessage extends PollEventSubmessage {
198+
@override
199+
@JsonKey(includeToJson: true)
200+
PollEventSubmessageType get type => PollEventSubmessageType.vote;
201+
202+
/// The key of the affected option.
203+
///
204+
/// See [PollEventSubmessage.optionKey].
205+
final String key;
206+
@JsonKey(name: 'vote', unknownEnumValue: PollVoteOp.unknown)
207+
final PollVoteOp op;
208+
209+
PollVoteEventSubmessage({required this.key, required this.op});
210+
211+
@override
212+
factory PollVoteEventSubmessage.fromJson(Map<String, Object?> json) {
213+
final result = _$PollVoteEventSubmessageFromJson(json);
214+
// Crunchy-shell validation
215+
final segments = result.key.split(',');
216+
final [senderId, optionIndex] = segments;
217+
if (senderId != 'canned') {
218+
int.parse(senderId, radix: 10);
219+
}
220+
int.parse(optionIndex, radix: 10);
221+
return result;
222+
}
223+
224+
@override
225+
Map<String, Object?> toJson() => _$PollVoteEventSubmessageToJson(this);
226+
}
227+
228+
/// As in [PollVoteEventSubmessage.op].
229+
@JsonEnum(valueField: 'apiValue')
230+
enum PollVoteOp {
231+
add(apiValue: 1),
232+
remove(apiValue: -1),
233+
unknown(apiValue: null);
234+
235+
const PollVoteOp({required this.apiValue});
236+
237+
final int? apiValue;
238+
239+
int? toJson() => apiValue;
240+
}
241+
242+
class UnknownPollEventSubmessage extends PollEventSubmessage {
243+
@override
244+
@JsonKey(includeToJson: true)
245+
PollEventSubmessageType get type => PollEventSubmessageType.unknown;
246+
247+
final Map<String, Object?> json;
248+
249+
UnknownPollEventSubmessage({required this.json});
250+
251+
@override
252+
Map<String, Object?> toJson() => json;
253+
}
254+
255+
/// As in [PollEventSubmessage.type].
256+
@JsonEnum(fieldRename: FieldRename.snake)
257+
enum PollEventSubmessageType {
258+
newOption,
259+
question,
260+
vote,
261+
unknown;
262+
263+
static PollEventSubmessageType fromRawString(String raw) => _byRawString[raw]!;
264+
265+
static final _byRawString = _$PollEventSubmessageTypeEnumMap
266+
.map((key, value) => MapEntry(value, key));
267+
}

lib/api/model/submessage.g.dart

Lines changed: 88 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/api/model/submessage_checks.dart

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,33 @@ extension SubmessageChecks on Subject<Submessage> {
77
Subject<Object?> get content => has((e) => e.content, 'content');
88
Subject<int> get senderId => has((e) => e.senderId, 'senderId');
99
}
10+
11+
extension WidgetDataChecks on Subject<WidgetData> {
12+
Subject<WidgetType> get widgetType => has((e) => e.widgetType, 'widgetType');
13+
}
14+
15+
extension PollWidgetDataChecks on Subject<PollWidgetData> {
16+
Subject<PollWidgetExtraData> get extraData => has((e) => e.extraData, 'extraData');
17+
}
18+
19+
extension PollWidgetExtraDataChecks on Subject<PollWidgetExtraData> {
20+
Subject<String> get question => has((e) => e.question, 'question');
21+
Subject<List<String>> get options => has((e) => e.options, 'options');
22+
}
23+
24+
extension PollEventChecks on Subject<PollEventSubmessage> {
25+
Subject<PollEventSubmessageType> get type => has((e) => e.type, 'type');
26+
}
27+
28+
extension PollOptionEventChecks on Subject<PollNewOptionEventSubmessage> {
29+
Subject<String> get option => has((e) => e.option, 'option');
30+
}
31+
32+
extension PollQuestionEventChecks on Subject<PollQuestionEventSubmessage> {
33+
Subject<String> get question => has((e) => e.question, 'question');
34+
}
35+
36+
extension PollVoteEventChecks on Subject<PollVoteEventSubmessage> {
37+
Subject<String> get key => has((e) => e.key, 'key');
38+
Subject<PollVoteOp> get op => has((e) => e.op, 'op');
39+
}

0 commit comments

Comments
 (0)