This commit is contained in:
Henry Hiles 2026-01-30 02:13:46 +01:00
commit bfd0b1ec47
No known key found for this signature in database
15 changed files with 186 additions and 89 deletions

View file

@ -15,6 +15,7 @@ import "package:nexus/models/event.dart";
import "package:nexus/models/paginate.dart"; import "package:nexus/models/paginate.dart";
import "package:nexus/models/requests/get_event_request.dart"; import "package:nexus/models/requests/get_event_request.dart";
import "package:nexus/models/requests/get_related_events_request.dart"; import "package:nexus/models/requests/get_related_events_request.dart";
import "package:nexus/models/requests/get_room_state_request.dart";
import "package:nexus/models/requests/login_request.dart"; import "package:nexus/models/requests/login_request.dart";
import "package:nexus/models/profile.dart"; import "package:nexus/models/profile.dart";
import "package:nexus/models/requests/paginate_request.dart"; import "package:nexus/models/requests/paginate_request.dart";
@ -117,7 +118,9 @@ class ClientController extends AsyncNotifier<int> {
calloc.free(bufferPointer); calloc.free(bufferPointer);
return response.buf.toJson(); final json = response.buf.toJson();
if (json is String) throw json;
return json;
} }
Future<void> redactEvent(RedactEventRequest report) => Future<void> redactEvent(RedactEventRequest report) =>
@ -140,6 +143,12 @@ class ClientController extends AsyncNotifier<int> {
await _sendCommand("leave_room", {"room_id": room.metadata!.id}); await _sendCommand("leave_room", {"room_id": room.metadata!.id});
} }
Future<IList<Event>> getRoomState(GetRoomStateRequest request) async {
final response =
(await _sendCommand("get_room_state", request.toJson())) as List;
return response.map((event) => Event.fromJson(event)).toIList();
}
Future<IList<Event>?> getRelatedEvents( Future<IList<Event>?> getRelatedEvents(
GetRelatedEventsRequest request, GetRelatedEventsRequest request,
) async { ) async {
@ -157,11 +166,8 @@ class ClientController extends AsyncNotifier<int> {
Future<Paginate> paginate(PaginateRequest request) async => Future<Paginate> paginate(PaginateRequest request) async =>
Paginate.fromJson(await _sendCommand("paginate", request.toJson())); Paginate.fromJson(await _sendCommand("paginate", request.toJson()));
Future<Profile?> getProfile(String userId) async { Future<Profile> getProfile(String userId) async =>
final json = await _sendCommand("get_profile", {"user_id": userId}); Profile.fromJson(await _sendCommand("get_profile", {"user_id": userId}));
return json == null ? null : Profile.fromJson(json);
}
Future<void> reportEvent(ReportRequest report) => Future<void> reportEvent(ReportRequest report) =>
_sendCommand("report_event", report.toJson()); _sendCommand("report_event", report.toJson());

View file

@ -1,22 +1,26 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; 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:matrix/matrix.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/room.dart";
class MembersController extends AsyncNotifier<IList<MatrixEvent>> { class MembersController extends AsyncNotifier<IList<Event>> {
final Room room; final Room room;
MembersController(this.room); MembersController(this.room);
@override @override
Future<IList<MatrixEvent>> build() async => IList( Future<IList<Event>> build() async =>
(await room.client.getMembersByRoom( (room.state["m.room.member"]?.values ?? [])
room.id, .map(
notMembership: Membership.leave, (eventRowId) => room.events.firstWhereOrNull(
)) ?? (event) => event.rowId == eventRowId,
[], ),
); )
.nonNulls
.toIList();
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose<MembersController, IList<MatrixEvent>, Room>( .autoDispose<MembersController, IList<Event>, Room>(
MembersController.new, MembersController.new,
); );
} }

View file

@ -0,0 +1,17 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/models/profile.dart";
class ProfileController extends AsyncNotifier<Profile> {
final String userId;
ProfileController(this.userId);
@override
Future<Profile> build() =>
ref.watch(ClientController.provider.notifier).getProfile(userId);
static final provider =
AsyncNotifierProvider.family<ProfileController, Profile, String>(
ProfileController.new,
);
}

View file

@ -6,14 +6,17 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:fluttertagger/fluttertagger.dart" as tagger; import "package:fluttertagger/fluttertagger.dart" as tagger;
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/new_events_controller.dart"; import "package:nexus/controllers/new_events_controller.dart";
import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/controllers/selected_room_controller.dart";
import "package:nexus/helpers/extensions/event_to_message.dart"; import "package:nexus/helpers/extensions/event_to_message.dart";
import "package:nexus/helpers/extensions/list_to_messages.dart"; import "package:nexus/helpers/extensions/list_to_messages.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/requests/get_room_state_request.dart";
import "package:nexus/models/requests/paginate_request.dart";
import "package:nexus/models/requests/redact_event_request.dart"; import "package:nexus/models/requests/redact_event_request.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/send_message_request.dart"; import "package:nexus/models/requests/send_message_request.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/sync_data.dart";
class RoomChatController extends AsyncNotifier<ChatController> { class RoomChatController extends AsyncNotifier<ChatController> {
final String roomId; final String roomId;
@ -22,9 +25,8 @@ class RoomChatController extends AsyncNotifier<ChatController> {
@override @override
Future<ChatController> build() async { Future<ChatController> build() async {
final client = ref.watch(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
final events = final room = ref.read(SelectedRoomController.provider);
ref.read(SelectedRoomController.provider)?.events ?? if (room == null) return InMemoryChatController();
const IList.empty();
ref.onDispose( ref.onDispose(
ref.listen(NewEventsController.provider(roomId), (_, next) async { ref.listen(NewEventsController.provider(roomId), (_, next) async {
@ -38,7 +40,7 @@ class RoomChatController extends AsyncNotifier<ChatController> {
await controller.removeMessage(message); await controller.removeMessage(message);
} else { } else {
final message = await event.toMessage(client, includeEdits: true); final message = await event.toMessage(ref, includeEdits: true);
if (event.relationType == "m.replace") { if (event.relationType == "m.replace") {
final controller = await future; final controller = await future;
final oldMessage = controller.messages.firstWhereOrNull( final oldMessage = controller.messages.firstWhereOrNull(
@ -69,11 +71,26 @@ class RoomChatController extends AsyncNotifier<ChatController> {
}).close, }).close,
); );
final messages = await events.toMessages(client); final messages = await room.timeline
.map(
(timelineRowTuple) => room.events.firstWhereOrNull(
(event) => event.rowId == timelineRowTuple.eventRowId,
),
)
.nonNulls
.toMessages(ref);
final controller = InMemoryChatController(messages: messages); final controller = InMemoryChatController(messages: messages);
ref.onDispose(controller.dispose);
if (messages.length < 20) await loadOlder(controller); if (messages.length < 20) await loadOlder(controller);
await client.getRoomState(
GetRoomStateRequest(
roomId: roomId,
fetchMembers: room.metadata?.hasMemberList == false,
),
);
return controller; return controller;
} }
@ -109,23 +126,47 @@ class RoomChatController extends AsyncNotifier<ChatController> {
final controller = chatController ?? await future; final controller = chatController ?? await future;
final client = ref.watch(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
client. final response = await client.paginate(
// await ref.watchInMemoryChatController? chatController(EventsController.provider(room).notifier).prev(); PaginateRequest(
// final timeline = await ref.watch(EventsController.provider(room).future); roomId: roomId,
maxTimelineId: controller.messages.firstOrNull?.metadata?["timelineId"],
),
);
// final controller = await future; ref
// await controller.insertAllMessages( .watch(RoomsController.provider.notifier)
// await timeline.events .update(
// .where( IMap({
// (event) => !currentEvents.messages.any( roomId: Room(
// (existingEvent) => existingEvent.id == event.eventId, events: response.events.addAll(response.relatedEvents),
// ), hasMore: response.hasMore,
// ) timeline: response.events
// .toList() .map(
// .toMessages(room, timeline), (event) => TimelineRowTuple(
// index: 0, timelineRowId: event.timelineRowId,
// ); eventRowId: event.rowId,
// ref.notifyListeners(); ),
)
.toIList(),
),
}),
const ISet.empty(),
);
final existingIds = controller.messages.map((m) => m.id).toSet();
final messages = await response.events
.where((event) => !existingIds.contains(event.eventId))
.fold(<String, Event>{}, (acc, event) {
acc[event.eventId] =
event; // overwrites duplicates in response.events
return acc;
})
.values
.toIList()
.reversed
.toMessages(ref);
await controller.insertAllMessages(messages, index: 0);
} }
Future<void> updateMessage(Message message, Message newMessage) async => Future<void> updateMessage(Message message, Message newMessage) async =>
@ -167,7 +208,7 @@ class RoomChatController extends AsyncNotifier<ChatController> {
.getProfile(id); .getProfile(id);
return chat.User( return chat.User(
id: id, id: id,
name: user?.displayName, name: user.displayName,
// imageSource: user.avatarUrl == null // imageSource: user.avatarUrl == null
// ? null // ? null
// : (await ref.watch( // : (await ref.watch(

View file

@ -1,3 +1,4 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; 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/new_events_controller.dart"; import "package:nexus/controllers/new_events_controller.dart";
@ -28,16 +29,22 @@ class RoomsController extends Notifier<IMap<String, Room>> {
), ),
state: incoming.state.entries.fold( state: incoming.state.entries.fold(
existing.state, existing.state,
(stateAcc, event) => stateAcc.add( (previousValue, event) => previousValue.add(
event.key, event.key,
(stateAcc[event.key] ?? IMap<dynamic, dynamic>()).addAll( (previousValue[event.key] ?? const IMap.empty()).addAll(
event.value, event.value,
), ),
), ),
), ),
timeline: incoming.reset timeline:
? incoming.timeline (incoming.reset
: existing.timeline.addAll(incoming.timeline), ? incoming.timeline
: existing.timeline.updateById(
incoming.timeline,
(item) => item.timelineRowId,
))
.sortedBy((element) => element.timelineRowId)
.toIList(),
receipts: incoming.receipts.entries.fold( receipts: incoming.receipts.entries.fold(
existing.receipts, existing.receipts,
(receiptAcc, event) => receiptAcc.add( (receiptAcc, event) => receiptAcc.add(

View file

@ -1,16 +1,19 @@
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/profile_controller.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/requests/get_event_request.dart"; import "package:nexus/models/requests/get_event_request.dart";
import "package:nexus/models/requests/get_related_events_request.dart"; import "package:nexus/models/requests/get_related_events_request.dart";
extension EventToMessage on Event { extension EventToMessage on Event {
Future<Message?> toMessage( Future<Message?> toMessage(
ClientController client, { Ref ref, {
bool mustBeText = false, bool mustBeText = false,
bool includeEdits = false, bool includeEdits = false,
}) async { }) async {
if (relationType == "m.replace" && !includeEdits) return null; if (relationType == "m.replace" && !includeEdits) return null;
final client = ref.watch(ClientController.provider.notifier);
final newEvents = await client.getRelatedEvents( final newEvents = await client.getRelatedEvents(
GetRelatedEventsRequest( GetRelatedEventsRequest(
@ -28,22 +31,25 @@ extension EventToMessage on Event {
GetEventRequest(roomId: roomId, eventId: replyId), GetEventRequest(roomId: roomId, eventId: replyId),
); );
final author = await client.getProfile(event.authorId); final author = await ref.watch(
ProfileController.provider(event.authorId).future,
);
final content = (decrypted ?? this.content); final content = (decrypted ?? this.content);
final type = (decryptedType ?? this.type); final type = (decryptedType ?? this.type);
final newContent = content["m.new_content"] as Map?; final newContent = content["m.new_content"] as Map?;
final metadata = { final metadata = {
"timelineId": event.timelineRowId,
"formatted": "formatted":
newContent?["formatted_body"] ?? newContent?["formatted_body"] ??
newContent?["body"] ?? newContent?["body"] ??
content["formatted_body"] ?? content["formatted_body"] ??
content["body"] ?? content["body"] ??
"", "",
"reply": await replyEvent?.toMessage(client, mustBeText: true), "reply": await replyEvent?.toMessage(ref, mustBeText: true),
"body": newContent?["body"] ?? content["body"], "body": newContent?["body"] ?? content["body"],
"eventType": type, "eventType": type,
"avatarUrl": author?.avatarUrl, "avatarUrl": author.avatarUrl,
"displayName": author?.displayName ?? authorId, "displayName": author.displayName ?? authorId,
"txnId": transactionId, "txnId": transactionId,
}; };

View file

@ -1,12 +1,10 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/helpers/extensions/event_to_message.dart"; import "package:nexus/helpers/extensions/event_to_message.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
extension ListToMessages on IList<Event> { extension ListToMessages on Iterable<Event> {
Future<List<Message>> toMessages(ClientController client) async => Future<List<Message>> toMessages(Ref ref) async => (await Future.wait(
(await Future.wait( map((event) => event.toMessage(ref)),
map((event) => event.toMessage(client)), )).nonNulls.toList();
)).nonNulls.toList();
} }

View file

@ -8,6 +8,7 @@ part "paginate.g.dart";
abstract class Paginate with _$Paginate { abstract class Paginate with _$Paginate {
const factory Paginate({ const factory Paginate({
required IList<Event> events, required IList<Event> events,
required IList<Event> relatedEvents,
required bool hasMore, required bool hasMore,
}) = _Paginate; }) = _Paginate;

View file

@ -0,0 +1,15 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "get_room_state_request.freezed.dart";
part "get_room_state_request.g.dart";
@freezed
abstract class GetRoomStateRequest with _$GetRoomStateRequest {
const factory GetRoomStateRequest({
required String roomId,
required bool fetchMembers,
@Default(false) bool includeMembers,
}) = _GetRoomStateRequest;
factory GetRoomStateRequest.fromJson(Map<String, Object?> json) =>
_$GetRoomStateRequestFromJson(json);
}

View file

@ -6,6 +6,7 @@ part "paginate_request.g.dart";
abstract class PaginateRequest with _$PaginateRequest { abstract class PaginateRequest with _$PaginateRequest {
const factory PaginateRequest({ const factory PaginateRequest({
required String roomId, required String roomId,
required int? maxTimelineId,
@Default(20) int limit, @Default(20) int limit,
}) = _PaginateRequest; }) = _PaginateRequest;

View file

@ -9,8 +9,8 @@ abstract class SendMessageRequest with _$SendMessageRequest {
const factory SendMessageRequest({ const factory SendMessageRequest({
required String roomId, required String roomId,
required String text, required String text,
@Default(Mentions()) @JsonKey(name: "m.mentions") Mentions mentions, @Default(Mentions()) @JsonKey(name: "mentions") Mentions mentions,
@JsonKey(name: "m.relates_to") Relation? relation, @JsonKey(name: "relates_to") Relation? relation,
}) = _SendMessageRequest; }) = _SendMessageRequest;
factory SendMessageRequest.fromJson(Map<String, Object?> json) => factory SendMessageRequest.fromJson(Map<String, Object?> json) =>
@ -28,17 +28,16 @@ abstract class Mentions with _$Mentions {
_$MentionsFromJson(json); _$MentionsFromJson(json);
} }
@freezed @Freezed(toJson: false)
abstract class Relation with _$Relation { abstract class Relation with _$Relation {
const Relation._(); // required for custom methods const Relation._();
const factory Relation({ const factory Relation({
required String eventId, required String eventId,
required RelationType relationType, required RelationType relationType,
}) = _Relation; }) = _Relation;
@override Map<String, dynamic> toJson() {
Map<String, Object?> toJson() {
switch (relationType) { switch (relationType) {
case RelationType.reply: case RelationType.reply:
return { return {
@ -50,6 +49,6 @@ abstract class Relation with _$Relation {
} }
} }
factory Relation.fromJson(Map<String, Object?> json) => factory Relation.fromJson(Map<String, dynamic> json) =>
_$RelationFromJson(json); _$RelationFromJson(json);
} }

View file

@ -11,12 +11,13 @@ abstract class Room with _$Room {
const factory Room({ const factory Room({
@JsonKey(name: "meta") RoomMetadata? metadata, @JsonKey(name: "meta") RoomMetadata? metadata,
@Default(IList.empty()) IList<TimelineRowTuple> timeline, @Default(IList.empty()) IList<TimelineRowTuple> timeline,
required bool reset, @Default(false) bool reset,
required IMap<String, IMap> state, @Default(IMap.empty()) IMap<String, IMap<String, int>> state,
// required IMap<String, AccountData> accountData, // required IMap<String, AccountData> accountData,
required IList<Event> events, @Default(IList.empty()) IList<Event> events,
@Default(IMap.empty()) IMap<String, IList<ReadReceipt>> receipts, @Default(IMap.empty()) IMap<String, IList<ReadReceipt>> receipts,
required bool dismissNotifications, @Default(false) bool dismissNotifications,
@Default(true) bool hasMore,
// required IList<Notification> notifications, // required IList<Notification> notifications,
}) = _Room; }) = _Room;

View file

@ -1,11 +1,8 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/controllers/avatar_controller.dart";
import "package:nexus/controllers/members_controller.dart"; import "package:nexus/controllers/members_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/models/room.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class MemberList extends ConsumerWidget { class MemberList extends ConsumerWidget {
final Room room; final Room room;
@ -36,26 +33,30 @@ class MemberList extends ConsumerWidget {
.where( .where(
(membership) => (membership) =>
membership.content["membership"] == membership.content["membership"] ==
Membership.join.name, "join", // TODO: Show invites seperately
) )
.map( .map(
(member) => ListTile( (member) => ListTile(
onTap: () {}, onTap: () {},
leading: AvatarOrHash( // leading: AvatarOrHash( TODO
ref // ref
.watch( // .watch(
AvatarController.provider( // AvatarController.provider(
member.content["avatar_url"].toString(), // member.content["avatar_url"].toString(),
), // ),
) // )
.whenOrNull(data: (data) => data), // .whenOrNull(data: (data) => data),
member.content["displayname"].toString(), // member.content["displayname"].toString(),
headers: room.client.headers, // headers: room.client.headers,
), // ),
title: Text( title: Text(
member.content["displayname"].toString(), member.content["displayname"].toString(),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
subtitle: Text(
member.authorId,
overflow: TextOverflow.ellipsis,
),
), ),
), ),
], ],

View file

@ -21,12 +21,12 @@ import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/report_request.dart"; import "package:nexus/models/requests/report_request.dart";
import "package:nexus/widgets/chat_page/chat_box.dart"; import "package:nexus/widgets/chat_page/chat_box.dart";
import "package:nexus/widgets/chat_page/html/html.dart"; import "package:nexus/widgets/chat_page/html/html.dart";
import "package:nexus/widgets/chat_page/member_list.dart";
import "package:nexus/widgets/chat_page/room_appbar.dart"; import "package:nexus/widgets/chat_page/room_appbar.dart";
import "package:nexus/widgets/chat_page/top_widget.dart"; import "package:nexus/widgets/chat_page/top_widget.dart";
import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/widgets/form_text_input.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
// import "package:dynamic_polls/dynamic_polls.dart"; // import "package:dynamic_polls/dynamic_polls.dart";
// import "package:matrix/matrix.dart";
class RoomChat extends HookConsumerWidget { class RoomChat extends HookConsumerWidget {
final bool isDesktop; final bool isDesktop;
@ -534,12 +534,12 @@ class RoomChat extends HookConsumerWidget {
), ),
), ),
// if (memberListOpened.value == true && showMembersByDefault) TODO: Member list if (memberListOpened.value == true && showMembersByDefault)
// MemberList(room), MemberList(room),
], ],
), ),
// endDrawer: showMembersByDefault ? null : MemberList(room), endDrawer: showMembersByDefault ? null : MemberList(room),
); );
} }
} }

View file

@ -186,7 +186,7 @@ class Sidebar extends HookConsumerWidget {
? null ? null
: Icon(selectedSpace.icon), : Icon(selectedSpace.icon),
selectedSpace.title, // TODO RM selectedSpace.title,
headers: {}, headers: {},
// space.client.headers, TODO // space.client.headers, TODO
), ),