nexus/lib/controllers/room_chat.dart
2026-10-02 17:01:25 -04:00

351 lines
10 KiB
Dart

import "dart:async";
import "dart:math";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
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/models/content/content.dart";
import "package:nexus/models/content/reaction.dart";
import "package:nexus/models/direction.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/requests/redact_event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/send_message.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/room_chat.dart";
typedef ChatParams = ({String roomId, int? contextualEvent});
class RoomChatController(final ChatParams info)
extends AsyncNotifier<RoomChat?> {
@override
Future<RoomChat?> build() async {
final client = ref.read(ClientController.provider.notifier);
final room = ref.read(
RoomsController.provider.select((rooms) => rooms[info.roomId]),
);
if (room == null) return null;
if (!room.hasFetchedState) {
final state = await client.getRoomState(.new(roomId: info.roomId));
ref.read(RoomsController.provider.notifier).addState(info.roomId, state);
}
final timeline = room.timeline
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
.map((e) => e.value)
.toIList()
.addAll(room.clientSticky)
.nonNulls
.toIList();
if (info.contextualEvent == null ||
timeline.contains(info.contextualEvent)) {
ref.watch(
RoomsController.provider.select(
(rooms) =>
(rooms[info.roomId]?.timeline, rooms[info.roomId]?.clientSticky),
),
);
return .new(
timeline: timeline,
hasMoreBackward: room.hasMore,
hasMoreForward: false,
);
} else {
final context = await client.getEventContext(
.new(
roomId: info.roomId,
eventId: room.events[info.contextualEvent!]!.eventId,
),
);
final events = context.before.add(context.event).addAll(context.after);
ref
.read(RoomsController.provider.notifier)
.update(
.new({
info.roomId: Room(
events: IMap.fromIterable(
events,
keyMapper: (event) => event.rowId,
),
),
}),
);
return .new(
timeline: .new(events.map((element) => element.rowId)),
hasMoreBackward: true,
hasMoreForward: true,
historicalData: .new(start: context.start, end: context.end),
);
}
}
Future<void> deleteMessage(Event event, {String? reason}) => ref
.watch(ClientController.provider.notifier)
.redactEvent(
RedactEventRequest(
eventId: event.eventId,
roomId: info.roomId,
reason: reason,
),
);
void hideClientSticky(int eventRowId) => ref
.read(RoomsController.provider.notifier)
.update(
.new({
info.roomId: Room(
resetClientSticky: true,
clientSticky:
ref
.read(
RoomsController.provider.select(
(rooms) => rooms[info.roomId]?.clientSticky,
),
)
?.remove(eventRowId) ??
.new(),
),
}),
);
Future<void> paginate(Direction direction) async {
if (state.isLoading) return;
final chat = await future;
if (direction == .forward
? chat?.hasMoreForward == false
: chat?.hasMoreBackward == false) {
return;
}
state = .loading();
final client = ref.read(ClientController.provider.notifier);
if (chat?.historicalData == null) {
final timelineKeys = ref
.read(RoomsController.provider.select((value) => value[info.roomId]))
?.timeline
.keys;
final response = await client.paginate(
.new(
roomId: info.roomId,
maxTimelineId: timelineKeys?.isNotEmpty == true
? timelineKeys?.reduce(min)
: null,
),
);
if (response.events.isEmpty) {
state = .data(state.value);
}
ref
.read(RoomsController.provider.notifier)
.update(
IMap({
info.roomId: Room(
events: IMap.fromIterable(
response.events.addAll(response.relatedEvents),
keyMapper: (event) => event.rowId,
valueMapper: (event) => event,
),
hasMore: response.hasMore,
timeline: IMap.fromIterable(
response.events,
keyMapper: (event) => event.timelineRowId,
valueMapper: (event) => event.rowId,
),
),
}),
);
} else {
final paginationResponse = await client.paginateManual(
.new(
roomId: info.roomId,
direction: direction,
since: direction == .forward
? chat!.historicalData!.end
: chat!.historicalData!.start,
),
);
ref
.read(RoomsController.provider.notifier)
.update(
.new({
info.roomId: Room(
events: IMap.fromIterable(
paginationResponse.events,
keyMapper: (event) => event.rowId,
),
),
}),
);
final eventRowIds = paginationResponse.events
.map((element) => element.rowId)
.toIList();
state = .data(
.new(
timeline: direction == .forward
? chat.timeline.addAll(eventRowIds)
: eventRowIds.addAll(chat.timeline),
hasMoreForward:
direction == .forward && paginationResponse.nextBatch == null
? false
: chat.hasMoreForward,
hasMoreBackward:
direction == .backward && paginationResponse.nextBatch == null
? false
: chat.hasMoreBackward,
historicalData: chat.historicalData?.copyWith(
start:
(direction == .backward
? paginationResponse.nextBatch
: null) ??
chat.historicalData!.start,
end:
(direction == .forward ? paginationResponse.nextBatch : null) ??
chat.historicalData!.end,
),
),
);
}
}
Future<void> send(
String text, {
bool shouldMention = true,
required IList<Tag> tags,
required RelationType relationType,
int? relatedEventRowId,
}) async {
Content? baseContent;
final relatedEvent = relatedEventRowId == null
? null
: ref.watch(
RoomsController.provider.select(
(rooms) => rooms[info.roomId]?.events[relatedEventRowId],
),
);
if (relationType == .edit && relatedEventRowId != null) {
baseContent = relatedEvent?.content;
} else {
final provider = AttachmentController.provider(info.roomId);
baseContent = ref.read(provider)?.$2;
ref.invalidate(provider);
}
var taggedMessage = text;
for (final tag in tags) {
final escaped = RegExp.escape(tag.id);
final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?");
taggedMessage = taggedMessage.replaceAllMapped(
pattern,
(match) => match.group(1)!,
);
}
final client = ref.read(ClientController.provider.notifier);
final event = await client.sendMessage(
SendMessageRequest(
roomId: info.roomId,
baseContent: baseContent,
mentions: Mentions(
userIds: [
if (shouldMention == true &&
relatedEvent != null &&
relationType == RelationType.reply)
relatedEvent.sender,
].toIList(),
room: taggedMessage.contains("@room"),
),
text: taggedMessage,
relation: relatedEvent == null
? null
: .new(eventId: relatedEvent.eventId, relationType: relationType),
),
);
ref
.read(RoomsController.provider.notifier)
.update(
.new({
info.roomId: .new(
events: .new({event.rowId: event}),
clientSticky: .new({event.rowId}),
),
}),
);
}
Future<void> removeReaction(
String reaction,
Event event,
String userId,
) async {
final client = ref.read(ClientController.provider.notifier);
final allReactionEvents = await client.getRelatedEvents(
.new(
roomId: info.roomId,
eventId: event.eventId,
relationType: "m.annotation",
),
);
final reactionEvents = allReactionEvents
?.where((event) => event.redactedBy == null)
.toIList();
final reactionEvent = reactionEvents?.firstWhereOrNull(
(event) => switch (event.content) {
ReactionContent(:final key) =>
key == reaction && event.sender == userId,
_ => false,
},
);
if (reactionEvent != null) {
await ref
.watch(ClientController.provider.notifier)
.redactEvent(
.new(eventId: reactionEvent.eventId, roomId: info.roomId),
);
}
}
Future<void> sendReaction(String reaction, Event event) async {
final client = ref.read(ClientController.provider.notifier);
await client.sendEvent(
.new(
roomId: info.roomId,
type: EventType.reaction.type,
content: ReactionContent(key: reaction),
synchronous: true,
disableEncryption: true,
relatesTo: event.eventId,
relationType: "m.annotation",
),
);
}
static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
RoomChatController.new,
);
}