Skip to content

Commit

Permalink
feat: Implement msc 3381 polls
Browse files Browse the repository at this point in the history
  • Loading branch information
krille-chan committed Jan 14, 2025
1 parent a069589 commit 787684a
Show file tree
Hide file tree
Showing 9 changed files with 460 additions and 0 deletions.
4 changes: 4 additions & 0 deletions lib/fake_matrix_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2613,6 +2613,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':
Expand Down
45 changes: 45 additions & 0 deletions lib/msc_extensions/msc_3381_polls/README.md
Original file line number Diff line number Diff line change
@@ -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();
```
103 changes: 103 additions & 0 deletions lib/msc_extensions/msc_3381_polls/models/poll_event_content.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> json) =>
PollEventContent(
mText: json[mTextJsonKey],
pollStartContent: PollStartContent.fromJson(json[startType]),
);

Map<String, dynamic> toJson() => {
mTextJsonKey: mText,
startType: pollStartContent.toJson(),
};
}

class PollStartContent {
final PollKind? kind;
final int maxSelections;
final PollQuestion question;
final List<PollAnswer> answers;

const PollStartContent({
this.kind,
required this.maxSelections,
required this.question,
required this.answers,
});

factory PollStartContent.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) => PollQuestion(
mText: json[PollEventContent.mTextJsonKey] ?? json['body'],
);

Map<String, dynamic> 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<String, Object?> json) => PollAnswer(
id: json['id'] as String,
mText: json[PollEventContent.mTextJsonKey] as String,
);

Map<String, Object?> 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;
}
130 changes: 130 additions & 0 deletions lib/msc_extensions/msc_3381_polls/poll_event_extension.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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<String, Set<String>> getPollResponses(Timeline timeline) {
assert(type == PollEventContent.startType);
final aggregatedEvents =
timeline.aggregatedEvents[eventId]?['m.reference']?.toList();
if (aggregatedEvents == null || aggregatedEvents.isEmpty) return {};

final responses = <String, Set<String>>{};

final maxSelection = parsedPollEventContent.pollStartContent.maxSelections;

aggregatedEvents.removeWhere((event) {
if (event.type != PollEventContent.responseType) return true;

// Votes with timestamps after the poll has closed are ignored, as if they
// never happened.
if (originServerTs.isAfter(event.originServerTs)) {
Logs().d('Ignore poll answer which came after poll was closed.');
return true;
}

final answers = event.content
.tryGetMap<String, Object?>(PollEventContent.responseType)
?.tryGetList<String>('answers');
if (answers == null) {
Logs().d('Ignore poll answer with now valid answer IDs');
return true;
}
if (answers.length > maxSelection) {
Logs().d(
'Ignore poll answer with ${answers.length} while only $maxSelection are allowed.',
);
return true;
}
return false;
});

// 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<String, Object?>(PollEventContent.responseType)
?.tryGetList<String>('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<int>('redact') ??
50);

return aggregatedEvents.any(
(event) {
if (event.content
.tryGetMap<String, Object?>(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<String?> answerPoll(
List<String> answerIds, {
String? txid,
}) {
final maxSelection = parsedPollEventContent.pollStartContent.maxSelections;
if (answerIds.length > maxSelection) {
throw Exception(
'Can not add ${answerIds.length} answers while max selection is $maxSelection',
);
}
return room.sendEvent(
{
'm.relates_to': {
'rel_type': 'm.reference',
'event_id': eventId,
},
PollEventContent.responseType: {'answers': answerIds},
},
type: PollEventContent.responseType,
txid: txid,
);
}

Future<String?> endPoll({String? txid}) => room.sendEvent(
{
'm.relates_to': {
'rel_type': 'm.reference',
'event_id': eventId,
},
PollEventContent.endType: {},
},
type: PollEventContent.endType,
txid: txid,
);
}
43 changes: 43 additions & 0 deletions lib/msc_extensions/msc_3381_polls/poll_room_extension.dart
Original file line number Diff line number Diff line change
@@ -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<String?> startPoll({
required String question,
required List<PollAnswer> 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,
);
}
}
4 changes: 4 additions & 0 deletions lib/src/utils/event_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import 'package:collection/collection.dart';

import 'package:matrix/encryption.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/msc_extensions/msc_3381_polls/models/poll_event_content.dart';

abstract class EventLocalizations {
// As we need to create the localized body off of a different set of parameters, we
Expand Down Expand Up @@ -290,5 +291,8 @@ abstract class EventLocalizations {
?.tryGet<String>('key') ??
body,
),
PollEventContent.startType: (event, i18n, body) => i18n.startedAPoll(
event.senderFromMemoryOrFallback.calcDisplayname(i18n: i18n),
),
};
}
3 changes: 3 additions & 0 deletions lib/src/utils/matrix_default_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -310,4 +310,7 @@ class MatrixDefaultLocalizations extends MatrixLocalizations {
@override
String startedKeyVerification(String senderName) =>
'$senderName started key verification';

@override
String startedAPoll(String senderName) => '$senderName started a poll';
}
2 changes: 2 additions & 0 deletions lib/src/utils/matrix_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ abstract class MatrixLocalizations {
String completedKeyVerification(String senderName);

String canceledKeyVerification(String senderName);

String startedAPoll(String senderName);
}

extension HistoryVisibilityDisplayString on HistoryVisibility {
Expand Down
Loading

0 comments on commit 787684a

Please sign in to comment.