Skip to content

Commit 2043822

Browse files
committed
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 451dad9 commit 2043822

File tree

7 files changed

+381
-3
lines changed

7 files changed

+381
-3
lines changed

lib/api/model/model.dart

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

5+
import '../../log.dart';
36
import 'events.dart';
47
import 'initial_snapshot.dart';
58
import 'reaction.dart';
9+
import 'submessage.dart';
610

711
export 'json.dart' show JsonNullable;
812
export 'reaction.dart';
@@ -481,6 +485,9 @@ sealed class Message {
481485
final String senderRealmStr;
482486
@JsonKey(name: 'subject')
483487
String topic;
488+
/// Poll data if "submessages" describe a poll, `null` otherwise.
489+
@JsonKey(name: 'submessages', readValue: _readPoll, fromJson: _pollFromJson, toJson: _pollToJson)
490+
Poll? poll;
484491
final int timestamp;
485492
String get type;
486493

@@ -512,6 +519,45 @@ sealed class Message {
512519
return list.map((raw) => MessageFlag.fromRawString(raw as String)).toList();
513520
}
514521

522+
static List<Submessage>? _submessagesFromJson(Object? json) {
523+
final list = json as List<Object?>;
524+
return list.isNotEmpty
525+
? list.map((e) => Submessage.fromJson(e as Map<String, Object?>)).toList()
526+
: null;
527+
}
528+
529+
static Poll? _readPoll(Map<Object?, Object?> json, String key) {
530+
final submessages = _submessagesFromJson(json[key]);
531+
// If empty, [submessages] is converted to null.
532+
assert(submessages == null || submessages.isNotEmpty);
533+
if (submessages == null) return null;
534+
535+
final senderId = (json['sender_id'] as num).toInt();
536+
assert(submessages.first.senderId == senderId);
537+
538+
final widgetData = WidgetData.fromJson(jsonDecode(submessages.first.content));
539+
switch (widgetData) {
540+
case PollWidgetData():
541+
return Poll.fromSubmessages(
542+
submessages,
543+
senderId: senderId,
544+
widgetData: widgetData
545+
);
546+
case UnsupportedWidgetData():
547+
assert(debugLog('Unsupported widgetData: ${widgetData.json}'));
548+
return null;
549+
}
550+
}
551+
552+
static Poll? _pollFromJson(Object? json) {
553+
// Simple type cast since [_readPoll] does the heavy-lifting.
554+
return json as Poll?;
555+
}
556+
557+
static List<Submessage> _pollToJson(Poll? poll) {
558+
return [];
559+
}
560+
515561
Message({
516562
required this.client,
517563
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: 127 additions & 1 deletion
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 certain experimental Zulip widgets including polls and todo
@@ -22,7 +26,7 @@ class Submessage {
2226
// We cannot parse the String into one of the [SubmessageData] classes because
2327
// information from other submessages are required. Specifically, we need:
2428
// * the index of this submessage in [Message.submessages];
25-
// * the [WidgetType] of the first [Message.submessages].
29+
// * the parsed [WidgetData] from the first [Message.submessages].
2630
final String content;
2731
// final int messageId; // ignored; redundant with [Message.id]
2832
final int senderId;
@@ -265,3 +269,125 @@ enum PollEventSubmessageType {
265269
static final _byRawString = _$PollEventSubmessageTypeEnumMap
266270
.map((key, value) => MapEntry(value, key));
267271
}
272+
273+
/// States of a poll Zulip widget.
274+
///
275+
/// See also:
276+
/// - https://zulip.com/help/create-a-poll
277+
/// - https://github.com/zulip/zulip/blob/304d948416465c1a085122af5d752f03d6797003/web/shared/src/poll_data.ts
278+
class Poll {
279+
Poll({
280+
required this.pollSenderId,
281+
required this.question,
282+
required final List<String> options,
283+
}) {
284+
for (int index = 0; index < options.length; index += 1) {
285+
// Initial poll options use a placeholder senderId.
286+
// See [PollEventSubmessage.optionKey] for details.
287+
_addOption(null, options[index], optionIndex: index);
288+
}
289+
}
290+
291+
factory Poll.fromSubmessages(
292+
List<Submessage> submessages,
293+
{required int senderId, required PollWidgetData widgetData}
294+
) {
295+
assert(submessages.isNotEmpty);
296+
final widgetEventSubmessages = submessages.skip(1);
297+
298+
final poll = Poll(
299+
pollSenderId: senderId,
300+
question: widgetData.extraData.question,
301+
options: widgetData.extraData.options,
302+
);
303+
304+
for (final submessage in widgetEventSubmessages) {
305+
final event = PollEventSubmessage.fromJson(jsonDecode(submessage.content) as Map<String, Object?>);
306+
poll.applyEvent(submessage.senderId, event);
307+
}
308+
return poll;
309+
}
310+
311+
final int pollSenderId;
312+
String question;
313+
314+
/// The limit of options any single user can add to a poll.
315+
///
316+
/// See https://github.com/zulip/zulip/blob/304d948416465c1a085122af5d752f03d6797003/web/shared/src/poll_data.ts#L69-L71
317+
static const maxOptionIndex = 1000; // TODO validate
318+
319+
Iterable<PollOption> get options => _options.values;
320+
final Set<String> _optionNames = {};
321+
final Map<String, PollOption> _options = {};
322+
323+
void applyEvent(int senderId, PollEventSubmessage event) {
324+
switch (event) {
325+
case PollNewOptionEventSubmessage():
326+
_addOption(
327+
senderId,
328+
event.option,
329+
optionIndex: event.latestOptionIndex,
330+
);
331+
332+
case PollQuestionEventSubmessage():
333+
if (senderId != pollSenderId) {
334+
// Only the message owner can edit the question.
335+
assert(debugLog('unexpected poll data: user $senderId is not allowed to edit the question')); // TODO(log)
336+
return;
337+
}
338+
339+
question = event.question;
340+
341+
case PollVoteEventSubmessage():
342+
final option = _options[event.key];
343+
if (option == null) {
344+
assert(debugLog('vote for unknown key ${event.key}')); // TODO(log)
345+
return;
346+
}
347+
348+
switch (event.op) {
349+
case PollVoteOp.add:
350+
option.voters.add(senderId);
351+
case PollVoteOp.remove:
352+
option.voters.remove(senderId);
353+
case PollVoteOp.unknown:
354+
}
355+
356+
case UnknownPollEventSubmessage():
357+
}
358+
}
359+
360+
void _addOption(int? senderId, String option, {required int optionIndex}) {
361+
if (optionIndex > maxOptionIndex) return;
362+
final key = PollEventSubmessage.optionKey(senderId: senderId, optionIndex: optionIndex);
363+
// The web client suppresses duplicate options, which can be created through
364+
// the /poll command as there is no server-side validation.
365+
if (_optionNames.contains(option)) return;
366+
assert(!_options.containsKey(key));
367+
_options[key] = PollOption(text: option);
368+
_optionNames.add(option);
369+
}
370+
}
371+
372+
class PollOption {
373+
PollOption({required this.text});
374+
375+
factory PollOption.withVoters(String text, Iterable<int> voters) =>
376+
PollOption(text: text)..voters.addAll(voters);
377+
378+
final String text;
379+
final Set<int> voters = {};
380+
381+
@override
382+
bool operator ==(Object other) {
383+
if (other is! PollOption) return false;
384+
385+
return other.hashCode == hashCode;
386+
}
387+
388+
@override
389+
int get hashCode => Object.hash('Option', text, voters.join(','));
390+
391+
@override
392+
String toString() => 'Option(option: $text, voters: {${voters.join(', ')}})';
393+
}

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');
@@ -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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,8 @@ 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+
}

0 commit comments

Comments
 (0)