Skip to content

Commit 2eba1fb

Browse files
committed
api: Try parse polls 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. Signed-off-by: Zixuan James Li <[email protected]>
1 parent 292fa42 commit 2eba1fb

File tree

8 files changed

+319
-6
lines changed

8 files changed

+319
-6
lines changed

lib/api/model/model.dart

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

79
export 'json.dart' show JsonNullable;
810
export 'reaction.dart';
@@ -481,6 +483,9 @@ sealed class Message {
481483
final String senderRealmStr;
482484
@JsonKey(name: 'subject')
483485
final String topic;
486+
/// Poll data if "submessages" describe a poll, `null` otherwise.
487+
@JsonKey(name: 'submessages', readValue: _readPoll, fromJson: _pollFromJson, toJson: _pollToJson)
488+
Poll? poll;
484489
final int timestamp;
485490
String get type;
486491

@@ -512,6 +517,46 @@ sealed class Message {
512517
return list.map((raw) => MessageFlag.fromRawString(raw as String)).toList();
513518
}
514519

520+
static List<Submessage>? _submessagesFromJson(Object? json) {
521+
final list = json as List<Object?>;
522+
return list.isNotEmpty
523+
? list.map((e) => Submessage.fromJson(e as Map<String, Object?>)).toList()
524+
: null;
525+
}
526+
527+
static Poll? _readPoll(Map<Object?, Object?> json, String key) {
528+
final submessages = _submessagesFromJson(json[key]);
529+
// If empty, [submessages] is converted to null.
530+
assert(submessages == null || submessages.isNotEmpty);
531+
if (submessages == null) return null;
532+
533+
final messageId = (json['id'] as num).toInt();
534+
assert(submessages.every((e) => e.messageId == messageId));
535+
536+
final senderId = (json['sender_id'] as num).toInt();
537+
assert(submessages.first.senderId == senderId);
538+
539+
final widgetData = WidgetData.fromJson(submessages.first.content);
540+
switch (widgetData) {
541+
case PollWidgetData():
542+
return Poll.fromSubmessages(
543+
submessages,
544+
senderId: senderId,
545+
widgetData: widgetData
546+
);
547+
case UnsupportedWidgetData():
548+
}
549+
return null;
550+
}
551+
552+
static Poll? _pollFromJson(Object? json) {
553+
return json as Poll?;
554+
}
555+
556+
static Object? _pollToJson(Poll? poll) {
557+
return null;
558+
}
559+
515560
Message({
516561
required this.client,
517562
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.g.dart

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

lib/api/model/widget.dart

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import 'submessage.dart';
2+
3+
/// States of a poll Zulip widget.
4+
///
5+
/// See also:
6+
/// - https://zulip.com/help/create-a-poll
7+
class Poll {
8+
Poll({
9+
required this.senderId,
10+
required this.question,
11+
required final List<String> options,
12+
}) {
13+
for (int index = 0; index < options.length; index += 1) {
14+
_addOption(null, options[index], optionIndex: index);
15+
}
16+
}
17+
18+
factory Poll.fromSubmessages(
19+
List<Submessage> submessages,
20+
{required int senderId, required PollWidgetData widgetData}
21+
) {
22+
assert(submessages.isNotEmpty);
23+
final widgetEventSubmessages = submessages.sublist(1);
24+
25+
final poll = Poll(
26+
senderId: senderId,
27+
question: widgetData.extraData.question,
28+
options: widgetData.extraData.options,
29+
);
30+
31+
for (final submessage in widgetEventSubmessages) {
32+
final event = PollEvent.fromJson(submessage.content as Map<String, Object?>);
33+
poll.applyEvent(submessage.senderId, event);
34+
}
35+
return poll;
36+
}
37+
38+
final int senderId;
39+
String question;
40+
41+
Iterable<Option> get options => _options.values;
42+
final Map<String, Option> _options = {};
43+
44+
void applyEvent(int senderId, PollEvent event) {
45+
switch (event) {
46+
case PollOptionEvent():
47+
_addOption(
48+
senderId,
49+
event.option,
50+
optionIndex: event.latestOptionIndex,
51+
);
52+
53+
case PollQuestionEvent():
54+
question = event.question;
55+
56+
case PollVoteEvent():
57+
final option = _options[event.key];
58+
if (option == null) return;
59+
60+
switch (event.op) {
61+
case VoteOp.add:
62+
option.voters.add(senderId);
63+
case VoteOp.remove:
64+
option.voters.remove(senderId);
65+
case VoteOp.unknown:
66+
}
67+
68+
case UnknownPollEvent():
69+
}
70+
}
71+
72+
void _addOption(int? senderId, String option, {required int optionIndex}) {
73+
_options[PollEvent.optionKey(
74+
senderId: senderId, optionIndex: optionIndex)] = Option(text: option);
75+
}
76+
}
77+
78+
class Option {
79+
Option({required this.text});
80+
81+
factory Option.withVoters(String text, Iterable<int> voters) =>
82+
Option(text: text)..voters.addAll(voters);
83+
84+
final String text;
85+
final Set<int> voters = {};
86+
87+
@override
88+
bool operator ==(Object other) {
89+
if (other is! Option) return false;
90+
91+
return other.hashCode == hashCode;
92+
}
93+
94+
@override
95+
int get hashCode => Object.hash('Option', text, voters.join(','));
96+
97+
@override
98+
String toString() => 'Option(option: $text, voters: {${voters.join(', ')}})';
99+
}

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/widget.dart';
34

45
extension UserChecks on Subject<User> {
56
Subject<int> get userId => has((x) => x.userId, 'userId');
@@ -42,6 +43,7 @@ extension MessageChecks on Subject<Message> {
4243
Subject<int> get senderId => has((e) => e.senderId, 'senderId');
4344
Subject<String> get senderRealmStr => has((e) => e.senderRealmStr, 'senderRealmStr');
4445
Subject<String> get topic => has((e) => e.topic, 'topic');
46+
Subject<Poll?> get poll => has((e) => e.poll, 'poll');
4547
Subject<int> get timestamp => has((e) => e.timestamp, 'timestamp');
4648
Subject<String> get type => has((e) => e.type, 'type');
4749
Subject<List<MessageFlag>> get flags => has((e) => e.flags, 'flags');

test/api/model/submessage_checks.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
21
import 'package:checks/checks.dart';
32
import 'package:zulip/api/model/submessage.dart';
3+
import 'package:zulip/api/model/widget.dart';
44

55
extension SubmessageChecks on Subject<Submessage> {
66
Subject<SubmessageType> get msgType => has((e) => e.msgType, 'msgType');
@@ -10,6 +10,11 @@ extension SubmessageChecks on Subject<Submessage> {
1010
Subject<int> get id => has((e) => e.id, 'id');
1111
}
1212

13+
extension PollChecks on Subject<Poll> {
14+
Subject<String> get question => has((e) => e.question, 'question');
15+
Subject<Iterable<Option>> get options => has((e) => e.options, 'options');
16+
}
17+
1318
extension WidgetDataChecks on Subject<WidgetData> {
1419
Subject<WidgetType> get widgetType => has((e) => e.widgetType, 'widgetType');
1520
}

0 commit comments

Comments
 (0)