From 32a9f5358778188d3ccfccb8fcb3a0b56eb01172 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Sun, 2 Nov 2025 09:42:32 +0100 Subject: [PATCH] feat: Implement msc 3381 polls --- lib/fake_matrix_api.dart | 4 + lib/matrix.dart | 3 + lib/msc_extensions/msc_3381_polls/README.md | 45 ++++++ .../models/poll_event_content.dart | 103 ++++++++++++++ .../msc_3381_polls/poll_event_extension.dart | 132 ++++++++++++++++++ .../msc_3381_polls/poll_room_extension.dart | 39 ++++++ lib/src/utils/event_localizations.dart | 4 + .../utils/matrix_default_localizations.dart | 6 + lib/src/utils/matrix_localizations.dart | 4 + test/msc_extensions/msc_3881_polls_test.dart | 123 ++++++++++++++++ 10 files changed, 463 insertions(+) create mode 100644 lib/msc_extensions/msc_3381_polls/README.md create mode 100644 lib/msc_extensions/msc_3381_polls/models/poll_event_content.dart create mode 100644 lib/msc_extensions/msc_3381_polls/poll_event_extension.dart create mode 100644 lib/msc_extensions/msc_3381_polls/poll_room_extension.dart create mode 100644 test/msc_extensions/msc_3881_polls_test.dart diff --git a/lib/fake_matrix_api.dart b/lib/fake_matrix_api.dart index 5dffda6d..e7f144bb 100644 --- a/lib/fake_matrix_api.dart +++ b/lib/fake_matrix_api.dart @@ -2679,6 +2679,10 @@ class FakeMatrixApi extends BaseClient { (var req) => {'event_id': '1234'}, '/client/v3/rooms/!1234%3Aexample.com/redact/1143273582443PhrSn%3Aexample.org/1234': (var req) => {'event_id': '1234'}, + '/client/v3/rooms/!696r7674%3Aexample.com/send/org.matrix.msc3381.poll.start/1234': + (var req) => {'event_id': '1234'}, + '/client/v3/rooms/!696r7674%3Aexample.com/send/org.matrix.msc3381.poll.response/1234': + (var req) => {'event_id': '1234'}, '/client/v3/pushrules/global/room/!localpart%3Aserver.abc': (var req) => {}, '/client/v3/pushrules/global/override/.m.rule.master/enabled': diff --git a/lib/matrix.dart b/lib/matrix.dart index 8ad93add..1f82309b 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -82,6 +82,9 @@ export 'msc_extensions/msc_2835_uia_login/msc_2835_uia_login.dart'; export 'msc_extensions/msc_3814_dehydrated_devices/msc_3814_dehydrated_devices.dart'; export 'msc_extensions/extension_timeline_export/timeline_export.dart'; export 'msc_extensions/msc_4140_delayed_events/api.dart'; +export 'msc_extensions/msc_3381_polls/models/poll_event_content.dart'; +export 'msc_extensions/msc_3381_polls/poll_event_extension.dart'; +export 'msc_extensions/msc_3381_polls/poll_room_extension.dart'; export 'src/utils/web_worker/web_worker_stub.dart' if (dart.library.js_interop) 'src/utils/web_worker/web_worker.dart'; diff --git a/lib/msc_extensions/msc_3381_polls/README.md b/lib/msc_extensions/msc_3381_polls/README.md new file mode 100644 index 00000000..4a3b1bf1 --- /dev/null +++ b/lib/msc_extensions/msc_3381_polls/README.md @@ -0,0 +1,45 @@ +# Polls + +Implementation of [MSC-3381](https://github.com/matrix-org/matrix-spec-proposals/blob/main/proposals/3381-polls.md). + +```Dart + +// Start a poll: +final pollEventId = await room.startPoll( + question: 'What do you like more?', + kind: PollKind.undisclosed, + maxSelections: 2, + answers: [ + PollAnswer( + id: 'pepsi', // You should use `Client.generateUniqueTransactionId()` here + mText: 'Pepsi, + ), + PollAnswer( + id: 'coca', + mText: 'Coca Cola, + ), + ]; +); + +// Check if an event is a poll (Do this before performing any other action): +final isPoll = event.type == PollEventContent.startType; + +// Get the poll content +final pollEventContent = event.parsedPollEventContent; + +// Check if poll has not ended yet (do this before answerPoll or endPoll): +final hasEnded = event.getPollHasBeenEnded(timeline); + +// Responde to a poll: +final respondeId = await event.answerPoll(['pepsi', 'coca']); + +// Get poll responses: +final responses = event.getPollResponses(timeline); + +for(final userId in responses.keys) { + print('$userId voted for ${responses[userId]}'); +} + +// End poll: +final endPollId = await event.endPoll(); +``` \ No newline at end of file diff --git a/lib/msc_extensions/msc_3381_polls/models/poll_event_content.dart b/lib/msc_extensions/msc_3381_polls/models/poll_event_content.dart new file mode 100644 index 00000000..9ad9cdd4 --- /dev/null +++ b/lib/msc_extensions/msc_3381_polls/models/poll_event_content.dart @@ -0,0 +1,103 @@ +import 'package:collection/collection.dart'; + +class PollEventContent { + final String mText; + final PollStartContent pollStartContent; + + const PollEventContent({ + required this.mText, + required this.pollStartContent, + }); + static const String mTextJsonKey = 'org.matrix.msc1767.text'; + static const String startType = 'org.matrix.msc3381.poll.start'; + static const String responseType = 'org.matrix.msc3381.poll.response'; + static const String endType = 'org.matrix.msc3381.poll.end'; + + factory PollEventContent.fromJson(Map json) => + PollEventContent( + mText: json[mTextJsonKey], + pollStartContent: PollStartContent.fromJson(json[startType]), + ); + + Map toJson() => { + mTextJsonKey: mText, + startType: pollStartContent.toJson(), + }; +} + +class PollStartContent { + final PollKind? kind; + final int maxSelections; + final PollQuestion question; + final List answers; + + const PollStartContent({ + this.kind, + required this.maxSelections, + required this.question, + required this.answers, + }); + + factory PollStartContent.fromJson(Map json) => + PollStartContent( + kind: PollKind.values + .singleWhereOrNull((kind) => kind.name == json['kind']), + maxSelections: json['max_selections'], + question: PollQuestion.fromJson(json['question']), + answers: (json['answers'] as List) + .map((i) => PollAnswer.fromJson(i)) + .toList(), + ); + + Map toJson() => { + if (kind != null) 'kind': kind?.name, + 'max_selections': maxSelections, + 'question': question.toJson(), + 'answers': answers.map((i) => i.toJson()).toList(), + }; +} + +class PollQuestion { + final String mText; + + const PollQuestion({ + required this.mText, + }); + + factory PollQuestion.fromJson(Map json) => PollQuestion( + mText: json[PollEventContent.mTextJsonKey] ?? json['body'], + ); + + Map toJson() => { + PollEventContent.mTextJsonKey: mText, + // Compatible with older Element versions + 'msgtype': 'm.text', + 'body': mText, + }; +} + +class PollAnswer { + final String id; + final String mText; + + const PollAnswer({required this.id, required this.mText}); + + factory PollAnswer.fromJson(Map json) => PollAnswer( + id: json['id'] as String, + mText: json[PollEventContent.mTextJsonKey] as String, + ); + + Map toJson() => { + 'id': id, + PollEventContent.mTextJsonKey: mText, + }; +} + +enum PollKind { + disclosed('org.matrix.msc3381.poll.disclosed'), + undisclosed('org.matrix.msc3381.poll.undisclosed'); + + const PollKind(this.name); + + final String name; +} diff --git a/lib/msc_extensions/msc_3381_polls/poll_event_extension.dart b/lib/msc_extensions/msc_3381_polls/poll_event_extension.dart new file mode 100644 index 00000000..6c49e386 --- /dev/null +++ b/lib/msc_extensions/msc_3381_polls/poll_event_extension.dart @@ -0,0 +1,132 @@ +import 'package:collection/collection.dart'; + +import 'package:matrix/matrix.dart'; + +extension PollEventExtension on Event { + PollEventContent get parsedPollEventContent { + assert(type == PollEventContent.startType); + return PollEventContent.fromJson(content); + } + + /// Returns a Map of user IDs to a Set of answer IDs. + Map> getPollResponses(Timeline timeline) { + assert(type == PollEventContent.startType); + final aggregatedEvents = timeline.aggregatedEvents[eventId] + ?[RelationshipTypes.reference] + ?.toList(); + if (aggregatedEvents == null || aggregatedEvents.isEmpty) return {}; + aggregatedEvents + .removeWhere((event) => event.type != PollEventContent.responseType); + + final responses = {}; + + final endPollEvent = _getEndPollEvent(timeline); + + for (final event in aggregatedEvents) { + // Ignore older responses if we already have a newer one: + final existingEvent = responses[event.senderId]; + if (existingEvent != null && + existingEvent.originServerTs.isAfter(event.originServerTs)) { + continue; + } + // Ignore all responses sent **after** the poll end event: + if (endPollEvent != null && + event.originServerTs.isAfter(endPollEvent.originServerTs)) { + continue; + } + responses[event.senderId] = event; + } + return responses.map( + (userId, event) => MapEntry( + userId, + event.content + .tryGetMap(PollEventContent.responseType) + ?.tryGetList('answers') + ?.toSet() ?? + {}, + ), + ); + } + + Event? _getEndPollEvent(Timeline timeline) { + assert(type == PollEventContent.startType); + final aggregatedEvents = + timeline.aggregatedEvents[eventId]?[RelationshipTypes.reference]; + if (aggregatedEvents == null || aggregatedEvents.isEmpty) return null; + + final redactPowerLevel = (room + .getState(EventTypes.RoomPowerLevels) + ?.content + .tryGet('redact') ?? + 50); + + return aggregatedEvents.firstWhereOrNull( + (event) { + if (event.content + .tryGetMap(PollEventContent.endType) == + null) { + return false; + } + + // If a m.poll.end event is received from someone other than the poll + //creator or user with permission to redact other's messages in the + //room, the event must be ignored by clients due to being invalid. + if (event.senderId == senderId || + event.senderFromMemoryOrFallback.powerLevel >= redactPowerLevel) { + return true; + } + Logs().w( + 'Ignore poll end event form user without permission ${event.senderId}', + ); + return false; + }, + ); + } + + bool getPollHasBeenEnded(Timeline timeline) => + _getEndPollEvent(timeline) != null; + + Future answerPoll( + List answerIds, { + String? txid, + }) { + if (type != PollEventContent.startType) { + throw Exception('Event is not a poll.'); + } + if (answerIds.length > + parsedPollEventContent.pollStartContent.maxSelections) { + throw Exception('Selected more answers than allowed in this poll.'); + } + return room.sendEvent( + { + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': eventId, + }, + PollEventContent.responseType: {'answers': answerIds}, + }, + type: PollEventContent.responseType, + txid: txid, + ); + } + + Future endPoll({String? txid}) { + if (type != PollEventContent.startType) { + throw Exception('Event is not a poll.'); + } + if (senderId != room.client.userID) { + throw Exception('You can not end a poll created by someone else.'); + } + return room.sendEvent( + { + 'm.relates_to': { + 'rel_type': RelationshipTypes.reference, + 'event_id': eventId, + }, + PollEventContent.endType: {}, + }, + type: PollEventContent.endType, + txid: txid, + ); + } +} diff --git a/lib/msc_extensions/msc_3381_polls/poll_room_extension.dart b/lib/msc_extensions/msc_3381_polls/poll_room_extension.dart new file mode 100644 index 00000000..069b0521 --- /dev/null +++ b/lib/msc_extensions/msc_3381_polls/poll_room_extension.dart @@ -0,0 +1,39 @@ +import 'package:matrix/matrix.dart'; + +extension PollRoomExtension on Room { + Future startPoll({ + required String question, + required List answers, + String? body, + PollKind kind = PollKind.undisclosed, + int maxSelections = 1, + String? txid, + }) async { + if (answers.length > 20) { + throw Exception('Client must not set more than 20 answers in a poll'); + } + + if (body == null) { + body = question; + for (var i = 0; i < answers.length; i++) { + body = '$body\n$i. ${answers[i].mText}'; + } + } + + final newPollEvent = PollEventContent( + mText: body!, + pollStartContent: PollStartContent( + kind: kind, + maxSelections: maxSelections, + question: PollQuestion(mText: question), + answers: answers, + ), + ); + + return sendEvent( + newPollEvent.toJson(), + type: PollEventContent.startType, + txid: txid, + ); + } +} diff --git a/lib/src/utils/event_localizations.dart b/lib/src/utils/event_localizations.dart index 4f19972e..3ddff085 100644 --- a/lib/src/utils/event_localizations.dart +++ b/lib/src/utils/event_localizations.dart @@ -300,5 +300,9 @@ abstract class EventLocalizations { body, ), EventTypes.refreshingLastEvent: (_, i18n, ___) => i18n.refreshingLastEvent, + PollEventContent.startType: (event, i18n, body) => i18n.startedAPoll( + event.senderFromMemoryOrFallback.calcDisplayname(i18n: i18n), + ), + PollEventContent.endType: (event, i18n, body) => i18n.pollHasBeenEnded, }; } diff --git a/lib/src/utils/matrix_default_localizations.dart b/lib/src/utils/matrix_default_localizations.dart index c9076777..93ed4e57 100644 --- a/lib/src/utils/matrix_default_localizations.dart +++ b/lib/src/utils/matrix_default_localizations.dart @@ -321,4 +321,10 @@ class MatrixDefaultLocalizations extends MatrixLocalizations { @override String get refreshingLastEvent => 'Refreshing last event...'; + + @override + String startedAPoll(String senderName) => '$senderName started a poll'; + + @override + String get pollHasBeenEnded => 'Poll has been ended'; } diff --git a/lib/src/utils/matrix_localizations.dart b/lib/src/utils/matrix_localizations.dart index 37c9672f..4173aad6 100644 --- a/lib/src/utils/matrix_localizations.dart +++ b/lib/src/utils/matrix_localizations.dart @@ -187,6 +187,10 @@ abstract class MatrixLocalizations { String completedKeyVerification(String senderName); String canceledKeyVerification(String senderName); + + String startedAPoll(String senderName); + + String get pollHasBeenEnded; } extension HistoryVisibilityDisplayString on HistoryVisibility { diff --git a/test/msc_extensions/msc_3881_polls_test.dart b/test/msc_extensions/msc_3881_polls_test.dart new file mode 100644 index 00000000..e95bf620 --- /dev/null +++ b/test/msc_extensions/msc_3881_polls_test.dart @@ -0,0 +1,123 @@ +import 'package:test/test.dart'; + +import 'package:matrix/matrix.dart'; +import 'package:matrix/src/models/timeline_chunk.dart'; +import '../fake_client.dart'; + +void main() { + group('MSC 3881 Polls', () { + late Client client; + const roomId = '!696r7674:example.com'; + setUpAll(() async { + client = await getClient(); + }); + tearDownAll(() async => client.dispose()); + test('Start poll', () async { + final room = client.getRoomById(roomId)!; + final eventId = await room.startPoll( + question: 'What do you like more?', + kind: PollKind.undisclosed, + maxSelections: 2, + answers: [ + PollAnswer( + id: 'pepsi', + mText: 'Pepsi', + ), + PollAnswer( + id: 'coca', + mText: 'Coca Cola', + ), + ], + txid: '1234', + ); + + expect(eventId, '1234'); + }); + test('Check Poll Event', () async { + final room = client.getRoomById(roomId)!; + final pollEventContent = PollEventContent( + mText: 'TestPoll', + pollStartContent: PollStartContent( + maxSelections: 2, + question: PollQuestion(mText: 'Question'), + answers: [PollAnswer(id: 'id', mText: 'mText')], + ), + ); + final pollEvent = Event( + content: pollEventContent.toJson(), + type: PollEventContent.startType, + eventId: 'testevent', + senderId: client.userID!, + originServerTs: DateTime.now().subtract(const Duration(seconds: 10)), + room: room, + ); + expect( + pollEvent.parsedPollEventContent.toJson(), + pollEventContent.toJson(), + ); + + final timeline = Timeline( + room: room, + chunk: TimelineChunk( + events: [pollEvent], + ), + ); + + expect(pollEvent.getPollResponses(timeline), {}); + expect(pollEvent.getPollHasBeenEnded(timeline), false); + + timeline.aggregatedEvents['testevent'] ??= {}; + timeline.aggregatedEvents['testevent']?['m.reference'] ??= {}; + + timeline.aggregatedEvents['testevent']!['m.reference']!.add( + Event( + content: { + 'm.relates_to': { + 'rel_type': 'm.reference', + 'event_id': 'testevent', + }, + 'org.matrix.msc3381.poll.response': { + 'answers': ['pepsi'], + }, + }, + type: PollEventContent.responseType, + eventId: 'testevent2', + senderId: client.userID!, + originServerTs: DateTime.now().subtract(const Duration(seconds: 9)), + room: room, + ), + ); + + expect( + pollEvent.getPollResponses(timeline), + { + '@test:fakeServer.notExisting': ['pepsi'], + }, + ); + + timeline.aggregatedEvents['testevent']!['m.reference']!.add( + Event( + content: { + 'm.relates_to': { + 'rel_type': 'm.reference', + 'event_id': 'testevent', + }, + 'org.matrix.msc3381.poll.end': {}, + }, + type: PollEventContent.responseType, + eventId: 'testevent3', + senderId: client.userID!, + originServerTs: DateTime.now().subtract(const Duration(seconds: 8)), + room: room, + ), + ); + expect(pollEvent.getPollHasBeenEnded(timeline), true); + + final respondeEventId = await pollEvent.answerPoll( + ['pepsi'], + txid: '1234', + ); + expect(respondeEventId, '1234'); + }); + }); +}