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
9 changed files with 1041 additions and 144 deletions

View file

@ -0,0 +1,16 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "event.freezed.dart";
part "event.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const EventConfig({
required final String roomId,
required final int eventRowId,
}) with _$EventConfig {
Map<String, Object?> toJson() => _$EventConfigToJson(this);
factory EventConfig.fromJson(Map<String, Object?> json) =>
_$EventConfigFromJson(json);
}

View file

@ -1,16 +0,0 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "reactions.freezed.dart";
part "reactions.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const ReactionsConfig({
required final String roomId,
required final int eventRowId,
}) with _$ReactionsConfig {
Map<String, Object?> toJson() => _$ReactionsConfigToJson(this);
factory ReactionsConfig.fromJson(Map<String, Object?> json) =>
_$ReactionsConfigFromJson(json);
}

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);
}

View file

@ -0,0 +1,25 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
class const PollVotes({
/// Each user's answers, by user ID
final IMap<String, IList<String>> votes = const .empty(),
final String? endEventId,
final int undecryptable = 0,
}) {
bool get hasEnded => endEventId != null;
IMap<String, IList<String>> get votersByAnswer => votes.entries.fold(
const .empty(),
(acc, entry) => entry.value.fold(
acc,
(acc, answer) => acc.update(
answer,
(voters) => voters.add(entry.key),
ifAbsent: () => .new([entry.key]),
),
),
);
int get voterCount =>
votes.values.where((answers) => answers.isNotEmpty).length;
}

View file

@ -24,6 +24,14 @@ class const Room({
@JsonKey(fromJson: Room.eventsJsonToIMap)
final IMap<int, Event> events = const .empty(),
/// [relations] is an IMap of event ID to the eventRowIds relating to it
@JsonKey(includeFromJson: false, includeToJson: false)
final IMap<String, ISet<int>> relations = const .empty(),
/// [eventIds] is an IMap of event ID to eventRowId
@JsonKey(includeFromJson: false, includeToJson: false)
final IMap<String, int> eventIds = const .empty(),
final bool reset = false,
@JsonKey(includeFromJson: false, includeToJson: false)
final bool resetClientSticky = false,

View file

@ -8,4 +8,7 @@ class const RoomPowerLevels({
double levelOf(String userId) => creators.contains(userId)
? double.infinity
: (content.users[userId] ?? content.usersDefault).toDouble();
bool canEndPoll(String userId, {required String pollSender}) =>
userId == pollSender || levelOf(userId) >= content.redact;
}