diff --git a/lib/fake_matrix_api.dart b/lib/fake_matrix_api.dart index 37f3e91e..ec0e6ec6 100644 --- a/lib/fake_matrix_api.dart +++ b/lib/fake_matrix_api.dart @@ -2614,6 +2614,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/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..5178020d --- /dev/null +++ b/lib/msc_extensions/msc_3381_polls/poll_event_extension.dart @@ -0,0 +1,100 @@ +import 'package:matrix/matrix.dart'; +import 'package:matrix/msc_extensions/msc_3381_polls/models/poll_event_content.dart'; + +extension PollEventExtension on Event { + PollEventContent get parsedPollEventContent { + assert(type == PollEventContent.startType); + return PollEventContent.fromJson(content); + } + + /// Returns a Map of answer IDs to a Set of user IDs. + Map> getPollResponses(Timeline timeline) { + assert(type == PollEventContent.startType); + final aggregatedEvents = + timeline.aggregatedEvents[eventId]?['m.reference']?.toList(); + if (aggregatedEvents == null || aggregatedEvents.isEmpty) return {}; + + final responses = >{}; + + // Votes with timestamps after the poll has closed are ignored, as if they + // never happened. + aggregatedEvents + .removeWhere((event) => originServerTs.isAfter(event.originServerTs)); + + // Sort by date so only the users most recent vote is used in the end, even + // if it is invalid. + aggregatedEvents + .sort((a, b) => a.originServerTs.compareTo(b.originServerTs)); + + for (final event in aggregatedEvents) { + final answers = event.content + .tryGetMap(PollEventContent.responseType) + ?.tryGetList('answers') ?? + []; + responses[event.senderId] = answers.toSet(); + } + return responses; + } + + bool getPollHasBeenEnded(Timeline timeline) { + assert(type == PollEventContent.startType); + final aggregatedEvents = timeline.aggregatedEvents[eventId]?['m.reference']; + if (aggregatedEvents == null || aggregatedEvents.isEmpty) return false; + + final redactPowerLevel = (room + .getState(EventTypes.RoomPowerLevels) + ?.content + .tryGet('redact') ?? + 50); + + return aggregatedEvents.any( + (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; + }, + ); + } + + Future answerPoll( + List answerIds, { + String? txid, + }) => + room.sendEvent( + { + 'm.relates_to': { + 'rel_type': 'm.reference', + 'event_id': eventId, + }, + PollEventContent.responseType: {'answers': answerIds}, + }, + type: PollEventContent.responseType, + txid: txid, + ); + + Future endPoll({String? txid}) => room.sendEvent( + { + 'm.relates_to': { + 'rel_type': 'm.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..f960ca12 --- /dev/null +++ b/lib/msc_extensions/msc_3381_polls/poll_room_extension.dart @@ -0,0 +1,43 @@ +import 'package:matrix/matrix.dart'; +import 'package:matrix/msc_extensions/msc_3381_polls/models/poll_event_content.dart'; + +extension PollRoomExtension on Room { + static const String mTextJsonKey = 'org.matrix.msc1767.text'; + static const String startType = 'org.matrix.msc3381.poll.start'; + + 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: startType, + txid: txid, + ); + } +} 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..bac3616a --- /dev/null +++ b/test/msc_extensions/msc_3881_polls_test.dart @@ -0,0 +1,126 @@ +import 'package:test/test.dart'; + +import 'package:matrix/matrix.dart'; +import 'package:matrix/msc_extensions/msc_3381_polls/models/poll_event_content.dart'; +import 'package:matrix/msc_extensions/msc_3381_polls/poll_event_extension.dart'; +import 'package:matrix/msc_extensions/msc_3381_polls/poll_room_extension.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'); + }); + }); +}