Skip to content

Commit 4a1e81e

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 0b51072 commit 4a1e81e

File tree

5 files changed

+444
-0
lines changed

5 files changed

+444
-0
lines changed

lib/api/model/submessage.dart

Lines changed: 233 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,231 @@ 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.fromJson(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+
final Object? json;
119+
120+
UnsupportedWidgetData.fromJson(this.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 [idx]'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 idx}) =>
138+
// "canned" is a canonical constant coined by the web client.
139+
'${senderId ?? 'canned'},$idx';
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.fromJson(json);
148+
}
149+
}
150+
151+
Map<String, Object?> toJson();
152+
}
153+
154+
/// As in [PollEventSubmessage.type].
155+
@JsonEnum(fieldRename: FieldRename.snake)
156+
enum PollEventSubmessageType {
157+
newOption,
158+
question,
159+
vote,
160+
unknown;
161+
162+
static PollEventSubmessageType fromRawString(String raw) => _byRawString[raw]!;
163+
164+
static final _byRawString = _$PollEventSubmessageTypeEnumMap
165+
.map((key, value) => MapEntry(value, key));
166+
}
167+
168+
/// A poll event when an option is added.
169+
@JsonSerializable(fieldRename: FieldRename.snake)
170+
class PollNewOptionEventSubmessage extends PollEventSubmessage {
171+
@override
172+
@JsonKey(includeToJson: true)
173+
PollEventSubmessageType get type => PollEventSubmessageType.newOption;
174+
175+
final String option;
176+
/// A sequence number for this option, among options added to this poll
177+
/// by this [Submessage.senderId].
178+
///
179+
/// See [PollEventSubmessage.optionKey].
180+
final int idx;
181+
182+
PollNewOptionEventSubmessage({required this.option, required this.idx});
183+
184+
@override
185+
factory PollNewOptionEventSubmessage.fromJson(Map<String, Object?> json) =>
186+
_$PollNewOptionEventSubmessageFromJson(json);
187+
188+
@override
189+
Map<String, Object?> toJson() => _$PollNewOptionEventSubmessageToJson(this);
190+
}
191+
192+
/// A poll event when the question has been edited.
193+
@JsonSerializable(fieldRename: FieldRename.snake)
194+
class PollQuestionEventSubmessage extends PollEventSubmessage {
195+
@override
196+
@JsonKey(includeToJson: true)
197+
PollEventSubmessageType get type => PollEventSubmessageType.question;
198+
199+
final String question;
200+
201+
PollQuestionEventSubmessage({required this.question});
202+
203+
@override
204+
factory PollQuestionEventSubmessage.fromJson(Map<String, Object?> json) =>
205+
_$PollQuestionEventSubmessageFromJson(json);
206+
207+
@override
208+
Map<String, Object?> toJson() => _$PollQuestionEventSubmessageToJson(this);
209+
}
210+
211+
/// A poll event when a vote has been cast or removed.
212+
@JsonSerializable(fieldRename: FieldRename.snake)
213+
class PollVoteEventSubmessage extends PollEventSubmessage {
214+
@override
215+
@JsonKey(includeToJson: true)
216+
PollEventSubmessageType get type => PollEventSubmessageType.vote;
217+
218+
/// The key of the affected option.
219+
///
220+
/// See [PollEventSubmessage.optionKey].
221+
final String key;
222+
@JsonKey(name: 'vote', unknownEnumValue: PollVoteOp.unknown)
223+
final PollVoteOp op;
224+
225+
PollVoteEventSubmessage({required this.key, required this.op});
226+
227+
@override
228+
factory PollVoteEventSubmessage.fromJson(Map<String, Object?> json) {
229+
final result = _$PollVoteEventSubmessageFromJson(json);
230+
// Crunchy-shell validation
231+
final segments = result.key.split(',');
232+
final [senderId, idx] = segments;
233+
if (senderId != 'canned') {
234+
int.parse(senderId, radix: 10);
235+
}
236+
int.parse(idx, radix: 10);
237+
return result;
238+
}
239+
240+
@override
241+
Map<String, Object?> toJson() => _$PollVoteEventSubmessageToJson(this);
242+
}
243+
244+
/// As in [PollVoteEventSubmessage.op].
245+
@JsonEnum(valueField: 'apiValue')
246+
enum PollVoteOp {
247+
add(apiValue: 1),
248+
remove(apiValue: -1),
249+
unknown(apiValue: null);
250+
251+
const PollVoteOp({required this.apiValue});
252+
253+
final int? apiValue;
254+
255+
int? toJson() => apiValue;
256+
}
257+
258+
class UnknownPollEventSubmessage extends PollEventSubmessage {
259+
@override
260+
@JsonKey(includeToJson: true)
261+
PollEventSubmessageType get type => PollEventSubmessageType.unknown;
262+
263+
final Map<String, Object?> json;
264+
265+
UnknownPollEventSubmessage.fromJson(this.json);
266+
267+
@override
268+
Map<String, Object?> toJson() => json;
269+
}

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)