parent
40f5d0f459
commit
f6aaf49164
27 changed files with 1041 additions and 144 deletions
|
|
@ -1,4 +1,3 @@
|
||||||
import "package:collection/collection.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client.dart";
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
import "package:nexus/controllers/rooms.dart";
|
||||||
|
|
@ -10,11 +9,14 @@ class EventController(final GetEventRequest request)
|
||||||
@override
|
@override
|
||||||
Future<Event?> build() async {
|
Future<Event?> build() async {
|
||||||
final event = ref.watch(
|
final event = ref.watch(
|
||||||
RoomsController.provider.select(
|
RoomsController.provider.select((rooms) {
|
||||||
(rooms) => rooms[request.roomId]?.events.values.firstWhereOrNull(
|
final room = rooms[request.roomId];
|
||||||
(event) => event.eventId == request.eventId,
|
|
||||||
),
|
return switch (room?.eventIds[request.eventId]) {
|
||||||
),
|
final rowId? => room?.events[rowId],
|
||||||
|
null => null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (event != null) return event;
|
if (event != null) return event;
|
||||||
|
|
|
||||||
132
lib/controllers/poll_votes.dart
Normal file
132
lib/controllers/poll_votes.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,10 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client.dart";
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/controllers/rooms.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";
|
import "package:nexus/models/content/reaction.dart";
|
||||||
|
|
||||||
class ReactionsController(final ReactionsConfig config)
|
class ReactionsController(final EventConfig config)
|
||||||
extends AsyncNotifier<IMap<String, IList<String>>> {
|
extends AsyncNotifier<IMap<String, IList<String>>> {
|
||||||
@override
|
@override
|
||||||
Future<IMap<String, IList<String>>> build() async {
|
Future<IMap<String, IList<String>>> build() async {
|
||||||
|
|
@ -48,6 +48,6 @@ class ReactionsController(final ReactionsConfig config)
|
||||||
AsyncNotifierProvider.family<
|
AsyncNotifierProvider.family<
|
||||||
ReactionsController,
|
ReactionsController,
|
||||||
IMap<String, IList<String>>,
|
IMap<String, IList<String>>,
|
||||||
ReactionsConfig
|
EventConfig
|
||||||
>(ReactionsController.new);
|
>(ReactionsController.new);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,12 @@ import "package:fluttertagger/fluttertagger.dart";
|
||||||
import "package:nexus/controllers/attachment.dart";
|
import "package:nexus/controllers/attachment.dart";
|
||||||
import "package:nexus/controllers/client.dart";
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/controllers/rooms.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/content.dart";
|
||||||
import "package:nexus/models/content/image_pack.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/reaction.dart";
|
||||||
import "package:nexus/models/content/sticker.dart";
|
import "package:nexus/models/content/sticker.dart";
|
||||||
import "package:nexus/models/direction.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.
|
/// Sends [content], as a reply to [repliedEventRowId] if set.
|
||||||
Future<void> sendSticker(
|
Future<void> sendWithReply(
|
||||||
String shortcode,
|
EventType type,
|
||||||
PackImage image, {
|
Content content, {
|
||||||
bool shouldMention = true,
|
bool shouldMention = true,
|
||||||
int? repliedEventRowId,
|
int? repliedEventRowId,
|
||||||
}) async {
|
}) async {
|
||||||
|
|
@ -360,12 +364,8 @@ class RoomChatController(final ChatParams info)
|
||||||
.sendEvent(
|
.sendEvent(
|
||||||
.new(
|
.new(
|
||||||
roomId: info.roomId,
|
roomId: info.roomId,
|
||||||
type: EventType.sticker.type,
|
type: type.type,
|
||||||
content: StickerContent(
|
content: content,
|
||||||
body: image.body ?? shortcode,
|
|
||||||
info: image.info ?? const .new(),
|
|
||||||
url: image.url,
|
|
||||||
),
|
|
||||||
mentions: Mentions(
|
mentions: Mentions(
|
||||||
userIds: [
|
userIds: [
|
||||||
if (shouldMention && repliedEvent != null) repliedEvent.sender,
|
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
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
|
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
|
||||||
RoomChatController.new,
|
RoomChatController.new,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
void update(
|
||||||
IMap<String, Room> rooms, [
|
IMap<String, Room> rooms, [
|
||||||
ISet<String> leftRooms = const .empty(),
|
ISet<String> leftRooms = const .empty(),
|
||||||
|
|
@ -109,6 +138,15 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
events: incoming.events.isEmpty
|
events: incoming.events.isEmpty
|
||||||
? existing.events
|
? existing.events
|
||||||
: existing.events.addAll(incoming.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(
|
state: incoming.state.entries.fold(
|
||||||
existing.state,
|
existing.state,
|
||||||
(previousValue, event) => previousValue.add(
|
(previousValue, event) => previousValue.add(
|
||||||
|
|
@ -129,6 +167,12 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
) ??
|
) ??
|
||||||
incoming.copyWith(
|
incoming.copyWith(
|
||||||
receipts: mergeReceipts(const .empty(), incoming.receipts),
|
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 type,
|
||||||
String eventId, {
|
String eventId, {
|
||||||
String stateKey = "",
|
String stateKey = "",
|
||||||
}) {
|
}) => awaitRooms(
|
||||||
bool arrived(IMap<String, Room> rooms) =>
|
(rooms) => switch (rooms[roomId]?.state[type]?[stateKey]) {
|
||||||
switch (rooms[roomId]?.state[type]?[stateKey]) {
|
final rowId? => rooms[roomId]?.events[rowId]?.eventId == eventId,
|
||||||
final rowId? => rooms[roomId]?.events[rowId]?.eventId == eventId,
|
null => false,
|
||||||
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();
|
if (arrived(state)) return .value();
|
||||||
|
|
||||||
final completer = Completer<void>();
|
final completer = Completer<void>();
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
import "package:nexus/models/content/encrypted.dart";
|
import "package:nexus/models/content/encrypted.dart";
|
||||||
import "package:nexus/models/content/message.dart";
|
import "package:nexus/models/content/message.dart";
|
||||||
|
import "package:nexus/models/content/poll_start.dart";
|
||||||
import "package:nexus/models/content/sticker.dart";
|
import "package:nexus/models/content/sticker.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
|
|
||||||
extension IsShownAsMessage on Event {
|
extension IsShownAsMessage on Event {
|
||||||
|
/// Whether this is a message, sticker or poll.
|
||||||
|
bool get hasMessageContent => switch (content) {
|
||||||
|
MessageContent() || StickerContent() || PollStartContent() => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
/// Whether this is shown in the timeline as a message, with MessageRenderer.
|
/// Whether this is shown in the timeline as a message, with MessageRenderer.
|
||||||
bool get isShownAsMessage =>
|
bool get isShownAsMessage =>
|
||||||
redactedBy == null &&
|
redactedBy == null &&
|
||||||
relationType != "m.replace" &&
|
relationType != "m.replace" &&
|
||||||
switch (content) {
|
(hasMessageContent || content is EncryptedContent);
|
||||||
MessageContent() || EncryptedContent() || StickerContent() => true,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
lib/helpers/extensions/send_failed.dart
Normal file
6
lib/helpers/extensions/send_failed.dart
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
|
|
||||||
|
extension SendFailed on Event {
|
||||||
|
/// gomuks marks events that are still sending as "not sent" (and often forgets to clear it at all)
|
||||||
|
bool get sendFailed => sendError != null && sendError != "not sent";
|
||||||
|
}
|
||||||
16
lib/models/configs/event.dart
Normal file
16
lib/models/configs/event.dart
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
|
||||||
|
part "event.freezed.dart";
|
||||||
|
part "event.g.dart";
|
||||||
|
|
||||||
|
@Freezed(toJson: false, fromJson: false)
|
||||||
|
@JsonSerializable()
|
||||||
|
class const EventConfig({
|
||||||
|
required final String roomId,
|
||||||
|
required final int eventRowId,
|
||||||
|
}) with _$EventConfig {
|
||||||
|
Map<String, Object?> toJson() => _$EventConfigToJson(this);
|
||||||
|
|
||||||
|
factory EventConfig.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$EventConfigFromJson(json);
|
||||||
|
}
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
|
||||||
|
|
||||||
part "reactions.freezed.dart";
|
|
||||||
part "reactions.g.dart";
|
|
||||||
|
|
||||||
@Freezed(toJson: false, fromJson: false)
|
|
||||||
@JsonSerializable()
|
|
||||||
class const ReactionsConfig({
|
|
||||||
required final String roomId,
|
|
||||||
required final int eventRowId,
|
|
||||||
}) with _$ReactionsConfig {
|
|
||||||
Map<String, Object?> toJson() => _$ReactionsConfigToJson(this);
|
|
||||||
|
|
||||||
factory ReactionsConfig.fromJson(Map<String, Object?> json) =>
|
|
||||||
_$ReactionsConfigFromJson(json);
|
|
||||||
}
|
|
||||||
|
|
@ -17,6 +17,9 @@ import "package:nexus/models/content/topic.dart";
|
||||||
import "package:nexus/models/content/sticker.dart";
|
import "package:nexus/models/content/sticker.dart";
|
||||||
import "package:nexus/models/content/history_visibility.dart";
|
import "package:nexus/models/content/history_visibility.dart";
|
||||||
import "package:nexus/models/content/image_pack.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";
|
||||||
|
|
||||||
class const Content({final String? parseError, final StackTrace? stackTrace}) {
|
class const Content({final String? parseError, final StackTrace? stackTrace}) {
|
||||||
factory Content.fromJson(Map<String, dynamic> json) => const Content();
|
factory Content.fromJson(Map<String, dynamic> json) => const Content();
|
||||||
|
|
@ -64,5 +67,11 @@ enum EventType(
|
||||||
pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson),
|
pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson),
|
||||||
imagePack("m.room.image_pack", ImagePackContent.fromJson),
|
imagePack("m.room.image_pack", ImagePackContent.fromJson),
|
||||||
legacyImagePack("im.ponies.room_emotes", ImagePackContent.fromJson),
|
legacyImagePack("im.ponies.room_emotes", ImagePackContent.fromJson),
|
||||||
|
pollStart("org.matrix.msc3381.poll.start", PollStartContent.fromJson),
|
||||||
|
pollResponse(
|
||||||
|
"org.matrix.msc3381.poll.response",
|
||||||
|
PollResponseContent.fromJson,
|
||||||
|
),
|
||||||
|
pollEnd("org.matrix.msc3381.poll.end", PollEndContent.fromJson),
|
||||||
message("m.room.message", MessageContent.fromJson),
|
message("m.room.message", MessageContent.fromJson),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
lib/models/content/poll_end.dart
Normal file
12
lib/models/content/poll_end.dart
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
|
||||||
|
class const PollEndContent() extends Content {
|
||||||
|
factory PollEndContent.fromJson(Map<String, dynamic> json) =>
|
||||||
|
const PollEndContent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"org.matrix.msc3381.poll.end": {},
|
||||||
|
"org.matrix.msc1767.text": "Ended the poll",
|
||||||
|
};
|
||||||
|
}
|
||||||
23
lib/models/content/poll_response.dart
Normal file
23
lib/models/content/poll_response.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
part "poll_response.freezed.dart";
|
||||||
|
part "poll_response.g.dart";
|
||||||
|
|
||||||
|
@Freezed(toJson: false, fromJson: false)
|
||||||
|
@JsonSerializable(createToJson: false)
|
||||||
|
class const PollResponseContent({
|
||||||
|
@JsonKey(readValue: PollResponseContent.answersFromJson)
|
||||||
|
final IList<String> answers = const .empty(),
|
||||||
|
}) extends Content with _$PollResponseContent {
|
||||||
|
static List<dynamic>? answersFromJson(Map<dynamic, dynamic> json, _) =>
|
||||||
|
json["org.matrix.msc3381.poll.response"]?["answers"];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
"org.matrix.msc3381.poll.response": {"answers": answers.toList()},
|
||||||
|
};
|
||||||
|
|
||||||
|
factory PollResponseContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$PollResponseContentFromJson(json);
|
||||||
|
}
|
||||||
90
lib/models/content/poll_start.dart
Normal file
90
lib/models/content/poll_start.dart
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import "dart:math";
|
||||||
|
|
||||||
|
import "package:collection/collection.dart";
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
part "poll_start.freezed.dart";
|
||||||
|
part "poll_start.g.dart";
|
||||||
|
|
||||||
|
@Freezed(toJson: false, fromJson: false)
|
||||||
|
@JsonSerializable(createToJson: false)
|
||||||
|
class const PollStartContent({
|
||||||
|
@JsonKey(name: "org.matrix.msc3381.poll.start") required final PollStart poll,
|
||||||
|
}) extends Content with _$PollStartContent {
|
||||||
|
@override
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
"org.matrix.msc3381.poll.start": poll.toJson(),
|
||||||
|
// Shown by clients without poll support
|
||||||
|
"org.matrix.msc1767.text": [
|
||||||
|
poll.question,
|
||||||
|
...poll.answers.mapIndexed(
|
||||||
|
(index, answer) => "${index + 1}. ${answer.text}",
|
||||||
|
),
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory PollStartContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$PollStartContentFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Freezed(toJson: false, fromJson: false)
|
||||||
|
@JsonSerializable()
|
||||||
|
class const PollStart({
|
||||||
|
@JsonKey(readValue: PollStart.questionFromJson) final String question = "",
|
||||||
|
final int? maxSelections,
|
||||||
|
@JsonKey(readValue: PollStart.answersFromJson)
|
||||||
|
final IList<PollAnswer> answers = const .empty(),
|
||||||
|
}) with _$PollStart {
|
||||||
|
static const maxAnswers = 20;
|
||||||
|
|
||||||
|
static String? extensibleTextFromJson(Object? json) => switch (json) {
|
||||||
|
{"org.matrix.msc1767.text": final String text} => text,
|
||||||
|
{"body": final String body} => body,
|
||||||
|
{"org.matrix.msc1767.message": final List message} =>
|
||||||
|
message
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((part) => part["body"])
|
||||||
|
.whereType<String>()
|
||||||
|
.firstOrNull,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
static String? questionFromJson(Map<dynamic, dynamic> json, String key) =>
|
||||||
|
PollStart.extensibleTextFromJson(json[key]);
|
||||||
|
|
||||||
|
static List<dynamic>? answersFromJson(
|
||||||
|
Map<dynamic, dynamic> json,
|
||||||
|
String key,
|
||||||
|
) => (json[key] as List?)?.take(maxAnswers).toList();
|
||||||
|
|
||||||
|
/// [maxSelections], defaulting to 1, and at most the number of answers
|
||||||
|
int get selectionLimit =>
|
||||||
|
(maxSelections ?? 1).clamp(1, max(answers.length, 1));
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
..._$PollStartToJson(this),
|
||||||
|
// We don't allow undisclosed polls as they make no sense
|
||||||
|
"kind": "org.matrix.msc3381.poll.disclosed",
|
||||||
|
"question": {"org.matrix.msc1767.text": question},
|
||||||
|
"answers": answers.map((answer) => answer.toJson()).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory PollStart.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$PollStartFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Freezed(toJson: false, fromJson: false)
|
||||||
|
@JsonSerializable(createToJson: false)
|
||||||
|
class const PollAnswer({
|
||||||
|
required final String id,
|
||||||
|
@JsonKey(readValue: PollAnswer.textFromJson) final String text = "",
|
||||||
|
}) with _$PollAnswer {
|
||||||
|
static String? textFromJson(Map<dynamic, dynamic> json, _) =>
|
||||||
|
PollStart.extensibleTextFromJson(json);
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {"id": id, "org.matrix.msc1767.text": text};
|
||||||
|
|
||||||
|
factory PollAnswer.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$PollAnswerFromJson(json);
|
||||||
|
}
|
||||||
25
lib/models/poll_votes.dart
Normal file
25
lib/models/poll_votes.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
|
||||||
|
class const PollVotes({
|
||||||
|
/// Each user's answers, by user ID
|
||||||
|
final IMap<String, IList<String>> votes = const .empty(),
|
||||||
|
final String? endEventId,
|
||||||
|
final int undecryptable = 0,
|
||||||
|
}) {
|
||||||
|
bool get hasEnded => endEventId != null;
|
||||||
|
|
||||||
|
IMap<String, IList<String>> get votersByAnswer => votes.entries.fold(
|
||||||
|
const .empty(),
|
||||||
|
(acc, entry) => entry.value.fold(
|
||||||
|
acc,
|
||||||
|
(acc, answer) => acc.update(
|
||||||
|
answer,
|
||||||
|
(voters) => voters.add(entry.key),
|
||||||
|
ifAbsent: () => .new([entry.key]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
int get voterCount =>
|
||||||
|
votes.values.where((answers) => answers.isNotEmpty).length;
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,14 @@ class const Room({
|
||||||
@JsonKey(fromJson: Room.eventsJsonToIMap)
|
@JsonKey(fromJson: Room.eventsJsonToIMap)
|
||||||
final IMap<int, Event> events = const .empty(),
|
final IMap<int, Event> events = const .empty(),
|
||||||
|
|
||||||
|
/// [relations] is an IMap of event ID to the eventRowIds relating to it
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
final IMap<String, ISet<int>> relations = const .empty(),
|
||||||
|
|
||||||
|
/// [eventIds] is an IMap of event ID to eventRowId
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
final IMap<String, int> eventIds = const .empty(),
|
||||||
|
|
||||||
final bool reset = false,
|
final bool reset = false,
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
final bool resetClientSticky = false,
|
final bool resetClientSticky = false,
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,7 @@ class const RoomPowerLevels({
|
||||||
double levelOf(String userId) => creators.contains(userId)
|
double levelOf(String userId) => creators.contains(userId)
|
||||||
? double.infinity
|
? double.infinity
|
||||||
: (content.users[userId] ?? content.usersDefault).toDouble();
|
: (content.users[userId] ?? content.usersDefault).toDouble();
|
||||||
|
|
||||||
|
bool canEndPoll(String userId, {required String pollSender}) =>
|
||||||
|
userId == pollSender || levelOf(userId) >= content.redact;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,11 @@ import "package:nexus/controllers/power_level.dart";
|
||||||
import "package:nexus/controllers/image_packs.dart";
|
import "package:nexus/controllers/image_packs.dart";
|
||||||
import "package:nexus/models/content/image_pack.dart";
|
import "package:nexus/models/content/image_pack.dart";
|
||||||
import "package:nexus/models/content/message.dart";
|
import "package:nexus/models/content/message.dart";
|
||||||
|
import "package:nexus/models/content/poll_start.dart";
|
||||||
import "package:nexus/models/image_pack.dart";
|
import "package:nexus/models/image_pack.dart";
|
||||||
import "package:nexus/widgets/mxc_emoji.dart";
|
import "package:nexus/widgets/mxc_emoji.dart";
|
||||||
import "package:nexus/widgets/sticker_picker.dart";
|
import "package:nexus/widgets/sticker_picker.dart";
|
||||||
|
import "package:nexus/widgets/create_poll_sheet.dart";
|
||||||
import "package:nexus/models/relation_type.dart";
|
import "package:nexus/models/relation_type.dart";
|
||||||
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
|
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
|
||||||
import "package:nexus/widgets/composer/paste_files_action.dart";
|
import "package:nexus/widgets/composer/paste_files_action.dart";
|
||||||
|
|
@ -30,6 +32,7 @@ import "package:pasteboard/pasteboard.dart";
|
||||||
import "package:path/path.dart";
|
import "package:path/path.dart";
|
||||||
import "package:path_provider/path_provider.dart";
|
import "package:path_provider/path_provider.dart";
|
||||||
import "package:nexus/widgets/composer/relation_preview.dart";
|
import "package:nexus/widgets/composer/relation_preview.dart";
|
||||||
|
import "package:nexus/widgets/composer/composer_replacement.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
|
|
||||||
class const Composer(
|
class const Composer(
|
||||||
|
|
@ -49,6 +52,11 @@ class const Composer(
|
||||||
required bool shouldMention,
|
required bool shouldMention,
|
||||||
})
|
})
|
||||||
onSendSticker,
|
onSendSticker,
|
||||||
|
required final Future<void> Function(
|
||||||
|
PollStart poll, {
|
||||||
|
required bool shouldMention,
|
||||||
|
})
|
||||||
|
onSendPoll,
|
||||||
final FocusNode? node,
|
final FocusNode? node,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends HookConsumerWidget {
|
}) extends HookConsumerWidget {
|
||||||
|
|
@ -70,6 +78,11 @@ class const Composer(
|
||||||
.value
|
.value
|
||||||
?.any((pack) => pack.stickers.isNotEmpty) ??
|
?.any((pack) => pack.stickers.isNotEmpty) ??
|
||||||
false);
|
false);
|
||||||
|
final canSendPolls = ref.watch(
|
||||||
|
PowerLevelController.provider(
|
||||||
|
.new(eventType: .pollStart, roomId: roomId),
|
||||||
|
),
|
||||||
|
);
|
||||||
final relatedEvent = this.relatedEvent == null
|
final relatedEvent = this.relatedEvent == null
|
||||||
? null
|
? null
|
||||||
: ref.watch(
|
: ref.watch(
|
||||||
|
|
@ -95,15 +108,24 @@ class const Composer(
|
||||||
/// The sticker to send, which replaces the text field until it is sent or
|
/// The sticker to send, which replaces the text field until it is sent or
|
||||||
/// removed, as stickers can't have a caption
|
/// removed, as stickers can't have a caption
|
||||||
final sticker = useState<(String, PackImage)?>(null);
|
final sticker = useState<(String, PackImage)?>(null);
|
||||||
|
|
||||||
|
final poll = useState<PollStart?>(null);
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
sticker.value = null;
|
sticker.value = null;
|
||||||
|
poll.value = null;
|
||||||
return null;
|
return null;
|
||||||
}, [roomId]);
|
}, [roomId]);
|
||||||
|
|
||||||
|
final hasReplacement = sticker.value != null || poll.value != null;
|
||||||
|
|
||||||
|
// The relation type stays as edit after cancelling an edit
|
||||||
|
final isEditing = relationType == .edit && relatedEvent != null;
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
if (relationType == .edit && relatedEvent != null) {
|
if (relationType == .edit && relatedEvent != null) {
|
||||||
textBefore.value ??= controller.text;
|
textBefore.value ??= controller.text;
|
||||||
sticker.value = null;
|
sticker.value = null;
|
||||||
|
poll.value = null;
|
||||||
|
|
||||||
final text =
|
final text =
|
||||||
relatedEvent.localContent?.editSource ??
|
relatedEvent.localContent?.editSource ??
|
||||||
|
|
@ -216,11 +238,16 @@ class const Composer(
|
||||||
}
|
}
|
||||||
|
|
||||||
void send() {
|
void send() {
|
||||||
if (sticker.value case (final shortcode, final image)?) {
|
if (hasReplacement) {
|
||||||
onSendSticker(shortcode, image, shouldMention: shouldMention.value);
|
if (sticker.value case (final shortcode, final image)?) {
|
||||||
|
onSendSticker(shortcode, image, shouldMention: shouldMention.value);
|
||||||
|
} else if (poll.value case final draft?) {
|
||||||
|
onSendPoll(draft, shouldMention: shouldMention.value);
|
||||||
|
}
|
||||||
onDismiss();
|
onDismiss();
|
||||||
// The draft is kept, so it can still be sent after the sticker
|
// The draft is kept, so it can still be sent after the replacement
|
||||||
sticker.value = null;
|
sticker.value = null;
|
||||||
|
poll.value = null;
|
||||||
node?.requestFocus();
|
node?.requestFocus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -302,7 +329,7 @@ class const Composer(
|
||||||
)
|
)
|
||||||
? [
|
? [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: sticker.value != null
|
onPressed: hasReplacement
|
||||||
? null
|
? null
|
||||||
: () => context.showAdaptiveSheet(
|
: () => context.showAdaptiveSheet(
|
||||||
(context) => EmojiPicker(
|
(context) => EmojiPicker(
|
||||||
|
|
@ -338,8 +365,7 @@ class const Composer(
|
||||||
),
|
),
|
||||||
AdaptiveMenuButton(
|
AdaptiveMenuButton(
|
||||||
tooltip: "Add media",
|
tooltip: "Add media",
|
||||||
enabled:
|
enabled: attachment == null && !hasReplacement,
|
||||||
attachment == null && sticker.value == null,
|
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
for (final (source, title, icon) in [
|
for (final (source, title, icon) in [
|
||||||
if (Platform.isAndroid || Platform.isIOS)
|
if (Platform.isAndroid || Platform.isIOS)
|
||||||
|
|
@ -386,7 +412,7 @@ class const Composer(
|
||||||
if (canSendStickers)
|
if (canSendStickers)
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
// Stickers can't replace messages
|
// Stickers can't replace messages
|
||||||
enabled: relationType != .edit,
|
enabled: !isEditing,
|
||||||
onTap: () => context.showAdaptiveSheet(
|
onTap: () => context.showAdaptiveSheet(
|
||||||
(context) => StickerPicker(
|
(context) => StickerPicker(
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
|
|
@ -401,6 +427,22 @@ class const Composer(
|
||||||
leading: Icon(Icons.sticky_note_2),
|
leading: Icon(Icons.sticky_note_2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (canSendPolls)
|
||||||
|
PopupMenuItem(
|
||||||
|
enabled: !isEditing,
|
||||||
|
onTap: () => context.showAdaptiveSheet(
|
||||||
|
(context) => CreatePollSheet(
|
||||||
|
onCreate: (created) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
poll.value = created;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text("Poll"),
|
||||||
|
leading: Icon(Icons.poll),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
icon: Icon(Icons.add),
|
icon: Icon(Icons.add),
|
||||||
),
|
),
|
||||||
|
|
@ -409,35 +451,49 @@ class const Composer(
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: Durations.short4,
|
duration: Durations.short4,
|
||||||
alignment: .bottomCenter,
|
alignment: .bottomCenter,
|
||||||
child: switch (sticker.value) {
|
child: switch ((sticker.value, poll.value)) {
|
||||||
(final shortcode, final image)? => Focus(
|
(_, final draft?) => ComposerReplacement(
|
||||||
autofocus: true,
|
removeTooltip: "Remove poll",
|
||||||
|
onRemove: () {
|
||||||
|
poll.value = null;
|
||||||
|
node?.requestFocus();
|
||||||
|
},
|
||||||
onKeyEvent: sendOnEnter,
|
onKeyEvent: sendOnEnter,
|
||||||
child: Padding(
|
ListTile(
|
||||||
padding: .symmetric(vertical: 8),
|
leading: Icon(
|
||||||
child: Row(
|
Icons.poll,
|
||||||
children: [
|
color: theme.colorScheme.primary,
|
||||||
ConstrainedBox(
|
),
|
||||||
constraints: .loose(.square(128)),
|
title: Text(draft.question),
|
||||||
child: MxcEmoji(
|
subtitle: Text(
|
||||||
image.url,
|
draft.answers
|
||||||
label: image.body ?? shortcode,
|
.map((answer) => answer.text)
|
||||||
),
|
.join(" · "),
|
||||||
),
|
maxLines: 1,
|
||||||
Spacer(),
|
overflow: .ellipsis,
|
||||||
IconButton(
|
|
||||||
tooltip: "Remove sticker",
|
|
||||||
onPressed: () {
|
|
||||||
sticker.value = null;
|
|
||||||
node?.requestFocus();
|
|
||||||
},
|
|
||||||
icon: Icon(Icons.close),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
null => FlutterTagger(
|
((final shortcode, final image)?, _) =>
|
||||||
|
ComposerReplacement(
|
||||||
|
removeTooltip: "Remove sticker",
|
||||||
|
onRemove: () {
|
||||||
|
sticker.value = null;
|
||||||
|
node?.requestFocus();
|
||||||
|
},
|
||||||
|
onKeyEvent: sendOnEnter,
|
||||||
|
Align(
|
||||||
|
alignment: .centerLeft,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: .loose(.square(128)),
|
||||||
|
child: MxcEmoji(
|
||||||
|
image.url,
|
||||||
|
label: image.body ?? shortcode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(null, null) => FlutterTagger(
|
||||||
triggerStrategy: .eager,
|
triggerStrategy: .eager,
|
||||||
// Characters that continue a search, for
|
// Characters that continue a search, for
|
||||||
// e.g. :+1, :custom_emoji or @user:server
|
// e.g. :+1, :custom_emoji or @user:server
|
||||||
|
|
|
||||||
30
lib/widgets/composer/composer_replacement.dart
Normal file
30
lib/widgets/composer/composer_replacement.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
|
||||||
|
/// Replaces the composer's text field with something to send, like a sticker.
|
||||||
|
class const ComposerReplacement(
|
||||||
|
final Widget child, {
|
||||||
|
required final String removeTooltip,
|
||||||
|
required final VoidCallback onRemove,
|
||||||
|
final FocusOnKeyEventCallback? onKeyEvent,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Focus(
|
||||||
|
autofocus: true,
|
||||||
|
onKeyEvent: onKeyEvent,
|
||||||
|
child: Padding(
|
||||||
|
padding: .symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
spacing: 12,
|
||||||
|
children: [
|
||||||
|
Expanded(child: child),
|
||||||
|
IconButton(
|
||||||
|
tooltip: removeTooltip,
|
||||||
|
onPressed: onRemove,
|
||||||
|
icon: Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
109
lib/widgets/create_poll_sheet.dart
Normal file
109
lib/widgets/create_poll_sheet.dart
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import "package:collection/collection.dart";
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/models/content/poll_start.dart";
|
||||||
|
import "package:nexus/widgets/sheet_layout.dart";
|
||||||
|
|
||||||
|
class const CreatePollSheet({
|
||||||
|
required final void Function(PollStart poll) onCreate,
|
||||||
|
super.key,
|
||||||
|
}) extends HookWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final questionController = useTextEditingController();
|
||||||
|
final question = useValueListenable(questionController).text.trim();
|
||||||
|
|
||||||
|
// Keyed by ID, so fields keep their text when one above is removed
|
||||||
|
final nextId = useRef(2);
|
||||||
|
final answers = useState<IMap<int, String>>(.new({0: "", 1: ""}));
|
||||||
|
final filledAnswers = answers.value.entries
|
||||||
|
.map(
|
||||||
|
(answer) =>
|
||||||
|
PollAnswer(id: "${answer.key}", text: answer.value.trim()),
|
||||||
|
)
|
||||||
|
.where((answer) => answer.text.isNotEmpty)
|
||||||
|
.toIList();
|
||||||
|
|
||||||
|
final allowMultiple = useState(false);
|
||||||
|
|
||||||
|
final canCreate = question.isNotEmpty && filledAnswers.length >= 2;
|
||||||
|
|
||||||
|
void submit() => onCreate(
|
||||||
|
.new(
|
||||||
|
question: question,
|
||||||
|
answers: filledAnswers,
|
||||||
|
maxSelections: allowMultiple.value ? filledAnswers.length : 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return SheetLayout(
|
||||||
|
title: const Text("Create a Poll"),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: canCreate ? submit : null,
|
||||||
|
child: const Text("Create"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: questionController,
|
||||||
|
autofocus: true,
|
||||||
|
textCapitalization: .sentences,
|
||||||
|
decoration: const .new(labelText: "Question"),
|
||||||
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: Durations.medium2,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
alignment: .topCenter,
|
||||||
|
child: Column(
|
||||||
|
spacing: 12,
|
||||||
|
children: answers.value.entries
|
||||||
|
.mapIndexed(
|
||||||
|
(index, answer) => TextFormField(
|
||||||
|
key: ValueKey(answer.key),
|
||||||
|
initialValue: answer.value,
|
||||||
|
textCapitalization: .sentences,
|
||||||
|
onChanged: (text) =>
|
||||||
|
answers.value = answers.value.add(answer.key, text),
|
||||||
|
decoration: .new(
|
||||||
|
labelText: "Answer ${index + 1}",
|
||||||
|
suffixIcon: answers.value.length > 2
|
||||||
|
? IconButton(
|
||||||
|
tooltip: "Remove answer",
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => answers.value = answers.value
|
||||||
|
.remove(answer.key),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: .centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: answers.value.length < PollStart.maxAnswers
|
||||||
|
? () => answers.value = answers.value.add(nextId.value++, "")
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text("Add Answer"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.checklist),
|
||||||
|
title: const Text("Allow multiple answers"),
|
||||||
|
subtitle: const Text("Voters can choose more than one"),
|
||||||
|
value: allowMultiple.value,
|
||||||
|
onChanged: (value) => allowMultiple.value = value,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:nexus/models/content/message.dart";
|
import "package:nexus/helpers/extensions/is_shown_as_message.dart";
|
||||||
import "package:nexus/models/content/sticker.dart";
|
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
|
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
|
||||||
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
||||||
|
|
@ -10,10 +9,7 @@ class const EventPreview(final Event event, {super.key})
|
||||||
extends StatelessWidget {
|
extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final showSender = switch (event.content) {
|
final showSender = event.hasMessageContent;
|
||||||
MessageContent() || StickerContent() => true,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue