add poll support

Fixes #130
This commit is contained in:
Henry Hiles 2026-11-02 12:03:52 -05:00
commit f6aaf49164
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
4 changed files with 1041 additions and 144 deletions

View file

@ -17,6 +17,9 @@ import "package:nexus/models/content/topic.dart";
import "package:nexus/models/content/sticker.dart";
import "package:nexus/models/content/history_visibility.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/content/poll_end.dart";
import "package:nexus/models/content/poll_response.dart";
import "package:nexus/models/content/poll_start.dart";
class const Content({final String? parseError, final StackTrace? stackTrace}) {
factory Content.fromJson(Map<String, dynamic> json) => const Content();
@ -64,5 +67,11 @@ enum EventType(
pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson),
imagePack("m.room.image_pack", ImagePackContent.fromJson),
legacyImagePack("im.ponies.room_emotes", ImagePackContent.fromJson),
pollStart("org.matrix.msc3381.poll.start", PollStartContent.fromJson),
pollResponse(
"org.matrix.msc3381.poll.response",
PollResponseContent.fromJson,
),
pollEnd("org.matrix.msc3381.poll.end", PollEndContent.fromJson),
message("m.room.message", MessageContent.fromJson),
}

View file

@ -0,0 +1,12 @@
import "package:nexus/models/content/content.dart";
class const PollEndContent() extends Content {
factory PollEndContent.fromJson(Map<String, dynamic> json) =>
const PollEndContent();
@override
Map<String, dynamic> toJson() => {
"org.matrix.msc3381.poll.end": {},
"org.matrix.msc1767.text": "Ended the poll",
};
}

View file

@ -0,0 +1,23 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/content.dart";
part "poll_response.freezed.dart";
part "poll_response.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(createToJson: false)
class const PollResponseContent({
@JsonKey(readValue: PollResponseContent.answersFromJson)
final IList<String> answers = const .empty(),
}) extends Content with _$PollResponseContent {
static List<dynamic>? answersFromJson(Map<dynamic, dynamic> json, _) =>
json["org.matrix.msc3381.poll.response"]?["answers"];
@override
Map<String, Object?> toJson() => {
"org.matrix.msc3381.poll.response": {"answers": answers.toList()},
};
factory PollResponseContent.fromJson(Map<String, Object?> json) =>
_$PollResponseContentFromJson(json);
}

View file

@ -0,0 +1,90 @@
import "dart:math";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/content.dart";
part "poll_start.freezed.dart";
part "poll_start.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(createToJson: false)
class const PollStartContent({
@JsonKey(name: "org.matrix.msc3381.poll.start") required final PollStart poll,
}) extends Content with _$PollStartContent {
@override
Map<String, Object?> toJson() => {
"org.matrix.msc3381.poll.start": poll.toJson(),
// Shown by clients without poll support
"org.matrix.msc1767.text": [
poll.question,
...poll.answers.mapIndexed(
(index, answer) => "${index + 1}. ${answer.text}",
),
].join("\n"),
};
factory PollStartContent.fromJson(Map<String, Object?> json) =>
_$PollStartContentFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const PollStart({
@JsonKey(readValue: PollStart.questionFromJson) final String question = "",
final int? maxSelections,
@JsonKey(readValue: PollStart.answersFromJson)
final IList<PollAnswer> answers = const .empty(),
}) with _$PollStart {
static const maxAnswers = 20;
static String? extensibleTextFromJson(Object? json) => switch (json) {
{"org.matrix.msc1767.text": final String text} => text,
{"body": final String body} => body,
{"org.matrix.msc1767.message": final List message} =>
message
.whereType<Map>()
.map((part) => part["body"])
.whereType<String>()
.firstOrNull,
_ => null,
};
static String? questionFromJson(Map<dynamic, dynamic> json, String key) =>
PollStart.extensibleTextFromJson(json[key]);
static List<dynamic>? answersFromJson(
Map<dynamic, dynamic> json,
String key,
) => (json[key] as List?)?.take(maxAnswers).toList();
/// [maxSelections], defaulting to 1, and at most the number of answers
int get selectionLimit =>
(maxSelections ?? 1).clamp(1, max(answers.length, 1));
Map<String, Object?> toJson() => {
..._$PollStartToJson(this),
// We don't allow undisclosed polls as they make no sense
"kind": "org.matrix.msc3381.poll.disclosed",
"question": {"org.matrix.msc1767.text": question},
"answers": answers.map((answer) => answer.toJson()).toList(),
};
factory PollStart.fromJson(Map<String, Object?> json) =>
_$PollStartFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(createToJson: false)
class const PollAnswer({
required final String id,
@JsonKey(readValue: PollAnswer.textFromJson) final String text = "",
}) with _$PollAnswer {
static String? textFromJson(Map<dynamic, dynamic> json, _) =>
PollStart.extensibleTextFromJson(json);
Map<String, Object?> toJson() => {"id": id, "org.matrix.msc1767.text": text};
factory PollAnswer.fromJson(Map<String, Object?> json) =>
_$PollAnswerFromJson(json);
}