feat: Implement msc 3381 polls
This commit is contained in:
parent
4bd25bd005
commit
32a9f53587
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
```
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<String, Set<String>> 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 = <String, Event>{};
|
||||
|
||||
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<String, Object?>(PollEventContent.responseType)
|
||||
?.tryGetList<String>('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<int>('redact') ??
|
||||
50);
|
||||
|
||||
return aggregatedEvents.firstWhereOrNull(
|
||||
(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;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool getPollHasBeenEnded(Timeline timeline) =>
|
||||
_getEndPollEvent(timeline) != null;
|
||||
|
||||
Future<String?> answerPoll(
|
||||
List<String> 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<String?> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import 'package:matrix/matrix.dart';
|
||||
|
||||
extension PollRoomExtension on Room {
|
||||
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: PollEventContent.startType,
|
||||
txid: txid,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue