Skip to content

Commit 5af5c76

Browse files
PIG208gnprice
authored andcommitted
api: Construct polls data store from submessages.
For now, we only consider the case when the submessages describe a poll, and disgard them otherwise. It will be a simple refactor later to support other Zulip widgets like todo lists, by extracting a common ancestors for such widget data structures. The `Poll` data structure will become useful when we support submessage events, where updates to a poll can happen. Some more comments are added here instead of earlier because of their references to `SubmessageData`. Signed-off-by: Zixuan James Li <[email protected]>
1 parent dcdc491 commit 5af5c76

File tree

8 files changed

+319
-2
lines changed

8 files changed

+319
-2
lines changed

lib/api/model/model.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:json_annotation/json_annotation.dart';
33
import 'events.dart';
44
import 'initial_snapshot.dart';
55
import 'reaction.dart';
6+
import 'submessage.dart';
67

78
export 'json.dart' show JsonNullable;
89
export 'reaction.dart';
@@ -533,6 +534,9 @@ sealed class Message {
533534
final String senderRealmStr;
534535
@JsonKey(name: 'subject')
535536
String topic;
537+
/// Poll data if "submessages" describe a poll, `null` otherwise.
538+
@JsonKey(name: 'submessages', readValue: _readPoll, fromJson: Poll.fromJson, toJson: Poll.toJson)
539+
Poll? poll;
536540
final int timestamp;
537541
String get type;
538542

@@ -564,6 +568,13 @@ sealed class Message {
564568
return list.map((raw) => MessageFlag.fromRawString(raw as String)).toList();
565569
}
566570

571+
static Poll? _readPoll(Map<Object?, Object?> json, String key) {
572+
return Submessage.parseSubmessagesJson(
573+
json['submessages'] as List<Object?>? ?? [],
574+
messageSenderId: (json['sender_id'] as num).toInt(),
575+
);
576+
}
577+
567578
Message({
568579
required this.client,
569580
required this.content,

lib/api/model/model.g.dart

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

lib/api/model/submessage.dart

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import 'dart:convert';
2+
13
import 'package:json_annotation/json_annotation.dart';
24

5+
import '../../log.dart';
6+
37
part 'submessage.g.dart';
48

59
/// Data used for Zulip "widgets" within messages, like polls and todo lists.
@@ -41,6 +45,30 @@ class Submessage {
4145
// * the parsed [WidgetType] from the first [Message.submessages].
4246
final String content;
4347

48+
/// Parse a JSON list into a [Poll].
49+
// TODO: Use a generalized return type when supporting other Zulip widgets.
50+
static Poll? parseSubmessagesJson(List<Object?> json, {
51+
required int messageSenderId,
52+
}) {
53+
final submessages = json.map((e) => Submessage.fromJson(e as Map<String, Object?>)).toList();
54+
if (submessages.isEmpty) return null;
55+
56+
assert(submessages.first.senderId == messageSenderId);
57+
58+
final widgetData = WidgetData.fromJson(jsonDecode(submessages.first.content));
59+
switch (widgetData) {
60+
case PollWidgetData():
61+
return Poll.fromSubmessages(
62+
widgetData: widgetData,
63+
pollEventSubmessages: submessages.skip(1),
64+
messageSenderId: messageSenderId,
65+
);
66+
case UnsupportedWidgetData():
67+
assert(debugLog('Unsupported widgetData: ${widgetData.json}'));
68+
return null;
69+
}
70+
}
71+
4472
factory Submessage.fromJson(Map<String, Object?> json) =>
4573
_$SubmessageFromJson(json);
4674

@@ -319,3 +347,126 @@ class UnknownPollEventSubmessage extends PollEventSubmessage {
319347
@override
320348
Map<String, Object?> toJson() => json;
321349
}
350+
351+
/// States of a poll Zulip widget.
352+
///
353+
/// See also:
354+
/// - https://zulip.com/help/create-a-poll
355+
/// - https://github.com/zulip/zulip/blob/304d948416465c1a085122af5d752f03d6797003/web/shared/src/poll_data.ts
356+
class Poll {
357+
/// Construct a poll from submessages.
358+
///
359+
/// For a poll Zulip widget, the first submessage's content contains a
360+
/// [PollWidgetData], and all the following submessages' content each contains
361+
/// a [PollEventSubmessage].
362+
factory Poll.fromSubmessages({
363+
required PollWidgetData widgetData,
364+
required Iterable<Submessage> pollEventSubmessages,
365+
required int messageSenderId,
366+
}) {
367+
final poll = Poll._(
368+
messageSenderId: messageSenderId,
369+
question: widgetData.extraData.question,
370+
options: widgetData.extraData.options,
371+
);
372+
373+
for (final submessage in pollEventSubmessages) {
374+
final event = PollEventSubmessage.fromJson(jsonDecode(submessage.content) as Map<String, Object?>);
375+
poll._applyEvent(submessage.senderId, event);
376+
}
377+
return poll;
378+
}
379+
380+
Poll._({
381+
required this.messageSenderId,
382+
required this.question,
383+
required List<String> options,
384+
}) {
385+
for (int index = 0; index < options.length; index += 1) {
386+
// Initial poll options use a placeholder senderId.
387+
// See [PollEventSubmessage.optionKey] for details.
388+
_addOption(senderId: null, idx: index, option: options[index]);
389+
}
390+
}
391+
392+
final int messageSenderId;
393+
String question;
394+
395+
/// The limit of options any single user can add to a poll.
396+
///
397+
/// See https://github.com/zulip/zulip/blob/304d948416465c1a085122af5d752f03d6797003/web/shared/src/poll_data.ts#L69-L71
398+
static const _maxIdx = 1000;
399+
400+
Iterable<PollOption> get options => _options.values;
401+
/// Contains the text of all options from [_options].
402+
final Set<String> _existingOptionTexts = {};
403+
final Map<PollOptionKey, PollOption> _options = {};
404+
405+
void _applyEvent(int senderId, PollEventSubmessage event) {
406+
switch (event) {
407+
case PollNewOptionEventSubmessage():
408+
_addOption(senderId: senderId, idx: event.idx, option: event.option);
409+
410+
case PollQuestionEventSubmessage():
411+
if (senderId != messageSenderId) {
412+
// Only the message owner can edit the question.
413+
assert(debugLog('unexpected poll data: user $senderId is not allowed to edit the question')); // TODO(log)
414+
return;
415+
}
416+
417+
question = event.question;
418+
419+
case PollVoteEventSubmessage():
420+
final option = _options[event.key];
421+
if (option == null) {
422+
assert(debugLog('vote for unknown key ${event.key}')); // TODO(log)
423+
return;
424+
}
425+
426+
switch (event.op) {
427+
case PollVoteOp.add:
428+
option.voters.add(senderId);
429+
case PollVoteOp.remove:
430+
option.voters.remove(senderId);
431+
case PollVoteOp.unknown:
432+
assert(debugLog('unknown vote op ${event.op}')); // TODO(log)
433+
}
434+
435+
case UnknownPollEventSubmessage():
436+
}
437+
}
438+
439+
void _addOption({required int? senderId, required int idx, required String option}) {
440+
if (idx > _maxIdx || idx < 0) return;
441+
442+
// The web client suppresses duplicate options, which can be created through
443+
// the /poll command as there is no server-side validation.
444+
if (_existingOptionTexts.contains(option)) return;
445+
446+
final key = PollEventSubmessage.optionKey(senderId: senderId, idx: idx);
447+
assert(!_options.containsKey(key));
448+
_options[key] = PollOption(text: option);
449+
_existingOptionTexts.add(option);
450+
}
451+
452+
static Poll? fromJson(Object? json) {
453+
// [Submessage.parseSubmessagesJson] does all the heavy lifting for parsing.
454+
return json as Poll?;
455+
}
456+
457+
static List<Submessage> toJson(Poll? poll) {
458+
// Rather than maintaining a up-to-date submessages list, return as if it is
459+
// empty, because we are not sending the submessages to the server anyway.
460+
return [];
461+
}
462+
}
463+
464+
class PollOption {
465+
PollOption({required this.text});
466+
467+
final String text;
468+
final Set<int> voters = {};
469+
470+
@override
471+
String toString() => 'PollOption(text: $text, voters: {${voters.join(', ')}})';
472+
}

test/api/model/model_checks.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:checks/checks.dart';
22
import 'package:zulip/api/model/model.dart';
3+
import 'package:zulip/api/model/submessage.dart';
34

45
extension UserChecks on Subject<User> {
56
Subject<int> get userId => has((x) => x.userId, 'userId');
@@ -39,6 +40,7 @@ extension MessageChecks on Subject<Message> {
3940
Subject<int> get senderId => has((e) => e.senderId, 'senderId');
4041
Subject<String> get senderRealmStr => has((e) => e.senderRealmStr, 'senderRealmStr');
4142
Subject<String> get topic => has((e) => e.topic, 'topic');
43+
Subject<Poll?> get poll => has((e) => e.poll, 'poll');
4244
Subject<int> get timestamp => has((e) => e.timestamp, 'timestamp');
4345
Subject<String> get type => has((e) => e.type, 'type');
4446
Subject<List<MessageFlag>> get flags => has((e) => e.flags, 'flags');

test/api/model/submessage_checks.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,13 @@ extension PollVoteEventChecks on Subject<PollVoteEventSubmessage> {
3737
Subject<String> get key => has((e) => e.key, 'key');
3838
Subject<PollVoteOp> get op => has((e) => e.op, 'op');
3939
}
40+
41+
extension PollChecks on Subject<Poll> {
42+
Subject<String> get question => has((e) => e.question, 'question');
43+
Subject<Iterable<PollOption>> get options => has((e) => e.options, 'options');
44+
}
45+
46+
extension PollOptionChecks on Subject<PollOption> {
47+
Subject<String> get text => has((e) => e.text, 'text');
48+
Subject<Set<int>> get voters => has((e) => e.voters, 'voters');
49+
}

test/api/model/submessage_test.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,7 @@ void main() {
114114
'key': PollEventSubmessage.optionKey(senderId: null, idx: 0)
115115
})).isA<PollVoteEventSubmessage>().op.equals(PollVoteOp.unknown);
116116
});
117+
118+
// Parsing polls with PollEventSubmessages are tested in
119+
// `test/model/message_test.dart` in the "handleSubmessageEvent" test.
117120
}

test/example_data.dart

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'dart:convert';
12
import 'dart:math';
23

34
import 'package:zulip/api/model/events.dart';
@@ -343,6 +344,7 @@ StreamMessage streamMessage({
343344
List<Reaction>? reactions,
344345
int? timestamp,
345346
List<MessageFlag>? flags,
347+
List<Submessage>? submessages,
346348
}) {
347349
_checkPositive(id, 'message ID');
348350
final effectiveStream = stream ?? _stream(streamId: defaultStreamMessageStreamId);
@@ -362,6 +364,7 @@ StreamMessage streamMessage({
362364
'id': id ?? _nextMessageId(),
363365
'last_edit_timestamp': lastEditTimestamp,
364366
'subject': topic ?? 'example topic',
367+
'submessages': submessages ?? [],
365368
'timestamp': timestamp ?? 1678139636,
366369
'type': 'stream',
367370
}) as Map<String, dynamic>);
@@ -386,6 +389,7 @@ DmMessage dmMessage({
386389
int? lastEditTimestamp,
387390
int? timestamp,
388391
List<MessageFlag>? flags,
392+
List<Submessage>? submessages,
389393
}) {
390394
_checkPositive(id, 'message ID');
391395
assert(!to.any((user) => user.userId == from.userId));
@@ -401,6 +405,7 @@ DmMessage dmMessage({
401405
'id': id ?? _nextMessageId(),
402406
'last_edit_timestamp': lastEditTimestamp,
403407
'subject': '',
408+
'submessages': submessages ?? [],
404409
'timestamp': timestamp ?? 1678139636,
405410
'type': 'private',
406411
}) as Map<String, dynamic>);
@@ -414,6 +419,21 @@ PollWidgetData pollWidgetData({
414419
extraData: PollWidgetExtraData(question: question, options: options));
415420
}
416421

422+
Submessage submessage({
423+
SubmessageType? msgType,
424+
required SubmessageData? content,
425+
int? senderId,
426+
}) {
427+
return Submessage(
428+
msgType: msgType ?? SubmessageType.widget,
429+
content: jsonEncode(content),
430+
senderId: senderId ?? selfUser.userId,
431+
);
432+
}
433+
434+
PollOption pollOption({required String text, required Iterable<int> voters}) =>
435+
PollOption(text: text)..voters.addAll(voters);
436+
417437
////////////////////////////////////////////////////////////////
418438
// Aggregate data structures.
419439
//

0 commit comments

Comments
 (0)