add poll support

Fixes #130
This commit is contained in:
Henry Hiles 2026-11-02 12:03:52 -05:00
commit f6aaf49164
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
5 changed files with 1041 additions and 144 deletions

View file

@ -1,4 +1,3 @@
import "package:collection/collection.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
@ -10,11 +9,14 @@ class EventController(final GetEventRequest request)
@override
Future<Event?> build() async {
final event = ref.watch(
RoomsController.provider.select(
(rooms) => rooms[request.roomId]?.events.values.firstWhereOrNull(
(event) => event.eventId == request.eventId,
),
),
RoomsController.provider.select((rooms) {
final room = rooms[request.roomId];
return switch (room?.eventIds[request.eventId]) {
final rowId? => room?.events[rowId],
null => null,
};
}),
);
if (event != null) return event;

View file

@ -0,0 +1,132 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/event_id_is_fake.dart";
import "package:nexus/models/configs/event.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/encrypted.dart";
import "package:nexus/models/content/poll_end.dart";
import "package:nexus/models/content/poll_response.dart";
import "package:nexus/models/content/poll_start.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/poll_votes.dart";
class PollVotesController(final EventConfig config)
extends AsyncNotifier<PollVotes> {
@override
Future<PollVotes> build() async {
// Only what's needed, so e.g. reactions don't cause refetches
final (poll, localRelated) = ref.watch(
RoomsController.provider.select((rooms) {
final room = rooms[config.roomId];
final event = room?.events[config.eventRowId];
if (room == null || event == null) {
return (null, const IList<Event>.empty());
}
final edited = event.lastEditRowId == 0
? null
: room.events[event.lastEditRowId];
return (
(
eventId: event.eventId,
sender: event.sender,
// Edits can change the answers
content: (edited ?? event).content,
),
(room.relations[event.eventId] ?? const ISet<int>.empty())
.map((rowId) => room.events[rowId])
.nonNulls
.where((related) => related.relationType == "m.reference")
.toIList(),
);
}),
);
final powerLevels = ref.watch(
RoomPowerLevelsController.provider(config.roomId),
);
if (poll == null) return const PollVotes();
final PollStart(:answers, :selectionLimit) = switch (poll.content) {
PollStartContent(:final poll) => poll,
_ => const PollStart(),
};
final fetched = await ref
.read(ClientController.provider.notifier)
.getRelatedEvents(
.new(
roomId: config.roomId,
eventId: poll.eventId,
relationType: "m.reference",
),
);
final related =
IMap.fromIterable(
(fetched ?? const IList.empty()).addAll(localRelated),
keyMapper: (event) => event.rowId,
valueMapper: (event) => event,
).values
// gomuks' database has unsent local echoes too
.where((event) => event.redactedBy == null && !event.eventId.isFake)
.toIList();
final end = related
.where(
(event) =>
event.content is PollEndContent &&
powerLevels.canEndPoll(event.sender, pollSender: poll.sender),
)
.sortedBy((event) => event.timestamp)
.firstOrNull;
final endedAt = end?.timestamp;
bool beforeEnd(Event event) =>
endedAt == null || !event.timestamp.isAfter(endedAt);
final answerIds = answers.map((answer) => answer.id).toISet();
return .new(
endEventId: end?.eventId,
undecryptable: related
.where(
(event) => event.content is EncryptedContent && beforeEnd(event),
)
.length,
// Each user's latest response before the end counts, even if invalid
votes: related
.where(
(event) =>
event.type == EventType.pollResponse.type && beforeEnd(event),
)
.toIList()
.sort((a, b) => a.timestamp.compareTo(b.timestamp))
.fold(const .empty(), (votes, event) {
// Truncated before removing duplicates, as MSC3381 specifies
final picked = switch (event.content) {
PollResponseContent(:final answers) =>
answers.take(selectionLimit).toISet(),
_ => null,
};
return votes.add(
event.sender,
// Spoiled responses count as no answers
picked != null && picked.every(answerIds.contains)
? picked.toIList()
: const .empty(),
);
}),
);
}
static final provider = AsyncNotifierProvider.family
.autoDispose<PollVotesController, PollVotes, EventConfig>(
PollVotesController.new,
);
}

View file

@ -2,10 +2,10 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/configs/reactions.dart";
import "package:nexus/models/configs/event.dart";
import "package:nexus/models/content/reaction.dart";
class ReactionsController(final ReactionsConfig config)
class ReactionsController(final EventConfig config)
extends AsyncNotifier<IMap<String, IList<String>>> {
@override
Future<IMap<String, IList<String>>> build() async {
@ -48,6 +48,6 @@ class ReactionsController(final ReactionsConfig config)
AsyncNotifierProvider.family<
ReactionsController,
IMap<String, IList<String>>,
ReactionsConfig
EventConfig
>(ReactionsController.new);
}

View file

@ -8,8 +8,12 @@ import "package:fluttertagger/fluttertagger.dart";
import "package:nexus/controllers/attachment.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/send_failed.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/content/poll_end.dart";
import "package:nexus/models/content/poll_response.dart";
import "package:nexus/models/content/poll_start.dart";
import "package:nexus/models/content/reaction.dart";
import "package:nexus/models/content/sticker.dart";
import "package:nexus/models/direction.dart";
@ -340,10 +344,10 @@ class RoomChatController(final ChatParams info)
);
}
/// Sends [image] as a sticker, as a reply to [repliedEventRowId] if set.
Future<void> sendSticker(
String shortcode,
PackImage image, {
/// Sends [content], as a reply to [repliedEventRowId] if set.
Future<void> sendWithReply(
EventType type,
Content content, {
bool shouldMention = true,
int? repliedEventRowId,
}) async {
@ -360,12 +364,8 @@ class RoomChatController(final ChatParams info)
.sendEvent(
.new(
roomId: info.roomId,
type: EventType.sticker.type,
content: StickerContent(
body: image.body ?? shortcode,
info: image.info ?? const .new(),
url: image.url,
),
type: type.type,
content: content,
mentions: Mentions(
userIds: [
if (shouldMention && repliedEvent != null) repliedEvent.sender,
@ -387,6 +387,70 @@ class RoomChatController(final ChatParams info)
);
}
/// Sends [image] as a sticker, as a reply to [repliedEventRowId] if set.
Future<void> sendSticker(
String shortcode,
PackImage image, {
bool shouldMention = true,
int? repliedEventRowId,
}) => sendWithReply(
.sticker,
StickerContent(
body: image.body ?? shortcode,
info: image.info ?? const .new(),
url: image.url,
),
shouldMention: shouldMention,
repliedEventRowId: repliedEventRowId,
);
Future<void> sendPoll(
PollStart poll, {
bool shouldMention = true,
int? repliedEventRowId,
}) => sendWithReply(
.pollStart,
PollStartContent(poll: poll),
shouldMention: shouldMention,
repliedEventRowId: repliedEventRowId,
);
/// Completes once the event has come down sync.
Future<void> sendPollReference(
Event poll,
EventType type,
Content content,
) async {
final event = await ref
.read(ClientController.provider.notifier)
.sendEvent(
.new(
roomId: info.roomId,
type: type.type,
content: content,
relatesTo: poll.eventId,
relationType: "m.reference",
synchronous: true,
),
);
if (event.sendFailed) throw Exception(event.sendError);
await ref
.read(RoomsController.provider.notifier)
.awaitEvent(info.roomId, event.eventId);
}
/// An empty [answers] removes the vote.
Future<void> votePoll(Event poll, IList<String> answers) => sendPollReference(
poll,
.pollResponse,
PollResponseContent(answers: answers),
);
Future<void> endPoll(Event poll) =>
sendPollReference(poll, .pollEnd, const PollEndContent());
static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
RoomChatController.new,

View file

@ -82,6 +82,35 @@ class RoomsController extends Notifier<IMap<String, Room>> {
),
);
static IMap<String, ISet<int>> addRelations(
IMap<String, ISet<int>> relations,
Iterable<Event> events,
) => events.fold(
relations,
(acc, event) => switch (event.relatesTo) {
final relatesTo? => acc.update(
relatesTo,
(rowIds) => rowIds.add(event.rowId),
ifAbsent: () => .new([event.rowId]),
),
null => acc,
},
);
/// IDs replace previous ones for the same row, e.g. once a local echo is sent.
static IMap<String, int> addEventIds(
IMap<String, int> eventIds,
IMap<int, Event> existing,
Iterable<Event> events,
) => events.fold(eventIds, (acc, event) {
final previousId = existing[event.rowId]?.eventId;
return (previousId != null && previousId != event.eventId
? acc.remove(previousId)
: acc)
.add(event.eventId, event.rowId);
});
void update(
IMap<String, Room> rooms, [
ISet<String> leftRooms = const .empty(),
@ -109,6 +138,15 @@ class RoomsController extends Notifier<IMap<String, Room>> {
events: incoming.events.isEmpty
? existing.events
: existing.events.addAll(incoming.events),
relations: addRelations(
existing.relations,
incoming.events.values,
),
eventIds: addEventIds(
existing.eventIds,
existing.events,
incoming.events.values,
),
state: incoming.state.entries.fold(
existing.state,
(previousValue, event) => previousValue.add(
@ -129,6 +167,12 @@ class RoomsController extends Notifier<IMap<String, Room>> {
) ??
incoming.copyWith(
receipts: mergeReceipts(const .empty(), incoming.receipts),
relations: addRelations(const .empty(), incoming.events.values),
eventIds: addEventIds(
const .empty(),
const .empty(),
incoming.events.values,
),
),
);
});
@ -144,12 +188,19 @@ class RoomsController extends Notifier<IMap<String, Room>> {
String type,
String eventId, {
String stateKey = "",
}) {
bool arrived(IMap<String, Room> rooms) =>
switch (rooms[roomId]?.state[type]?[stateKey]) {
final rowId? => rooms[roomId]?.events[rowId]?.eventId == eventId,
null => false,
};
}) => awaitRooms(
(rooms) => switch (rooms[roomId]?.state[type]?[stateKey]) {
final rowId? => rooms[roomId]?.events[rowId]?.eventId == eventId,
null => false,
},
);
/// Waits for a sent event to come down sync, giving up after a while.
Future<void> awaitEvent(String roomId, String eventId) => awaitRooms(
(rooms) => rooms[roomId]?.eventIds.containsKey(eventId) ?? false,
);
Future<void> awaitRooms(bool Function(IMap<String, Room> rooms) arrived) {
if (arrived(state)) return .value();
final completer = Completer<void>();