Skip to content

Commit 144d5af

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 06ce603 commit 144d5af

File tree

7 files changed

+382
-3
lines changed

7 files changed

+382
-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: 128 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 Zulip "widgets" within messages, like polls and todo lists.
@@ -38,7 +42,7 @@ class Submessage {
3842
// We cannot parse the String into one of the [SubmessageData] classes because
3943
// information from other submessages are required. Specifically, we need:
4044
// * the index of this submessage in [Message.submessages];
41-
// * the [WidgetType] of the first [Message.submessages].
45+
// * the parsed [WidgetData] from the first [Message.submessages].
4246
final String content;
4347

4448
factory Submessage.fromJson(Map<String, Object?> json) =>
@@ -313,3 +317,126 @@ class UnknownPollEventSubmessage extends PollEventSubmessage {
313317
@override
314318
Map<String, Object?> toJson() => json;
315319
}
320+
321+
/// States of a poll Zulip widget.
322+
///
323+
/// See also:
324+
/// - https://zulip.com/help/create-a-poll
325+
/// - https://github.com/zulip/zulip/blob/304d948416465c1a085122af5d752f03d6797003/web/shared/src/poll_data.ts
326+
class Poll {
327+
Poll({
328+
required this.pollSenderId,
329+
required this.question,
330+
required final List<String> options,
331+
}) {
332+
for (int index = 0; index < options.length; index += 1) {
333+
// Initial poll options use a placeholder senderId.
334+
// See [PollEventSubmessage.optionKey] for details.
335+
_addOption(null, options[index], idx: index);
336+
}
337+
}
338+
339+
factory Poll.fromSubmessages(
340+
List<Submessage> submessages,
341+
{required int senderId, required PollWidgetData widgetData}
342+
) {
343+
assert(submessages.isNotEmpty);
344+
final widgetEventSubmessages = submessages.skip(1);
345+
346+
final poll = Poll(
347+
pollSenderId: senderId,
348+
question: widgetData.extraData.question,
349+
options: widgetData.extraData.options,
350+
);
351+
352+
for (final submessage in widgetEventSubmessages) {
353+
final event = PollEventSubmessage.fromJson(jsonDecode(submessage.content) as Map<String, Object?>);
354+
poll.applyEvent(submessage.senderId, event);
355+
}
356+
return poll;
357+
}
358+
359+
final int pollSenderId;
360+
String question;
361+
362+
/// The limit of options any single user can add to a poll.
363+
///
364+
/// See https://github.com/zulip/zulip/blob/304d948416465c1a085122af5d752f03d6797003/web/shared/src/poll_data.ts#L69-L71
365+
static const maxIdx = 1000; // TODO validate
366+
367+
Iterable<PollOption> get options => _options.values;
368+
final Set<String> _optionNames = {};
369+
final Map<String, PollOption> _options = {};
370+
371+
void applyEvent(int senderId, PollEventSubmessage event) {
372+
switch (event) {
373+
case PollNewOptionEventSubmessage():
374+
_addOption(
375+
senderId,
376+
event.option,
377+
idx: event.idx,
378+
);
379+
380+
case PollQuestionEventSubmessage():
381+
if (senderId != pollSenderId) {
382+
// Only the message owner can edit the question.
383+
assert(debugLog('unexpected poll data: user $senderId is not allowed to edit the question')); // TODO(log)
384+
return;
385+
}
386+
387+
question = event.question;
388+
389+
case PollVoteEventSubmessage():
390+
final option = _options[event.key];
391+
if (option == null) {
392+
assert(debugLog('vote for unknown key ${event.key}')); // TODO(log)
393+
return;
394+
}
395+
396+
switch (event.op) {
397+
case PollVoteOp.add:
398+
option.voters.add(senderId);
399+
case PollVoteOp.remove:
400+
option.voters.remove(senderId);
401+
case PollVoteOp.unknown:
402+
assert(debugLog('unknown vote op ${event.op}')); // TODO(log)
403+
}
404+
405+
case UnknownPollEventSubmessage():
406+
}
407+
}
408+
409+
void _addOption(int? senderId, String option, {required int idx}) {
410+
if (idx > maxIdx) return;
411+
final key = PollEventSubmessage.optionKey(senderId: senderId, idx: idx);
412+
// The web client suppresses duplicate options, which can be created through
413+
// the /poll command as there is no server-side validation.
414+
if (_optionNames.contains(option)) return;
415+
assert(!_options.containsKey(key));
416+
_options[key] = PollOption(text: option);
417+
_optionNames.add(option);
418+
}
419+
}
420+
421+
class PollOption {
422+
PollOption({required this.text});
423+
424+
factory PollOption.withVoters(String text, Iterable<int> voters) =>
425+
PollOption(text: text)..voters.addAll(voters);
426+
427+
final String text;
428+
final Set<int> voters = {};
429+
430+
@override
431+
bool operator ==(Object other) {
432+
if (other is! PollOption) return false;
433+
434+
return other.hashCode == hashCode;
435+
}
436+
437+
@override
438+
int get hashCode => Object.hash('Option', text, voters.join(','));
439+
440+
@override
441+
String toString() => 'Option(option: $text, voters: {${voters.join(', ')}})';
442+
}

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)