Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement msc 3381 polls #1995

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading