From fb3b19a27f9320c1e48c3a30226c970bc63acdb6 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 9 May 2026 17:48:20 -0400 Subject: [PATCH 001/108] Reapply "WIP removal of new_events_controller" This reverts commit 4dc16a552930c5bfb4ee8565f69c217169ab01ed. --- lib/controllers/client_controller.dart | 13 +- lib/controllers/new_events_controller.dart | 18 -- lib/controllers/room_chat_controller.dart | 201 +++++++++--------- lib/controllers/rooms_controller.dart | 16 +- pubspec.lock | 48 +++++ pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 8 files changed, 168 insertions(+), 133 deletions(-) delete mode 100644 lib/controllers/new_events_controller.dart diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index cc68871..bbadd8e 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -9,7 +9,7 @@ import "package:flutter/foundation.dart"; import "package:nexus/controllers/account_data_controller.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/init_complete_controller.dart"; -import "package:nexus/controllers/new_events_controller.dart"; +import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/space_edges_controller.dart"; import "package:nexus/controllers/sync_status_controller.dart"; @@ -82,11 +82,10 @@ class ClientController extends AsyncNotifier { final event = Event.fromJson(decodedMuksEvent["event"]); if (event.type == "m.room.message") { - ref - .watch( - NewEventsController.provider(event.roomId).notifier, - ) - .add(IList([event])); + final provider = RoomChatController.provider(event.roomId); + if (ref.exists(provider)) { + ref.watch(provider.notifier).addEvent(event); + } } break; case "sync_complete": @@ -127,9 +126,9 @@ class ClientController extends AsyncNotifier { } debugPrint("Finished handling $muksEventType..."); } catch (error, stackTrace) { + debugPrintStack(stackTrace: stackTrace, label: error.toString()); debugger(); showError(error, stackTrace); - debugPrintStack(stackTrace: stackTrace, label: error.toString()); } }); diff --git a/lib/controllers/new_events_controller.dart b/lib/controllers/new_events_controller.dart deleted file mode 100644 index 215ebd3..0000000 --- a/lib/controllers/new_events_controller.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/models/event.dart"; - -class NewEventsController extends Notifier> { - final String roomId; - NewEventsController(this.roomId); - - @override - IList build() => const IList.empty(); - - void add(IList newEvents) => state = newEvents; - - static final provider = NotifierProvider.autoDispose - .family, String>( - NewEventsController.new, - ); -} diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index fa32bf8..38b839d 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -7,11 +7,11 @@ import "package:fluttertagger/fluttertagger.dart"; import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/message_controller.dart"; import "package:nexus/controllers/messages_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/models/configs/messages_config.dart"; import "package:nexus/models/configs/message_config.dart"; +import "package:nexus/models/event.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/paginate_request.dart"; @@ -77,106 +77,6 @@ class RoomChatController extends AsyncNotifier { ); final controller = InMemoryChatController(messages: messages.toList()); - ref.onDispose( - ref.listen(NewEventsController.provider(roomId), (_, next) async { - for (final event in next) { - if (event.type == "m.reaction") { - final message = controller.messages.firstWhereOrNull( - (message) => - message.id == event.content["m.relates_to"]?["event_id"], - ); - final key = event.content["m.relates_to"]?["key"]; - if (message == null || key == null || !ref.mounted) return; - - return await controller.updateMessage( - message, - message.copyWith( - reactions: IMap(message.reactions) - .update( - key, - (reactors) => [...reactors, event.authorId], - ifAbsent: () => [event.authorId], - ) - .unlock, - ), - ); - } - - if (event.type == "m.room.redaction") { - final controller = await future; - final redactsId = event.content["redacts"]; - final originalMessage = controller.messages.firstWhereOrNull( - (message) => message.id == redactsId, - ); - if (!ref.mounted) return; - - if (originalMessage != null) { - return await controller.removeMessage(originalMessage); - } - - final redacts = ref - .read(SelectedRoomController.provider) - ?.events - .firstWhere((event) => event.eventId == redactsId); - - if (redacts?.type == "m.reaction") { - final message = controller.messages.firstWhereOrNull( - (message) => - message.id == redacts!.content["m.relates_to"]?["event_id"], - ); - final key = redacts!.content["m.relates_to"]?["key"]; - if (message == null || key == null || !ref.mounted) return; - - return await controller.updateMessage( - message, - message.copyWith( - reactions: IMap(message.reactions) - .update( - key, - (reactors) => - IList(reactors).remove(redacts.authorId).unlock, - ) - .where((_, value) => value.isNotEmpty) - .unlock, - ), - ); - } - } else { - final message = await ref.watch( - MessageController.provider( - MessageConfig(event: event, room: room!, includeEdits: true), - ).future, - ); - if (event.relationType == "m.replace") { - final controller = await future; - final oldMessage = controller.messages.firstWhereOrNull( - (element) => element.id == event.relatesTo, - ); - if (oldMessage == null || message == null || !ref.mounted) return; - - return await controller.updateMessage( - oldMessage, - message.copyWith( - id: oldMessage.id, - replyToMessageId: oldMessage.replyToMessageId, - metadata: { - ...(oldMessage.metadata ?? {}), - ...(message.metadata ?? {}) - .toIMap() - .where((key, value) => value != null) - .unlock, - }, - ), - ); - } - if (message != null && ref.mounted) { - await insertMessage(message); - } - } - } - }, weak: true).close, - ); - ref.onDispose(controller.dispose); // While there are under 20 messages, try up to load more messages until theres no more or we have 20 messages. @@ -187,6 +87,105 @@ class RoomChatController extends AsyncNotifier { return controller; } + Future addEvent(Event event) async { + final controller = await future; + if (event.type == "m.reaction") { + final message = controller.messages.firstWhereOrNull( + (message) => message.id == event.content["m.relates_to"]?["event_id"], + ); + final key = event.content["m.relates_to"]?["key"]; + if (message == null || key == null || !ref.mounted) return; + + return await controller.updateMessage( + message, + message.copyWith( + reactions: IMap(message.reactions) + .update( + key, + (reactors) => [...reactors, event.authorId], + ifAbsent: () => [event.authorId], + ) + .unlock, + ), + ); + } + + if (event.type == "m.room.redaction") { + final controller = await future; + final redactsId = event.content["redacts"]; + final originalMessage = controller.messages.firstWhereOrNull( + (message) => message.id == redactsId, + ); + if (!ref.mounted) return; + + if (originalMessage != null) { + return await controller.removeMessage(originalMessage); + } + + final redacts = ref + .read(SelectedRoomController.provider) + ?.events + .firstWhere((event) => event.eventId == redactsId); + + if (redacts?.type == "m.reaction") { + final message = controller.messages.firstWhereOrNull( + (message) => + message.id == redacts!.content["m.relates_to"]?["event_id"], + ); + final key = redacts!.content["m.relates_to"]?["key"]; + if (message == null || key == null || !ref.mounted) return; + + return await controller.updateMessage( + message, + message.copyWith( + reactions: IMap(message.reactions) + .update( + key, + (reactors) => IList(reactors).remove(redacts.authorId).unlock, + ) + .where((_, value) => value.isNotEmpty) + .unlock, + ), + ); + } + } else { + final message = await ref.watch( + MessageController.provider( + MessageConfig( + event: event, + room: ref.read(RoomsController.provider)[roomId]!, + includeEdits: true, + ), + ).future, + ); + if (event.relationType == "m.replace") { + final controller = await future; + final oldMessage = controller.messages.firstWhereOrNull( + (element) => element.id == event.relatesTo, + ); + if (oldMessage == null || message == null || !ref.mounted) return; + + return await controller.updateMessage( + oldMessage, + message.copyWith( + id: oldMessage.id, + replyToMessageId: oldMessage.replyToMessageId, + metadata: { + ...(oldMessage.metadata ?? {}), + ...(message.metadata ?? {}) + .toIMap() + .where((key, value) => value != null) + .unlock, + }, + ), + ); + } + if (message != null && ref.mounted) { + await insertMessage(message); + } + } + } + Future insertMessage(Message message) async { final controller = await future; final oldMessage = message.metadata?["txnId"] == null diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart index 7013de0..efdcfaa 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -2,7 +2,7 @@ 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_state_controller.dart"; -import "package:nexus/controllers/new_events_controller.dart"; +import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/models/read_receipt.dart"; import "package:nexus/models/room.dart"; @@ -34,18 +34,20 @@ class RoomsController extends Notifier> { ); if (addToNewEvents) { - ref - .watch(NewEventsController.provider(roomId).notifier) - .add( - incoming.timeline + final provider = RoomChatController.provider(roomId); + if (ref.exists(provider)) { + for (final event + in incoming.timeline .map( (timelineTuple) => events?.firstWhereOrNull( (event) => timelineTuple.eventRowId == event.rowId, ), ) .nonNulls - .toIList(), - ); + .toIList()) { + ref.read(provider.notifier).addEvent(event); + } + } } return acc.add( diff --git a/pubspec.lock b/pubspec.lock index 9b55aa4..79c5bdf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -935,6 +935,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f7532bf..8d7de4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: emoji_text_field: git: url: https://github.com/Henry-Hiles/emoji_text_field + permission_handler: ^12.0.1 dev_dependencies: build_runner: 2.15.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index bde1c28..9fab8cb 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -17,6 +18,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 7b6b425..12066f6 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_system_colors file_selector_windows + permission_handler_windows screen_retriever_windows url_launcher_windows window_manager -- 2.53.0 From 25888144a679ccf0c88d221900c14c1d912a6831 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 12 May 2026 19:27:23 -0400 Subject: [PATCH 002/108] small fixups --- lib/controllers/room_chat_controller.dart | 7 +++---- lib/controllers/rooms_controller.dart | 18 ------------------ .../wrappers/text_message_wrapper.dart | 2 +- 3 files changed, 4 insertions(+), 23 deletions(-) diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 38b839d..e1401ad 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -75,15 +75,14 @@ class RoomChatController extends AsyncNotifier { ), ).future, ); + + // While there are under 20 messages, try up to load more messages until there's no more or we have 20 messages. final controller = InMemoryChatController(messages: messages.toList()); - - ref.onDispose(controller.dispose); - - // While there are under 20 messages, try up to load more messages until theres no more or we have 20 messages. for (var more = true; more == true && controller.messages.length < 20;) { more = await loadOlder(controller); } + ref.onDispose(controller.dispose); return controller; } diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart index efdcfaa..3c34fc9 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -2,7 +2,6 @@ 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_state_controller.dart"; -import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/models/read_receipt.dart"; import "package:nexus/models/room.dart"; @@ -33,23 +32,6 @@ class RoomsController extends Notifier> { (item) => item.eventId, ); - if (addToNewEvents) { - final provider = RoomChatController.provider(roomId); - if (ref.exists(provider)) { - for (final event - in incoming.timeline - .map( - (timelineTuple) => events?.firstWhereOrNull( - (event) => timelineTuple.eventRowId == event.rowId, - ), - ) - .nonNulls - .toIList()) { - ref.read(provider.notifier).addEvent(event); - } - } - } - return acc.add( roomId, existing?.copyWith( diff --git a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart b/lib/widgets/chat_page/wrappers/text_message_wrapper.dart index 8d7a625..4c84117 100644 --- a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart +++ b/lib/widgets/chat_page/wrappers/text_message_wrapper.dart @@ -136,7 +136,7 @@ class TextMessageWrapper extends ConsumerWidget { onLinkPreviewDataFetched: (_) => null, ), ), - if (extra != null) extra!, + ?extra, ], ), ), -- 2.53.0 From 8bdc1060d327eae63e253a85bbf78fc07c2192c4 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 12 May 2026 19:51:05 -0400 Subject: [PATCH 003/108] remove flutter_chat --- lib/controllers/author_controller.dart | 1 - lib/controllers/message_controller.dart | 1 - lib/controllers/messages_controller.dart | 1 - lib/controllers/room_chat_controller.dart | 1 - lib/controllers/url_preview_controller.dart | 1 - lib/widgets/chat_page/composer/chat_box.dart | 1 - .../chat_page/composer/relation_preview.dart | 1 - .../chat_page/expandable_image_message.dart | 1 - .../lazy_loading/message_avatar.dart | 1 - .../lazy_loading/message_displayname.dart | 1 - lib/widgets/chat_page/reply_widget.dart | 1 - lib/widgets/chat_page/room_chat.dart | 4 - .../chat_page/wrappers/message_wrapper.dart | 1 - .../chat_page/wrappers/reaction_row.dart | 1 - .../wrappers/text_message_wrapper.dart | 1 - linux/nix/pkg/default.nix | 1 - pubspec.lock | 160 +----------------- pubspec.yaml | 7 - .../flutter/generated_plugin_registrant.cc | 3 - windows/flutter/generated_plugins.cmake | 1 - 20 files changed, 4 insertions(+), 186 deletions(-) diff --git a/lib/controllers/author_controller.dart b/lib/controllers/author_controller.dart index 70b7343..2ff9450 100644 --- a/lib/controllers/author_controller.dart +++ b/lib/controllers/author_controller.dart @@ -1,6 +1,5 @@ import "dart:async"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/user_controller.dart"; diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart index c65d18d..3c3483a 100644 --- a/lib/controllers/message_controller.dart +++ b/lib/controllers/message_controller.dart @@ -1,6 +1,5 @@ import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.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_state_controller.dart"; diff --git a/lib/controllers/messages_controller.dart b/lib/controllers/messages_controller.dart index 28885fb..80eea26 100644 --- a/lib/controllers/messages_controller.dart +++ b/lib/controllers/messages_controller.dart @@ -1,5 +1,4 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/message_controller.dart"; import "package:nexus/models/configs/message_config.dart"; diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index e1401ad..ab94e66 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -1,7 +1,6 @@ import "dart:async"; import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:fluttertagger/fluttertagger.dart"; import "package:nexus/controllers/client_controller.dart"; diff --git a/lib/controllers/url_preview_controller.dart b/lib/controllers/url_preview_controller.dart index c2161d5..08770c6 100644 --- a/lib/controllers/url_preview_controller.dart +++ b/lib/controllers/url_preview_controller.dart @@ -1,5 +1,4 @@ import "dart:convert"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:http/http.dart"; import "package:nexus/controllers/client_state_controller.dart"; diff --git a/lib/widgets/chat_page/composer/chat_box.dart b/lib/widgets/chat_page/composer/chat_box.dart index ac44aa3..340010c 100644 --- a/lib/widgets/chat_page/composer/chat_box.dart +++ b/lib/widgets/chat_page/composer/chat_box.dart @@ -1,6 +1,5 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:fluttertagger/fluttertagger.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; diff --git a/lib/widgets/chat_page/composer/relation_preview.dart b/lib/widgets/chat_page/composer/relation_preview.dart index c90b07b..bd7dec1 100644 --- a/lib/widgets/chat_page/composer/relation_preview.dart +++ b/lib/widgets/chat_page/composer/relation_preview.dart @@ -1,5 +1,4 @@ import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; diff --git a/lib/widgets/chat_page/expandable_image_message.dart b/lib/widgets/chat_page/expandable_image_message.dart index f6e8a03..ca7f266 100644 --- a/lib/widgets/chat_page/expandable_image_message.dart +++ b/lib/widgets/chat_page/expandable_image_message.dart @@ -1,6 +1,5 @@ import "package:cross_cache/cross_cache.dart"; import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flyer_chat_image_message/flyer_chat_image_message.dart"; import "package:nexus/controllers/cross_cache_controller.dart"; diff --git a/lib/widgets/chat_page/lazy_loading/message_avatar.dart b/lib/widgets/chat_page/lazy_loading/message_avatar.dart index dc8dfef..3930d1d 100644 --- a/lib/widgets/chat_page/lazy_loading/message_avatar.dart +++ b/lib/widgets/chat_page/lazy_loading/message_avatar.dart @@ -1,5 +1,4 @@ import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/author_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; diff --git a/lib/widgets/chat_page/lazy_loading/message_displayname.dart b/lib/widgets/chat_page/lazy_loading/message_displayname.dart index 88d2fa6..769e764 100644 --- a/lib/widgets/chat_page/lazy_loading/message_displayname.dart +++ b/lib/widgets/chat_page/lazy_loading/message_displayname.dart @@ -1,5 +1,4 @@ import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/author_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; diff --git a/lib/widgets/chat_page/reply_widget.dart b/lib/widgets/chat_page/reply_widget.dart index b999be4..24fcdd7 100644 --- a/lib/widgets/chat_page/reply_widget.dart +++ b/lib/widgets/chat_page/reply_widget.dart @@ -1,5 +1,4 @@ import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/event_controller.dart"; import "package:nexus/controllers/message_controller.dart"; diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 1a1cfdd..e0310fc 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -1,11 +1,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_chat_ui/flutter_chat_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; -import "package:flyer_chat_file_message/flyer_chat_file_message.dart"; -import "package:flyer_chat_system_message/flyer_chat_system_message.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/account_data_controller.dart"; import "package:nexus/controllers/client_controller.dart"; diff --git a/lib/widgets/chat_page/wrappers/message_wrapper.dart b/lib/widgets/chat_page/wrappers/message_wrapper.dart index 9c70c27..472f4a2 100644 --- a/lib/widgets/chat_page/wrappers/message_wrapper.dart +++ b/lib/widgets/chat_page/wrappers/message_wrapper.dart @@ -1,5 +1,4 @@ import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/chat_page/wrappers/reaction_row.dart"; diff --git a/lib/widgets/chat_page/wrappers/reaction_row.dart b/lib/widgets/chat_page/wrappers/reaction_row.dart index 5e8fe86..da7c825 100644 --- a/lib/widgets/chat_page/wrappers/reaction_row.dart +++ b/lib/widgets/chat_page/wrappers/reaction_row.dart @@ -1,7 +1,6 @@ import "package:cross_cache/cross_cache.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state_controller.dart"; diff --git a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart b/lib/widgets/chat_page/wrappers/text_message_wrapper.dart index 4c84117..a3d0192 100644 --- a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart +++ b/lib/widgets/chat_page/wrappers/text_message_wrapper.dart @@ -1,6 +1,5 @@ import "package:cross_cache/cross_cache.dart"; import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_link_previewer/flutter_link_previewer.dart"; import "package:flutter_linkify/flutter_linkify.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index 0e8bc2a..2d92a08 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -24,7 +24,6 @@ flutter.buildFlutterApplication { gitHashes = { window_size = "sha256-XelNtp7tpZ91QCEcvewVphNUtgQX7xrp5QP0oFo6DgM="; dynamic_system_colors = "sha256-GInPqU7r4Kj7+CNBQnf95u0BiagOUI6EtcW0A18pfd0="; - flutter_chat_ui = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk="; flutter_link_previewer = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk="; emoji_text_field = "sha256-3TOys09EP2GRo6pUBGPXaqBlE39O2Cmwt42Hs1cTDKo="; }; diff --git a/pubspec.lock b/pubspec.lock index 79c5bdf..cfd8fcc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,14 +65,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" - blurhash_dart: - dependency: transitive - description: - name: blurhash_dart - sha256: "43955b6c2e30a7d440028d1af0fa185852f3534b795cc6eb81fbf397b464409f" - url: "https://pub.dev" - source: hosted - version: "1.2.1" boolean_selector: dependency: transitive description: @@ -265,14 +257,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" - diffutil_dart: - dependency: transitive - description: - name: diffutil_dart - sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81" - url: "https://pub.dev" - source: hosted - version: "4.0.1" dio: dependency: transitive description: @@ -408,22 +392,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_chat_core: - dependency: "direct main" - description: - name: flutter_chat_core - sha256: "8c46790f64f106bf6e610e2a7324b3844320e9e295867c06d45d9deb134d848d" - url: "https://pub.dev" - source: hosted - version: "2.9.0" - flutter_chat_ui: - dependency: "direct main" - description: - name: flutter_chat_ui - sha256: cfbaac38f429beb33d9cc1ca920ae7ccbadbed282c99335d590d61306d3a3d0f - url: "https://pub.dev" - source: hosted - version: "2.11.1" flutter_hooks: dependency: "direct main" description: @@ -440,14 +408,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.14.4" - flutter_link_previewer: - dependency: "direct main" - description: - name: flutter_link_previewer - sha256: "346f345064e65bc8bf739bccf19d6d6ca50f8183ffc52e452afa58c06ee2cbf7" - url: "https://pub.dev" - source: hosted - version: "4.2.0" flutter_linkify: dependency: "direct main" description: @@ -519,30 +479,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" - flyer_chat_file_message: - dependency: "direct main" - description: - name: flyer_chat_file_message - sha256: "96c5c25908cd671dda1963ade03e188e6a14bba6b116e73fac329f1abefc9ad1" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - flyer_chat_image_message: - dependency: "direct main" - description: - name: flyer_chat_image_message - sha256: "04730c9373c9c7315ba0e1a360c67ac5f6c7ec8a700ffe2d2dc00e29b7f8ff90" - url: "https://pub.dev" - source: hosted - version: "2.3.0" - flyer_chat_system_message: - dependency: "direct main" - description: - name: flyer_chat_system_message - sha256: d254f85be55949f8eb1a4a9a9b1c5b54ffed0c9a39dfa7e4fa6a6358bdb5d45a - url: "https://pub.dev" - source: hosted - version: "2.2.0" freezed: dependency: "direct dev" description: @@ -839,14 +775,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.17.6" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" node_preamble: dependency: transitive description: @@ -935,54 +863,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" - permission_handler: - dependency: "direct main" - description: - name: permission_handler - sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 - url: "https://pub.dev" - source: hosted - version: "12.0.1" - permission_handler_android: - dependency: transitive - description: - name: permission_handler_android - sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" - url: "https://pub.dev" - source: hosted - version: "13.0.1" - permission_handler_apple: - dependency: transitive - description: - name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 - url: "https://pub.dev" - source: hosted - version: "9.4.7" - permission_handler_html: - dependency: transitive - description: - name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" - url: "https://pub.dev" - source: hosted - version: "0.1.3+5" - permission_handler_platform_interface: - dependency: transitive - description: - name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 - url: "https://pub.dev" - source: hosted - version: "4.3.0" - permission_handler_windows: - dependency: transitive - description: - name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" - url: "https://pub.dev" - source: hosted - version: "0.2.1" petitparser: dependency: transitive description: @@ -1023,14 +903,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" - provider: - dependency: transitive - description: - name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" - url: "https://pub.dev" - source: hosted - version: "6.1.5+1" pub_semver: dependency: transitive description: @@ -1047,14 +919,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" - punycode: - dependency: transitive - description: - name: punycode - sha256: "39b874cc1f78b94e57db17e74b3f2ba2a96e25c0bebdcc8a571614dccda0ff0c" - url: "https://pub.dev" - source: hosted - version: "1.0.0" quiver: dependency: transitive description: @@ -1143,14 +1007,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" - scrollview_observer: - dependency: transitive - description: - name: scrollview_observer - sha256: "6e40ced415145c449a691d892157a3b854b751f024aed20d9aebda04c21444a3" - url: "https://pub.dev" - source: hosted - version: "1.26.3" sembast: dependency: transitive description: @@ -1372,14 +1228,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.16" - thumbhash: - dependency: transitive - description: - name: thumbhash - sha256: "5f6d31c5279ca0b5caa81ec10aae8dcaab098d82cb699ea66ada4ed09c794a37" - url: "https://pub.dev" - source: hosted - version: "0.1.0+1" timeago: dependency: "direct main" description: @@ -1480,10 +1328,10 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "6409a25046024f0f8c5d8a59fec314081e81f9d436b66ca4015a8b49772bf445" + sha256: "4d35a36400983c3457c289d4d553b5308f506ea84f7e51c7a564651b5525209a" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" vector_graphics_codec: dependency: transitive description: @@ -1496,10 +1344,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "06f0c50f88a1a020f95138dcc14ef4d5a039ced3f89b386209e6763dfa2cefa0" + sha256: "98e7e94de127b46a86ef46197fff84ff99f3d3b80a708390d717ad731efef598" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8d7de4d..a174c51 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,12 +32,6 @@ dependencies: url: https://github.com/hasali19/flutter_dynamic_system_colors collection: 1.19.1 window_manager: 0.5.1 - flutter_chat_core: 2.9.0 - flyer_chat_image_message: 2.3.0 - flyer_chat_system_message: 2.2.0 - flyer_chat_file_message: 2.4.0 - flutter_chat_ui: 2.11.1 - flutter_link_previewer: 4.2.0 color_hash: 1.0.1 flutter_widget_from_html_core: 0.17.2 flutter_svg: 2.3.0 @@ -57,7 +51,6 @@ dependencies: emoji_text_field: git: url: https://github.com/Henry-Hiles/emoji_text_field - permission_handler: ^12.0.1 dev_dependencies: build_runner: 2.15.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 9fab8cb..bde1c28 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,7 +8,6 @@ #include #include -#include #include #include #include @@ -18,8 +17,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); - PermissionHandlerWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 12066f6..7b6b425 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,7 +5,6 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_system_colors file_selector_windows - permission_handler_windows screen_retriever_windows url_launcher_windows window_manager -- 2.53.0 From cee1298b62284425ad77d61c837be98fca17e1cc Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 12 May 2026 20:08:55 -0400 Subject: [PATCH 004/108] add back custom blurhashing --- .../chat_page/expandable_image_message.dart | 48 ++++++++++++------- pubspec.lock | 8 ++++ pubspec.yaml | 1 + 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/lib/widgets/chat_page/expandable_image_message.dart b/lib/widgets/chat_page/expandable_image_message.dart index ca7f266..5bc2c20 100644 --- a/lib/widgets/chat_page/expandable_image_message.dart +++ b/lib/widgets/chat_page/expandable_image_message.dart @@ -1,34 +1,48 @@ import "package:cross_cache/cross_cache.dart"; import "package:flutter/material.dart"; +import "package:flutter_blurhash/flutter_blurhash.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flyer_chat_image_message/flyer_chat_image_message.dart"; import "package:nexus/controllers/cross_cache_controller.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/widgets/chat_page/expandable_image.dart"; +import "package:nexus/widgets/loading.dart"; class ExpandableImageMessage extends ConsumerWidget { - final ImageMessage message; - final int index; + final String url; + final double? width; + final double? height; + final String? blurHash; - const ExpandableImageMessage(this.message, {required this.index, super.key}); + const ExpandableImageMessage( + this.url, { + this.width, + this.height, + this.blurHash, + super.key, + }); @override Widget build(BuildContext context, WidgetRef ref) => ExpandableImage( - message.source, - child: FlyerChatImageMessage( - customImageProvider: CachedNetworkImage( - message.source, - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - errorBuilder: (context, error, stackTrace) => Center( - child: Text( - "Image Failed to Load", - style: TextStyle(color: Theme.of(context).colorScheme.error), + url, + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: Image( + image: CachedNetworkImage( + url, + ref.watch(CrossCacheController.provider), + headers: ref.headers, + ), + width: width, + height: height, + loadingBuilder: (context, child, loadingProgress) => + blurHash == null ? Loading() : BlurHash(hash: blurHash!), + errorBuilder: (context, error, stackTrace) => Center( + child: Text( + "Image Failed to Load", + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), ), ), - message: message, - index: index, ), ); } diff --git a/pubspec.lock b/pubspec.lock index cfd8fcc..10f28bd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -392,6 +392,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_blurhash: + dependency: "direct main" + description: + name: flutter_blurhash + sha256: e97b9aff13b9930bbaa74d0d899fec76e3f320aba3190322dcc5d32104e3d25d + url: "https://pub.dev" + source: hosted + version: "0.9.1" flutter_hooks: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index a174c51..4e8d609 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,7 @@ dependencies: emoji_text_field: git: url: https://github.com/Henry-Hiles/emoji_text_field + flutter_blurhash: ^0.9.1 dev_dependencies: build_runner: 2.15.0 -- 2.53.0 From 881c76359b61b9c042f08dfd335f43f13e6610ed Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 12 May 2026 20:32:40 -0400 Subject: [PATCH 005/108] custom link previews --- lib/controllers/url_preview_controller.dart | 30 +++------ lib/models/open_graph_data.dart | 17 +++++ .../wrappers/text_message_wrapper.dart | 62 ++++++++++++------- 3 files changed, 66 insertions(+), 43 deletions(-) create mode 100644 lib/models/open_graph_data.dart diff --git a/lib/controllers/url_preview_controller.dart b/lib/controllers/url_preview_controller.dart index 08770c6..dab3d97 100644 --- a/lib/controllers/url_preview_controller.dart +++ b/lib/controllers/url_preview_controller.dart @@ -4,13 +4,14 @@ import "package:http/http.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/header_controller.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; +import "package:nexus/models/open_graph_data.dart"; -class UrlPreviewController extends AsyncNotifier { +class UrlPreviewController extends AsyncNotifier { final String link; UrlPreviewController(this.link); @override - Future build() async { + Future build() async { final homeserver = ref.watch(ClientStateController.provider)?.homeserverUrl; if (homeserver != null && !link.contains("matrix.to")) { @@ -23,28 +24,15 @@ class UrlPreviewController extends AsyncNotifier { ); if (response.statusCode == 200) { - final decodedValue = json.decode(response.body); - final mxc = decodedValue["og:image"]; + final decodedValue = json.decode(response.body) as Map?; + if (decodedValue?.isNotEmpty == true) return null; + + final mxc = decodedValue!["og:image"]; final image = mxc == null ? null : Uri.tryParse(mxc)?.mxcToHttps(homeserver); - return LinkPreviewData( - link: link, - title: decodedValue["og:title"], - description: decodedValue["og:description"], - image: image == null - ? null - : ImagePreviewData( - url: image.toString(), - width: - (decodedValue["og:image:width"] as int?)?.toDouble() ?? - 0, - height: - (decodedValue["og:image:height"] as int?)?.toDouble() ?? - 0, - ), - ); + return OpenGraphData.fromJson({...decodedValue, "og:image": image}); } } } @@ -53,7 +41,7 @@ class UrlPreviewController extends AsyncNotifier { } static final provider = AsyncNotifierProvider.autoDispose - .family( + .family( UrlPreviewController.new, ); } diff --git a/lib/models/open_graph_data.dart b/lib/models/open_graph_data.dart new file mode 100644 index 0000000..4076edd --- /dev/null +++ b/lib/models/open_graph_data.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +part "open_graph_data.freezed.dart"; +part "open_graph_data.g.dart"; + +@freezed +abstract class OpenGraphData with _$OpenGraphData { + const factory OpenGraphData({ + @JsonKey(name: "og:title") required String? title, + @JsonKey(name: "og:description") required String? description, + @JsonKey(name: "og:image") required String? imageUrl, + @JsonKey(name: "og:image:width") required double? width, + @JsonKey(name: "og:image:height") required double? height, + }) = _OpenGraphData; + + factory OpenGraphData.fromJson(Map json) => + _$OpenGraphDataFromJson(json); +} diff --git a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart b/lib/widgets/chat_page/wrappers/text_message_wrapper.dart index a3d0192..2870d23 100644 --- a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart +++ b/lib/widgets/chat_page/wrappers/text_message_wrapper.dart @@ -1,6 +1,5 @@ import "package:cross_cache/cross_cache.dart"; import "package:flutter/material.dart"; -import "package:flutter_link_previewer/flutter_link_previewer.dart"; import "package:flutter_linkify/flutter_linkify.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/cross_cache_controller.dart"; @@ -109,30 +108,49 @@ class TextMessageWrapper extends ConsumerWidget { loading: SizedBox.shrink, data: (preview) => preview == null ? SizedBox.shrink() - : LinkPreview( - onTap: (url) => ref + : InkWell( + onTap: () => ref .watch(LaunchHelper.provider) - .launchUrl(Uri.parse(url)), - imageBuilder: (url) => Image( - image: CachedNetworkImage( - url, - ref.watch(CrossCacheController.provider), - headers: ref.headers, + .launchUrl(Uri.parse(link)), + child: Card( + child: Column( + children: [ + if (preview.title != null) + Text( + preview.title!, + style: theme.textTheme.labelLarge, + ), + if (preview.description != null) + Text(preview.description!), + if (preview.imageUrl != null) + Image( + errorBuilder: (_, _, _) => + SizedBox.shrink(), + width: preview.width, + height: preview.height, + image: CachedNetworkImage( + preview.imageUrl!, + ref.watch( + CrossCacheController.provider, + ), + headers: ref.headers, + ), + fit: BoxFit.cover, + ), + ], ), - fit: BoxFit.cover, - errorBuilder: (_, _, _) => SizedBox.shrink(), + // text: link, + // backgroundColor: isSentByMe + // ? colorScheme.inversePrimary + // : colorScheme.surfaceContainerLow, + // outsidePadding: EdgeInsets.only(top: 4), + // insidePadding: EdgeInsets.symmetric( + // vertical: 8, + // horizontal: 16, + // ), + // linkPreviewData: preview, + // onLinkPreviewDataFetched: (_) => null, ), - text: link, - backgroundColor: isSentByMe - ? colorScheme.inversePrimary - : colorScheme.surfaceContainerLow, - outsidePadding: EdgeInsets.only(top: 4), - insidePadding: EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - linkPreviewData: preview, - onLinkPreviewDataFetched: (_) => null, ), ), ?extra, -- 2.53.0 From c520516d518a5090b1f4225d3006bec4efa83ad4 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 12 May 2026 20:50:55 -0400 Subject: [PATCH 006/108] treewide replace authorId with sender --- lib/controllers/author_controller.dart | 8 ++++---- lib/controllers/message_controller.dart | 20 +++++++++---------- lib/controllers/room_chat_controller.dart | 10 +++++----- lib/models/event.dart | 2 +- .../lazy_loading/message_avatar.dart | 2 +- .../lazy_loading/message_displayname.dart | 2 +- lib/widgets/chat_page/room_chat.dart | 9 +++------ 7 files changed, 25 insertions(+), 28 deletions(-) diff --git a/lib/controllers/author_controller.dart b/lib/controllers/author_controller.dart index 2ff9450..7dcdb23 100644 --- a/lib/controllers/author_controller.dart +++ b/lib/controllers/author_controller.dart @@ -14,14 +14,14 @@ class AuthorController extends AsyncNotifier { @override Future build() async { final member = await ref.watch( - UserController.provider(message.authorId).future, + UserController.provider(message.sender).future, ); final pmp = message.metadata?["pmp"] == null ? null : Membership.fromContent( IMap(message.metadata?["pmp"]), - message.authorId, + message.sender, ref.watch( ClientStateController.provider.select( (value) => value?.homeserverUrl, @@ -34,8 +34,8 @@ class AuthorController extends AsyncNotifier { status: member?.status ?? MembershipStatus.leave, avatarUrl: pmp?.avatarUrl ?? member?.avatarUrl, displayName: - pmp?.displayName ?? member?.displayName ?? message.authorId.localpart, - userId: message.authorId, + pmp?.displayName ?? member?.displayName ?? message.sender.localpart, + userId: message.sender, ); } diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart index 3c3483a..18bee2b 100644 --- a/lib/controllers/message_controller.dart +++ b/lib/controllers/message_controller.dart @@ -89,8 +89,8 @@ class MessageController extends AsyncNotifier { return acc.update( key, - (list) => list.add(event.authorId), - ifAbsent: () => IList([event.authorId]), + (list) => list.add(event.sender), + ifAbsent: () => IList([event.sender]), ); }) .map((key, value) => MapEntry(key, value.unlock)) @@ -101,7 +101,7 @@ class MessageController extends AsyncNotifier { metadata: metadata, id: config.event.eventId, reactions: reactions, - authorId: event.authorId, + sender: event.sender, text: content["formatted_body"] ?? content["body"] ?? "", replyToMessageId: replyId, deliveredAt: config.event.timestamp, @@ -113,7 +113,7 @@ class MessageController extends AsyncNotifier { metadata: {...metadata, "body": content}, id: config.event.eventId, reactions: reactions, - authorId: event.authorId, + sender: event.sender, deliveredAt: config.event.timestamp, text: content, ); @@ -131,12 +131,12 @@ class MessageController extends AsyncNotifier { // }, // id: eventId, // deliveredAt: originServerTs, - // authorId: senderId, + // sender: senderId, // ), ("m.sticker" || "m.room.message") => switch (content["msgtype"]) { null || "m.image" => Message.image( id: config.event.eventId, - authorId: event.authorId, + sender: event.sender, reactions: reactions, source: source, replyToMessageId: replyId, @@ -151,7 +151,7 @@ class MessageController extends AsyncNotifier { metadata: metadata, id: config.event.eventId, reactions: reactions, - authorId: event.authorId, + sender: event.sender, source: source, replyToMessageId: replyId, deliveredAt: config.event.timestamp, @@ -165,7 +165,7 @@ class MessageController extends AsyncNotifier { "${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) { "invite" => "was invited to", "join" => "joined", - "leave" => event.authorId == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), + "leave" => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), "ban" => "was banned from", "knock" => "asked to join", _ => "did something relating to", @@ -173,7 +173,7 @@ class MessageController extends AsyncNotifier { ), "m.room.server_acl" => toSystemMessage( - "${event.authorId} updated the server ban list.", + "${event.sender} updated the server ban list.", ), "m.room.redaction" => @@ -196,7 +196,7 @@ class MessageController extends AsyncNotifier { metadata: metadata, reactions: reactions, id: config.event.eventId, - authorId: event.authorId, + sender: event.sender, replyToMessageId: replyId, ) : null), diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index ab94e66..a9e838b 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -100,8 +100,8 @@ class RoomChatController extends AsyncNotifier { reactions: IMap(message.reactions) .update( key, - (reactors) => [...reactors, event.authorId], - ifAbsent: () => [event.authorId], + (reactors) => [...reactors, event.sender], + ifAbsent: () => [event.sender], ) .unlock, ), @@ -139,7 +139,7 @@ class RoomChatController extends AsyncNotifier { reactions: IMap(message.reactions) .update( key, - (reactors) => IList(reactors).remove(redacts.authorId).unlock, + (reactors) => IList(reactors).remove(redacts.sender).unlock, ) .where((_, value) => value.isNotEmpty) .unlock, @@ -291,7 +291,7 @@ class RoomChatController extends AsyncNotifier { if (shouldMention == true && relation != null && relationType == RelationType.reply) - relation.authorId, + relation.sender, ].toIList(), room: taggedMessage.contains("@room"), ), @@ -347,7 +347,7 @@ class RoomChatController extends AsyncNotifier { final reactionEvent = reactionEvents?.firstWhereOrNull( (event) => - event.authorId == userId && + event.sender == userId && event.content["m.relates_to"]?["key"] == reaction, ); diff --git a/lib/models/event.dart b/lib/models/event.dart index 734f667..4a72817 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -11,7 +11,7 @@ abstract class Event with _$Event { @JsonKey(name: "timeline_rowid") required int timelineRowId, required String roomId, required String eventId, - @JsonKey(name: "sender") required String authorId, + required String sender, required String type, String? stateKey, @EpochDateTimeConverter() required DateTime timestamp, diff --git a/lib/widgets/chat_page/lazy_loading/message_avatar.dart b/lib/widgets/chat_page/lazy_loading/message_avatar.dart index 3930d1d..4cc6665 100644 --- a/lib/widgets/chat_page/lazy_loading/message_avatar.dart +++ b/lib/widgets/chat_page/lazy_loading/message_avatar.dart @@ -26,6 +26,6 @@ class MessageAvatar extends ConsumerWidget { ), ), loading: () => - AvatarOrHash(null, message.authorId.substring(1), height: height), + AvatarOrHash(null, message.sender.substring(1), height: height), ); } diff --git a/lib/widgets/chat_page/lazy_loading/message_displayname.dart b/lib/widgets/chat_page/lazy_loading/message_displayname.dart index 769e764..72565e6 100644 --- a/lib/widgets/chat_page/lazy_loading/message_displayname.dart +++ b/lib/widgets/chat_page/lazy_loading/message_displayname.dart @@ -27,7 +27,7 @@ class MessageDisplayname extends ConsumerWidget { ) : null, child: Text( - "${membership.displayName}${message.metadata?["pmp"] == null ? "" : " (via ${message.authorId})"}", + "${membership.displayName}${message.metadata?["pmp"] == null ? "" : " (via ${message.sender})"}", style: style, overflow: TextOverflow.ellipsis, ), diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index e0310fc..dc5cb11 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -81,7 +81,7 @@ class RoomChat extends HookConsumerWidget { ); List getMessageOptions(Message message) { - final isSentByMe = message.authorId == userId; + final isSentByMe = message.sender == userId; return [ if (ref.watch( PowerLevelController.provider( @@ -405,10 +405,7 @@ class RoomChat extends HookConsumerWidget { onTapReply: notifier.scrollToMessage, updateMessage: controller.updateMessage, isSentByMe: isSentByMe, - extra: ExpandableImageMessage( - message, - index: index, - ), + extra: ExpandableImageMessage(message), ), fileMessageBuilder: @@ -462,7 +459,7 @@ class RoomChat extends HookConsumerWidget { required bool isSentByMe, MessageGroupStatus? groupStatus, }) => Text( - "${message.authorId} sent ${message.metadata?["eventType"]}", + "${message.sender} sent ${message.metadata?["eventType"]}", style: theme.textTheme.labelSmall?.copyWith( color: Colors.grey, ), -- 2.53.0 From b3db9bea6f4b46e27b0c02468ac609292521ca85 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 13 May 2026 14:32:12 -0400 Subject: [PATCH 007/108] possible way to union event --- README.md | 2 +- lib/controllers/client_controller.dart | 2 +- lib/controllers/event_controller.dart | 2 +- lib/controllers/room_chat_controller.dart | 2 +- lib/controllers/via_controller.dart | 1 + lib/models/configs/message_config.dart | 2 +- lib/models/configs/messages_config.dart | 2 +- lib/models/event.dart | 80 ----------------------- lib/models/event/content/membership.dart | 38 +++++++++++ lib/models/event/event.dart | 18 +++++ lib/models/event/info.dart | 38 +++++++++++ lib/models/event/local_content.dart | 18 +++++ lib/models/event/unread_type.dart | 29 ++++++++ lib/models/paginate.dart | 2 +- lib/models/room.dart | 2 +- pubspec.lock | 24 +++---- pubspec.yaml | 14 +++- 17 files changed, 175 insertions(+), 101 deletions(-) delete mode 100644 lib/models/event.dart create mode 100644 lib/models/event/content/membership.dart create mode 100644 lib/models/event/event.dart create mode 100644 lib/models/event/info.dart create mode 100644 lib/models/event/local_content.dart create mode 100644 lib/models/event/unread_type.dart diff --git a/README.md b/README.md index 2c44fc8..08d479f 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ dart scripts/generate.dart Build generated files, and watch for new changes: ```sh -flutter pub run build_runner watch --delete-conflicting-outputs +flutter pub run build_runner watch ``` Run the app: diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index bbadd8e..c666c87 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -17,7 +17,7 @@ import "package:nexus/controllers/top_level_spaces_controller.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/main.dart"; import "package:nexus/models/client_state.dart"; -import "package:nexus/models/event.dart"; +import "package:nexus/models/event/event.dart"; import "package:nexus/models/paginate.dart"; import "package:nexus/models/requests/get_event_request.dart"; import "package:nexus/models/requests/get_related_events_request.dart"; diff --git a/lib/controllers/event_controller.dart b/lib/controllers/event_controller.dart index 4f72963..6db926f 100644 --- a/lib/controllers/event_controller.dart +++ b/lib/controllers/event_controller.dart @@ -1,6 +1,6 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/models/event.dart"; +import "package:nexus/models/event/event.dart"; import "package:nexus/models/requests/get_event_request.dart"; class EventController extends AsyncNotifier { diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index a9e838b..70b4745 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -10,7 +10,7 @@ import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/models/configs/messages_config.dart"; import "package:nexus/models/configs/message_config.dart"; -import "package:nexus/models/event.dart"; +import "package:nexus/models/event/event.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/paginate_request.dart"; diff --git a/lib/controllers/via_controller.dart b/lib/controllers/via_controller.dart index b423947..fdd38dc 100644 --- a/lib/controllers/via_controller.dart +++ b/lib/controllers/via_controller.dart @@ -2,6 +2,7 @@ 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_state_controller.dart"; +import "package:nexus/models/event/event.dart"; import "package:nexus/models/room.dart"; class ViaController extends Notifier { diff --git a/lib/models/configs/message_config.dart b/lib/models/configs/message_config.dart index 66a437c..767372d 100644 --- a/lib/models/configs/message_config.dart +++ b/lib/models/configs/message_config.dart @@ -1,5 +1,5 @@ import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event.dart"; +import "package:nexus/models/event/event.dart"; import "package:nexus/models/room.dart"; part "message_config.freezed.dart"; part "message_config.g.dart"; diff --git a/lib/models/configs/messages_config.dart b/lib/models/configs/messages_config.dart index b33a71c..5944df8 100644 --- a/lib/models/configs/messages_config.dart +++ b/lib/models/configs/messages_config.dart @@ -1,6 +1,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event.dart"; +import "package:nexus/models/event/event.dart"; import "package:nexus/models/room.dart"; part "messages_config.freezed.dart"; part "messages_config.g.dart"; diff --git a/lib/models/event.dart b/lib/models/event.dart deleted file mode 100644 index 4a72817..0000000 --- a/lib/models/event.dart +++ /dev/null @@ -1,80 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/epoch_date_time_converter.dart"; -part "event.freezed.dart"; -part "event.g.dart"; - -@freezed -abstract class Event with _$Event { - const factory Event({ - @JsonKey(name: "rowid") required int rowId, - @JsonKey(name: "timeline_rowid") required int timelineRowId, - required String roomId, - required String eventId, - required String sender, - required String type, - String? stateKey, - @EpochDateTimeConverter() required DateTime timestamp, - required IMap content, - IMap? decrypted, - String? decryptedType, - @Default(IMap.empty()) IMap unsigned, - LocalContent? localContent, - String? transactionId, - String? redactedBy, - String? relatesTo, - String? relationType, - String? decryptionError, - String? sendError, - @Default(IMap.empty()) IMap reactions, - @JsonKey(name: "last_edit_rowid") int? lastEditRowId, - @UnreadTypeConverter() UnreadType? unreadType, - }) = _Event; - - factory Event.fromJson(Map json) => _$EventFromJson(json); -} - -@freezed -abstract class LocalContent with _$LocalContent { - const factory LocalContent({ - String? sanitizedHtml, - String? editSource, - bool? wasPlaintext, - bool? bigEmoji, - bool? hasMath, - bool? replyFallbackRemoved, - }) = _LocalContent; - - factory LocalContent.fromJson(Map json) => - _$LocalContentFromJson(json); -} - -class UnreadTypeConverter implements JsonConverter { - const UnreadTypeConverter(); - - @override - UnreadType? fromJson(int? json) => json == null ? null : UnreadType(json); - - @override - int? toJson(UnreadType? object) => object?.value; -} - -// I think this is correct but I'm not sure, its some type of bitmask. -@immutable -class UnreadType { - final int value; - - const UnreadType(this.value); - - static const none = UnreadType(0); - static const normal = UnreadType(1); - static const notify = UnreadType(2); - static const highlight = UnreadType(4); - static const sound = UnreadType(8); - - bool get isNone => value == 0; - bool get isNormal => (value & 1) != 0; - bool get shouldNotify => (value & 2) != 0; - bool get isHighlighted => (value & 4) != 0; - bool get playsSound => (value & 8) != 0; -} diff --git a/lib/models/event/content/membership.dart b/lib/models/event/content/membership.dart new file mode 100644 index 0000000..4f09584 --- /dev/null +++ b/lib/models/event/content/membership.dart @@ -0,0 +1,38 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; +import "package:nexus/models/event/local_content.dart"; +import "package:nexus/models/event/unread_type.dart"; +part "info.freezed.dart"; +part "info.g.dart"; + +@Freezed(unionKey: "type") +abstract class EventInfo with _$EventInfo { + const factory EventInfo({ + @JsonKey(name: "rowid") required int rowId, + @JsonKey(name: "timeline_rowid") required int timelineRowId, + required String type, + required String roomId, + required String eventinfoId, + required String sender, + String? stateKey, + @EpochDateTimeConverter() required DateTime timestamp, + @JsonKey(name: "content") required IMap rawContent, + IMap? decrypted, + String? decryptedType, + @Default(IMap.empty()) IMap unsigned, + LocalContent? localContent, + String? transactionId, + String? redactedBy, + String? relatesTo, + String? relationType, + String? decryptionError, + String? sendError, + @Default(IMap.empty()) IMap reactions, + @JsonKey(name: "last_edit_rowid") int? lastEditRowId, + @UnreadTypeConverter() UnreadType? unreadType, + }) = _EventInfo; + + factory EventInfo.fromJson(Map json) => + _$EventInfoFromJson(json); +} diff --git a/lib/models/event/event.dart b/lib/models/event/event.dart new file mode 100644 index 0000000..b081e18 --- /dev/null +++ b/lib/models/event/event.dart @@ -0,0 +1,18 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/event/info.dart"; +part "event.freezed.dart"; +part "event.g.dart"; + +@Freezed(unionKey: "type") +abstract class Event with _$Event { + const factory Event({@JsonKey(flatten: true) required EventInfo info}) = + _GenericEvent; + + @FreezedUnionValue("m.room.member") + const factory Event.membership({ + @JsonKey(flatten: true) required EventInfo info, + required String content, + }) = _MemberEvent; + + factory Event.fromJson(Map json) => _$EventFromJson(json); +} diff --git a/lib/models/event/info.dart b/lib/models/event/info.dart new file mode 100644 index 0000000..4f09584 --- /dev/null +++ b/lib/models/event/info.dart @@ -0,0 +1,38 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; +import "package:nexus/models/event/local_content.dart"; +import "package:nexus/models/event/unread_type.dart"; +part "info.freezed.dart"; +part "info.g.dart"; + +@Freezed(unionKey: "type") +abstract class EventInfo with _$EventInfo { + const factory EventInfo({ + @JsonKey(name: "rowid") required int rowId, + @JsonKey(name: "timeline_rowid") required int timelineRowId, + required String type, + required String roomId, + required String eventinfoId, + required String sender, + String? stateKey, + @EpochDateTimeConverter() required DateTime timestamp, + @JsonKey(name: "content") required IMap rawContent, + IMap? decrypted, + String? decryptedType, + @Default(IMap.empty()) IMap unsigned, + LocalContent? localContent, + String? transactionId, + String? redactedBy, + String? relatesTo, + String? relationType, + String? decryptionError, + String? sendError, + @Default(IMap.empty()) IMap reactions, + @JsonKey(name: "last_edit_rowid") int? lastEditRowId, + @UnreadTypeConverter() UnreadType? unreadType, + }) = _EventInfo; + + factory EventInfo.fromJson(Map json) => + _$EventInfoFromJson(json); +} diff --git a/lib/models/event/local_content.dart b/lib/models/event/local_content.dart new file mode 100644 index 0000000..98d69d2 --- /dev/null +++ b/lib/models/event/local_content.dart @@ -0,0 +1,18 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +part "local_content.g.dart"; +part "local_content.freezed.dart"; + +@freezed +abstract class LocalContent with _$LocalContent { + const factory LocalContent({ + String? sanitizedHtml, + String? editSource, + bool? wasPlaintext, + bool? bigEmoji, + bool? hasMath, + bool? replyFallbackRemoved, + }) = _LocalContent; + + factory LocalContent.fromJson(Map json) => + _$LocalContentFromJson(json); +} diff --git a/lib/models/event/unread_type.dart b/lib/models/event/unread_type.dart new file mode 100644 index 0000000..bc40718 --- /dev/null +++ b/lib/models/event/unread_type.dart @@ -0,0 +1,29 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +class UnreadTypeConverter implements JsonConverter { + const UnreadTypeConverter(); + + @override + UnreadType? fromJson(int? json) => json == null ? null : UnreadType(json); + + @override + int? toJson(UnreadType? object) => object?.value; +} + +@immutable +class UnreadType { + final int value; + const UnreadType(this.value); + + static const none = UnreadType(0); + static const normal = UnreadType(1); + static const notify = UnreadType(2); + static const highlight = UnreadType(4); + static const sound = UnreadType(8); + + bool get isNone => value == 0; + bool get isNormal => (value & 1) != 0; + bool get shouldNotify => (value & 2) != 0; + bool get isHighlighted => (value & 4) != 0; + bool get playsSound => (value & 8) != 0; +} diff --git a/lib/models/paginate.dart b/lib/models/paginate.dart index df0a0f6..64fb5ec 100644 --- a/lib/models/paginate.dart +++ b/lib/models/paginate.dart @@ -1,6 +1,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event.dart"; +import "package:nexus/models/event/event.dart"; part "paginate.freezed.dart"; part "paginate.g.dart"; diff --git a/lib/models/room.dart b/lib/models/room.dart index 3c3eec0..a369f2b 100644 --- a/lib/models/room.dart +++ b/lib/models/room.dart @@ -1,6 +1,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event.dart"; +import "package:nexus/models/event/event.dart"; import "package:nexus/models/read_receipt.dart"; import "package:nexus/models/room_metadata.dart"; part "room.freezed.dart"; diff --git a/pubspec.lock b/pubspec.lock index 10f28bd..bd98a0d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -680,21 +680,23 @@ packages: source: hosted version: "1.0.5" json_annotation: - dependency: "direct main" + dependency: "direct overridden" description: - name: json_annotation - sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 - url: "https://pub.dev" - source: hosted - version: "4.11.0" + path: json_annotation + ref: "feature/json-key-flatten-2" + resolved-ref: "292d155b643b5f6fd956399d16a45711a0512ecd" + url: "https://github.com/helgoboss/json_serializable.dart" + source: git + version: "4.9.1-wip" json_serializable: dependency: "direct dev" description: - name: json_serializable - sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" - url: "https://pub.dev" - source: hosted - version: "6.13.0" + path: json_serializable + ref: "feature/json-key-flatten-2" + resolved-ref: "292d155b643b5f6fd956399d16a45711a0512ecd" + url: "https://github.com/helgoboss/json_serializable.dart" + source: git + version: "6.11.4" leak_tracker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4e8d609..91fa146 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,13 @@ flutter: environment: sdk: "3.11.4" +dependency_overrides: + json_annotation: + git: + url: https://github.com/helgoboss/json_serializable.dart + ref: feature/json-key-flatten-2 + path: json_annotation + dependencies: flutter: sdk: flutter @@ -35,7 +42,6 @@ dependencies: color_hash: 1.0.1 flutter_widget_from_html_core: 0.17.2 flutter_svg: 2.3.0 - json_annotation: 4.11.0 shared_preferences: 2.5.5 fluttertagger: 2.3.2 dynamic_polls: 0.0.7 @@ -59,7 +65,11 @@ dev_dependencies: freezed: 3.2.5 riverpod_lint: 3.1.3 flutter_launcher_icons: 0.14.4 - json_serializable: 6.13.0 + json_serializable: + git: + url: https://github.com/helgoboss/json_serializable.dart + ref: feature/json-key-flatten-2 + path: json_serializable flutter_launcher_icons: ios: true -- 2.53.0 From 6af56ccb3e9b9de7626e96c173d6d0e4a67fa8d4 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 13 May 2026 14:32:23 -0400 Subject: [PATCH 008/108] Revert "possible way to union event" This reverts commit b3db9bea6f4b46e27b0c02468ac609292521ca85. --- README.md | 2 +- lib/controllers/client_controller.dart | 2 +- lib/controllers/event_controller.dart | 2 +- lib/controllers/room_chat_controller.dart | 2 +- lib/controllers/via_controller.dart | 1 - lib/models/configs/message_config.dart | 2 +- lib/models/configs/messages_config.dart | 2 +- lib/models/event.dart | 80 +++++++++++++++++++++++ lib/models/event/content/membership.dart | 38 ----------- lib/models/event/event.dart | 18 ----- lib/models/event/info.dart | 38 ----------- lib/models/event/local_content.dart | 18 ----- lib/models/event/unread_type.dart | 29 -------- lib/models/paginate.dart | 2 +- lib/models/room.dart | 2 +- pubspec.lock | 24 ++++--- pubspec.yaml | 14 +--- 17 files changed, 101 insertions(+), 175 deletions(-) create mode 100644 lib/models/event.dart delete mode 100644 lib/models/event/content/membership.dart delete mode 100644 lib/models/event/event.dart delete mode 100644 lib/models/event/info.dart delete mode 100644 lib/models/event/local_content.dart delete mode 100644 lib/models/event/unread_type.dart diff --git a/README.md b/README.md index 08d479f..2c44fc8 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ dart scripts/generate.dart Build generated files, and watch for new changes: ```sh -flutter pub run build_runner watch +flutter pub run build_runner watch --delete-conflicting-outputs ``` Run the app: diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index c666c87..bbadd8e 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -17,7 +17,7 @@ import "package:nexus/controllers/top_level_spaces_controller.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/main.dart"; import "package:nexus/models/client_state.dart"; -import "package:nexus/models/event/event.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/models/paginate.dart"; import "package:nexus/models/requests/get_event_request.dart"; import "package:nexus/models/requests/get_related_events_request.dart"; diff --git a/lib/controllers/event_controller.dart b/lib/controllers/event_controller.dart index 6db926f..4f72963 100644 --- a/lib/controllers/event_controller.dart +++ b/lib/controllers/event_controller.dart @@ -1,6 +1,6 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/models/event/event.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_event_request.dart"; class EventController extends AsyncNotifier { diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 70b4745..a9e838b 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -10,7 +10,7 @@ import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/models/configs/messages_config.dart"; import "package:nexus/models/configs/message_config.dart"; -import "package:nexus/models/event/event.dart"; +import "package:nexus/models/event.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/paginate_request.dart"; diff --git a/lib/controllers/via_controller.dart b/lib/controllers/via_controller.dart index fdd38dc..b423947 100644 --- a/lib/controllers/via_controller.dart +++ b/lib/controllers/via_controller.dart @@ -2,7 +2,6 @@ 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_state_controller.dart"; -import "package:nexus/models/event/event.dart"; import "package:nexus/models/room.dart"; class ViaController extends Notifier { diff --git a/lib/models/configs/message_config.dart b/lib/models/configs/message_config.dart index 767372d..66a437c 100644 --- a/lib/models/configs/message_config.dart +++ b/lib/models/configs/message_config.dart @@ -1,5 +1,5 @@ import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event/event.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/models/room.dart"; part "message_config.freezed.dart"; part "message_config.g.dart"; diff --git a/lib/models/configs/messages_config.dart b/lib/models/configs/messages_config.dart index 5944df8..b33a71c 100644 --- a/lib/models/configs/messages_config.dart +++ b/lib/models/configs/messages_config.dart @@ -1,6 +1,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event/event.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/models/room.dart"; part "messages_config.freezed.dart"; part "messages_config.g.dart"; diff --git a/lib/models/event.dart b/lib/models/event.dart new file mode 100644 index 0000000..4a72817 --- /dev/null +++ b/lib/models/event.dart @@ -0,0 +1,80 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; +part "event.freezed.dart"; +part "event.g.dart"; + +@freezed +abstract class Event with _$Event { + const factory Event({ + @JsonKey(name: "rowid") required int rowId, + @JsonKey(name: "timeline_rowid") required int timelineRowId, + required String roomId, + required String eventId, + required String sender, + required String type, + String? stateKey, + @EpochDateTimeConverter() required DateTime timestamp, + required IMap content, + IMap? decrypted, + String? decryptedType, + @Default(IMap.empty()) IMap unsigned, + LocalContent? localContent, + String? transactionId, + String? redactedBy, + String? relatesTo, + String? relationType, + String? decryptionError, + String? sendError, + @Default(IMap.empty()) IMap reactions, + @JsonKey(name: "last_edit_rowid") int? lastEditRowId, + @UnreadTypeConverter() UnreadType? unreadType, + }) = _Event; + + factory Event.fromJson(Map json) => _$EventFromJson(json); +} + +@freezed +abstract class LocalContent with _$LocalContent { + const factory LocalContent({ + String? sanitizedHtml, + String? editSource, + bool? wasPlaintext, + bool? bigEmoji, + bool? hasMath, + bool? replyFallbackRemoved, + }) = _LocalContent; + + factory LocalContent.fromJson(Map json) => + _$LocalContentFromJson(json); +} + +class UnreadTypeConverter implements JsonConverter { + const UnreadTypeConverter(); + + @override + UnreadType? fromJson(int? json) => json == null ? null : UnreadType(json); + + @override + int? toJson(UnreadType? object) => object?.value; +} + +// I think this is correct but I'm not sure, its some type of bitmask. +@immutable +class UnreadType { + final int value; + + const UnreadType(this.value); + + static const none = UnreadType(0); + static const normal = UnreadType(1); + static const notify = UnreadType(2); + static const highlight = UnreadType(4); + static const sound = UnreadType(8); + + bool get isNone => value == 0; + bool get isNormal => (value & 1) != 0; + bool get shouldNotify => (value & 2) != 0; + bool get isHighlighted => (value & 4) != 0; + bool get playsSound => (value & 8) != 0; +} diff --git a/lib/models/event/content/membership.dart b/lib/models/event/content/membership.dart deleted file mode 100644 index 4f09584..0000000 --- a/lib/models/event/content/membership.dart +++ /dev/null @@ -1,38 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/epoch_date_time_converter.dart"; -import "package:nexus/models/event/local_content.dart"; -import "package:nexus/models/event/unread_type.dart"; -part "info.freezed.dart"; -part "info.g.dart"; - -@Freezed(unionKey: "type") -abstract class EventInfo with _$EventInfo { - const factory EventInfo({ - @JsonKey(name: "rowid") required int rowId, - @JsonKey(name: "timeline_rowid") required int timelineRowId, - required String type, - required String roomId, - required String eventinfoId, - required String sender, - String? stateKey, - @EpochDateTimeConverter() required DateTime timestamp, - @JsonKey(name: "content") required IMap rawContent, - IMap? decrypted, - String? decryptedType, - @Default(IMap.empty()) IMap unsigned, - LocalContent? localContent, - String? transactionId, - String? redactedBy, - String? relatesTo, - String? relationType, - String? decryptionError, - String? sendError, - @Default(IMap.empty()) IMap reactions, - @JsonKey(name: "last_edit_rowid") int? lastEditRowId, - @UnreadTypeConverter() UnreadType? unreadType, - }) = _EventInfo; - - factory EventInfo.fromJson(Map json) => - _$EventInfoFromJson(json); -} diff --git a/lib/models/event/event.dart b/lib/models/event/event.dart deleted file mode 100644 index b081e18..0000000 --- a/lib/models/event/event.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event/info.dart"; -part "event.freezed.dart"; -part "event.g.dart"; - -@Freezed(unionKey: "type") -abstract class Event with _$Event { - const factory Event({@JsonKey(flatten: true) required EventInfo info}) = - _GenericEvent; - - @FreezedUnionValue("m.room.member") - const factory Event.membership({ - @JsonKey(flatten: true) required EventInfo info, - required String content, - }) = _MemberEvent; - - factory Event.fromJson(Map json) => _$EventFromJson(json); -} diff --git a/lib/models/event/info.dart b/lib/models/event/info.dart deleted file mode 100644 index 4f09584..0000000 --- a/lib/models/event/info.dart +++ /dev/null @@ -1,38 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/epoch_date_time_converter.dart"; -import "package:nexus/models/event/local_content.dart"; -import "package:nexus/models/event/unread_type.dart"; -part "info.freezed.dart"; -part "info.g.dart"; - -@Freezed(unionKey: "type") -abstract class EventInfo with _$EventInfo { - const factory EventInfo({ - @JsonKey(name: "rowid") required int rowId, - @JsonKey(name: "timeline_rowid") required int timelineRowId, - required String type, - required String roomId, - required String eventinfoId, - required String sender, - String? stateKey, - @EpochDateTimeConverter() required DateTime timestamp, - @JsonKey(name: "content") required IMap rawContent, - IMap? decrypted, - String? decryptedType, - @Default(IMap.empty()) IMap unsigned, - LocalContent? localContent, - String? transactionId, - String? redactedBy, - String? relatesTo, - String? relationType, - String? decryptionError, - String? sendError, - @Default(IMap.empty()) IMap reactions, - @JsonKey(name: "last_edit_rowid") int? lastEditRowId, - @UnreadTypeConverter() UnreadType? unreadType, - }) = _EventInfo; - - factory EventInfo.fromJson(Map json) => - _$EventInfoFromJson(json); -} diff --git a/lib/models/event/local_content.dart b/lib/models/event/local_content.dart deleted file mode 100644 index 98d69d2..0000000 --- a/lib/models/event/local_content.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "local_content.g.dart"; -part "local_content.freezed.dart"; - -@freezed -abstract class LocalContent with _$LocalContent { - const factory LocalContent({ - String? sanitizedHtml, - String? editSource, - bool? wasPlaintext, - bool? bigEmoji, - bool? hasMath, - bool? replyFallbackRemoved, - }) = _LocalContent; - - factory LocalContent.fromJson(Map json) => - _$LocalContentFromJson(json); -} diff --git a/lib/models/event/unread_type.dart b/lib/models/event/unread_type.dart deleted file mode 100644 index bc40718..0000000 --- a/lib/models/event/unread_type.dart +++ /dev/null @@ -1,29 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -class UnreadTypeConverter implements JsonConverter { - const UnreadTypeConverter(); - - @override - UnreadType? fromJson(int? json) => json == null ? null : UnreadType(json); - - @override - int? toJson(UnreadType? object) => object?.value; -} - -@immutable -class UnreadType { - final int value; - const UnreadType(this.value); - - static const none = UnreadType(0); - static const normal = UnreadType(1); - static const notify = UnreadType(2); - static const highlight = UnreadType(4); - static const sound = UnreadType(8); - - bool get isNone => value == 0; - bool get isNormal => (value & 1) != 0; - bool get shouldNotify => (value & 2) != 0; - bool get isHighlighted => (value & 4) != 0; - bool get playsSound => (value & 8) != 0; -} diff --git a/lib/models/paginate.dart b/lib/models/paginate.dart index 64fb5ec..df0a0f6 100644 --- a/lib/models/paginate.dart +++ b/lib/models/paginate.dart @@ -1,6 +1,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event/event.dart"; +import "package:nexus/models/event.dart"; part "paginate.freezed.dart"; part "paginate.g.dart"; diff --git a/lib/models/room.dart b/lib/models/room.dart index a369f2b..3c3eec0 100644 --- a/lib/models/room.dart +++ b/lib/models/room.dart @@ -1,6 +1,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event/event.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/models/read_receipt.dart"; import "package:nexus/models/room_metadata.dart"; part "room.freezed.dart"; diff --git a/pubspec.lock b/pubspec.lock index bd98a0d..10f28bd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -680,23 +680,21 @@ packages: source: hosted version: "1.0.5" json_annotation: - dependency: "direct overridden" + dependency: "direct main" description: - path: json_annotation - ref: "feature/json-key-flatten-2" - resolved-ref: "292d155b643b5f6fd956399d16a45711a0512ecd" - url: "https://github.com/helgoboss/json_serializable.dart" - source: git - version: "4.9.1-wip" + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" json_serializable: dependency: "direct dev" description: - path: json_serializable - ref: "feature/json-key-flatten-2" - resolved-ref: "292d155b643b5f6fd956399d16a45711a0512ecd" - url: "https://github.com/helgoboss/json_serializable.dart" - source: git - version: "6.11.4" + name: json_serializable + sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" + url: "https://pub.dev" + source: hosted + version: "6.13.0" leak_tracker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 91fa146..4e8d609 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,13 +11,6 @@ flutter: environment: sdk: "3.11.4" -dependency_overrides: - json_annotation: - git: - url: https://github.com/helgoboss/json_serializable.dart - ref: feature/json-key-flatten-2 - path: json_annotation - dependencies: flutter: sdk: flutter @@ -42,6 +35,7 @@ dependencies: color_hash: 1.0.1 flutter_widget_from_html_core: 0.17.2 flutter_svg: 2.3.0 + json_annotation: 4.11.0 shared_preferences: 2.5.5 fluttertagger: 2.3.2 dynamic_polls: 0.0.7 @@ -65,11 +59,7 @@ dev_dependencies: freezed: 3.2.5 riverpod_lint: 3.1.3 flutter_launcher_icons: 0.14.4 - json_serializable: - git: - url: https://github.com/helgoboss/json_serializable.dart - ref: feature/json-key-flatten-2 - path: json_serializable + json_serializable: 6.13.0 flutter_launcher_icons: ios: true -- 2.53.0 From 66356202c045683299ebb48a7c52b1186e55111c Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 13 May 2026 15:45:34 -0400 Subject: [PATCH 009/108] good framework for content models --- lib/controllers/client_controller.dart | 6 ++++-- lib/controllers/message_controller.dart | 1 + lib/models/content/content.dart | 17 +++++++++++++++++ lib/models/content/encrypted.dart | 9 +++++++++ lib/models/content/membership.dart | 21 +++++++++++++++++++++ lib/models/content/message.dart | 15 +++++++++++++++ lib/models/event.dart | 10 +++++++++- lib/models/profile.dart | 7 ++++--- 8 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 lib/models/content/content.dart create mode 100644 lib/models/content/encrypted.dart create mode 100644 lib/models/content/membership.dart create mode 100644 lib/models/content/message.dart diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index bbadd8e..4787b60 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -231,8 +231,10 @@ class ClientController extends AsyncNotifier { Future paginate(PaginateRequest request) async => Paginate.fromJson(await _sendCommand("paginate", request.toJson())); - Future getProfile(String userId) async => - Profile.fromJson(await _sendCommand("get_profile", {"user_id": userId})); + Future getProfile(String userId) async => Profile.fromJson({ + ...(await _sendCommand("get_profile", {"user_id": userId})), + "id": userId, + }); Future reportEvent(ReportRequest request) => _sendCommand("report_event", request.toJson()); diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart index 18bee2b..d52a835 100644 --- a/lib/controllers/message_controller.dart +++ b/lib/controllers/message_controller.dart @@ -123,6 +123,7 @@ class MessageController extends AsyncNotifier { text: "Unable to decrypt message.", metadata: {...metadata, "body": "Unable to decrypt message."}, ), + // "org.matrix.msc3381.poll.start" => Message.custom( // metadata: { // ...metadata, diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart new file mode 100644 index 0000000..06ec328 --- /dev/null +++ b/lib/models/content/content.dart @@ -0,0 +1,17 @@ +import "package:nexus/models/content/encrypted.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/content/message.dart"; + +class Content { + Content(); + factory Content.fromJson(Map json) => Content(); + + Map toJson() => {}; + static Content fromEventJson(Map eventJson) => + switch (eventJson["type"]) { + EncryptedContent.type => EncryptedContent.fromJson, + MembershipContent.type => MembershipContent.fromJson, + MessageContent.type => MessageContent.fromJson, + _ => Content.fromJson, + }(eventJson); +} diff --git a/lib/models/content/encrypted.dart b/lib/models/content/encrypted.dart new file mode 100644 index 0000000..0b575e7 --- /dev/null +++ b/lib/models/content/encrypted.dart @@ -0,0 +1,9 @@ +import "package:nexus/models/content/content.dart"; + +class EncryptedContent extends Content { + EncryptedContent(); + factory EncryptedContent.fromJson(Map json) => + EncryptedContent(); + + static const type = "m.room.encrypted"; +} diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart new file mode 100644 index 0000000..39a645b --- /dev/null +++ b/lib/models/content/membership.dart @@ -0,0 +1,21 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/membership_status.dart"; +part "membership.freezed.dart"; +part "membership.g.dart"; + +@freezed +abstract class MembershipContent extends Content with _$MembershipContent { + static const type = "m.room.membership"; + + MembershipContent._(); + const factory MembershipContent({ + @JsonKey(name: "displayname") required String displayName, + required MembershipStatus membership, + required Uri? avatarUrl, + required String? reason, + }) = _MembershipContent; + + factory MembershipContent.fromJson(Map json) => + _$MembershipContentFromJson(json); +} diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart new file mode 100644 index 0000000..629f21c --- /dev/null +++ b/lib/models/content/message.dart @@ -0,0 +1,15 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "message.freezed.dart"; +part "message.g.dart"; + +@freezed +abstract class MessageContent extends Content with _$MessageContent { + static const type = "m.room.message"; + + MessageContent._(); + const factory MessageContent({required String msgtype}) = _MessageContent; + + factory MessageContent.fromJson(Map json) => + _$MessageContentFromJson(json); +} diff --git a/lib/models/event.dart b/lib/models/event.dart index 4a72817..7fbf729 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -1,9 +1,16 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; import "package:nexus/models/epoch_date_time_converter.dart"; +import "package:nexus/models/profile.dart"; part "event.freezed.dart"; part "event.g.dart"; +Profile? pmpFromJson(Map json) { + final pmp = json["content"]?["com.beeper.per_message_profile"]; + return pmp == null ? null : Profile.fromJson(pmp); +} + @freezed abstract class Event with _$Event { const factory Event({ @@ -15,7 +22,6 @@ abstract class Event with _$Event { required String type, String? stateKey, @EpochDateTimeConverter() required DateTime timestamp, - required IMap content, IMap? decrypted, String? decryptedType, @Default(IMap.empty()) IMap unsigned, @@ -29,6 +35,8 @@ abstract class Event with _$Event { @Default(IMap.empty()) IMap reactions, @JsonKey(name: "last_edit_rowid") int? lastEditRowId, @UnreadTypeConverter() UnreadType? unreadType, + @JsonKey(fromJson: pmpFromJson) Profile? pmp, + @JsonKey(fromJson: Content.fromJson) required Content content, }) = _Event; factory Event.fromJson(Map json) => _$EventFromJson(json); diff --git a/lib/models/profile.dart b/lib/models/profile.dart index 584f27b..f0937f7 100644 --- a/lib/models/profile.dart +++ b/lib/models/profile.dart @@ -12,13 +12,14 @@ Object? readTimezone(Map map, _) => @freezed abstract class Profile with _$Profile { const factory Profile({ - String? avatarUrl, + required String id, + Uri? avatarUrl, @JsonKey(name: "displayname") String? displayName, - @JsonKey(readValue: readTimezone) String? timezone, + @JsonKey(readValue: readTimezone, name: "m.tz") String? timezone, @Default(IList.empty()) - @JsonKey(readValue: readPronouns) + @JsonKey(readValue: readPronouns, name: "io.fsky.nyx.pronouns") IList pronouns, }) = _Profile; -- 2.53.0 From 3325ebcad7a7bfaf9ffd2171a9dc44f89b5f2d3b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 15 May 2026 20:07:08 -0400 Subject: [PATCH 010/108] implement all msgtypes --- .vscode/settings.json | 1 + lib/models/content/message.dart | 67 ++++++++++++++++++++++++++++++++- lib/models/info/audio.dart | 17 +++++++++ lib/models/info/file.dart | 15 ++++++++ lib/models/info/image.dart | 17 +++++++++ lib/models/info/video.dart | 19 ++++++++++ lib/models/ms_duration.dart | 11 ++++++ 7 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 lib/models/info/audio.dart create mode 100644 lib/models/info/file.dart create mode 100644 lib/models/info/image.dart create mode 100644 lib/models/info/video.dart create mode 100644 lib/models/ms_duration.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index da80f4b..855ee97 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,7 @@ "Gomuks", "Homeserver", "localpart", + "msgtype", "muks", "prefs" ] diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index 629f21c..e98dbf5 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -1,14 +1,77 @@ import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/info/audio.dart"; import "package:nexus/models/content/content.dart"; +import "package:nexus/models/info/file.dart"; +import "package:nexus/models/info/image.dart"; part "message.freezed.dart"; part "message.g.dart"; -@freezed +@Freezed(unionKey: "msgtype", fallbackUnion: "default") abstract class MessageContent extends Content with _$MessageContent { static const type = "m.room.message"; MessageContent._(); - const factory MessageContent({required String msgtype}) = _MessageContent; + const factory MessageContent({ + required String msgtype, + required String body, + String? format, + String? formattedBody, + }) = _TextMessageContent; + + @FreezedUnionValue("m.image") + const factory MessageContent.image({ + required String msgtype, + required String body, + String? format, + String? formattedBody, + // EncryptedFile? file + String? filename, + ImageInfo? info, + String? url, + }) = _ImageMessageContent; + + @FreezedUnionValue("m.file") + const factory MessageContent.file({ + required String msgtype, + required String body, + String? format, + String? formattedBody, + // EncryptedFile? file + String? filename, + FileInfo? info, + String? url, + }) = _FileMessageContent; + + @FreezedUnionValue("m.audio") + const factory MessageContent.audio({ + required String msgtype, + required String body, + String? format, + String? formattedBody, + // EncryptedFile? file + String? filename, + AudioInfo? info, + String? url, + }) = _AudioMessageContent; + + @FreezedUnionValue("m.video") + const factory MessageContent.video({ + required String msgtype, + required String body, + String? format, + String? formattedBody, + // EncryptedFile? file + String? filename, + AudioInfo? info, + String? url, + }) = _AudioMessageContent; + + @FreezedUnionValue("m.location") + const factory MessageContent.location({ + required String msgtype, + required String body, + required Uri geoUri, + }) = _LocationMessageContent; factory MessageContent.fromJson(Map json) => _$MessageContentFromJson(json); diff --git a/lib/models/info/audio.dart b/lib/models/info/audio.dart new file mode 100644 index 0000000..ccfcf7a --- /dev/null +++ b/lib/models/info/audio.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/ms_duration.dart"; +part "audio.freezed.dart"; +part "audio.g.dart"; + +@freezed +abstract class AudioInfo with _$AudioInfo { + /// Information for images, [size] is in bytes. + const factory AudioInfo({ + @MSDuration() Duration? duration, + @JsonKey(name: "mimetype") String? mimeType, + int? size, + }) = _AudioInfo; + + factory AudioInfo.fromJson(Map json) => + _$AudioInfoFromJson(json); +} diff --git a/lib/models/info/file.dart b/lib/models/info/file.dart new file mode 100644 index 0000000..1509c99 --- /dev/null +++ b/lib/models/info/file.dart @@ -0,0 +1,15 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +part "file.freezed.dart"; +part "file.g.dart"; + +@freezed +abstract class FileInfo with _$FileInfo { + /// Information for images, [size] is in bytes. + const factory FileInfo({ + @JsonKey(name: "mimetype") String? mimeType, + int? size, + }) = _FileInfo; + + factory FileInfo.fromJson(Map json) => + _$FileInfoFromJson(json); +} diff --git a/lib/models/info/image.dart b/lib/models/info/image.dart new file mode 100644 index 0000000..9397aa8 --- /dev/null +++ b/lib/models/info/image.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +part "image.freezed.dart"; +part "image.g.dart"; + +@freezed +abstract class ImageInfo with _$ImageInfo { + /// Information for images, [size] is in bytes. + const factory ImageInfo({ + @JsonKey(name: "h") int? height, + @JsonKey(name: "w") int? width, + @JsonKey(name: "mimetype") String? mimeType, + int? size, + }) = _ImageInfo; + + factory ImageInfo.fromJson(Map json) => + _$ImageInfoFromJson(json); +} diff --git a/lib/models/info/video.dart b/lib/models/info/video.dart new file mode 100644 index 0000000..6ff3547 --- /dev/null +++ b/lib/models/info/video.dart @@ -0,0 +1,19 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/ms_duration.dart"; +part "video.freezed.dart"; +part "video.g.dart"; + +@freezed +abstract class VideoInfo with _$VideoInfo { + /// Information for images, [size] is in bytes. + const factory VideoInfo({ + @JsonKey(name: "h") int? height, + @JsonKey(name: "w") int? width, + @JsonKey(name: "mimetype") String? mimeType, + @MSDuration() Duration? duration, + int? size, + }) = _VideoInfo; + + factory VideoInfo.fromJson(Map json) => + _$VideoInfoFromJson(json); +} diff --git a/lib/models/ms_duration.dart b/lib/models/ms_duration.dart new file mode 100644 index 0000000..de12943 --- /dev/null +++ b/lib/models/ms_duration.dart @@ -0,0 +1,11 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +class MSDuration implements JsonConverter { + const MSDuration(); + + @override + Duration fromJson(int ms) => Duration(milliseconds: ms); + + @override + int toJson(Duration duration) => duration.inMilliseconds; +} -- 2.53.0 From 3ce1f53bc452d507c1b25bfe76985cd33d8465b1 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 15 May 2026 20:14:17 -0400 Subject: [PATCH 011/108] enum for event type --- lib/models/content/content.dart | 24 +++++++++++++++++------- lib/models/content/encrypted.dart | 9 --------- lib/models/content/membership.dart | 2 -- lib/models/content/message.dart | 2 -- 4 files changed, 17 insertions(+), 20 deletions(-) delete mode 100644 lib/models/content/encrypted.dart diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index 06ec328..0f931ea 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -1,4 +1,5 @@ -import "package:nexus/models/content/encrypted.dart"; +import "package:collection/collection.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/message.dart"; @@ -8,10 +9,19 @@ class Content { Map toJson() => {}; static Content fromEventJson(Map eventJson) => - switch (eventJson["type"]) { - EncryptedContent.type => EncryptedContent.fromJson, - MembershipContent.type => MembershipContent.fromJson, - MessageContent.type => MessageContent.fromJson, - _ => Content.fromJson, - }(eventJson); + (EventType.values + .firstWhereOrNull((eventType) => eventType == eventJson["type"]) + ?.contentFromJson ?? + Content.fromJson)(eventJson); +} + +@JsonEnum(valueField: "type") +enum EventType { + encrypted("m.room.encrypted", Content.fromJson), + membership("m.room.member", MembershipContent.fromJson), + message("m.room.message", MessageContent.fromJson); + + final String type; + final Content Function(Map json) contentFromJson; + const EventType(this.type, this.contentFromJson); } diff --git a/lib/models/content/encrypted.dart b/lib/models/content/encrypted.dart deleted file mode 100644 index 0b575e7..0000000 --- a/lib/models/content/encrypted.dart +++ /dev/null @@ -1,9 +0,0 @@ -import "package:nexus/models/content/content.dart"; - -class EncryptedContent extends Content { - EncryptedContent(); - factory EncryptedContent.fromJson(Map json) => - EncryptedContent(); - - static const type = "m.room.encrypted"; -} diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart index 39a645b..6e4f875 100644 --- a/lib/models/content/membership.dart +++ b/lib/models/content/membership.dart @@ -6,8 +6,6 @@ part "membership.g.dart"; @freezed abstract class MembershipContent extends Content with _$MembershipContent { - static const type = "m.room.membership"; - MembershipContent._(); const factory MembershipContent({ @JsonKey(name: "displayname") required String displayName, diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index e98dbf5..3c0ebdc 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -8,8 +8,6 @@ part "message.g.dart"; @Freezed(unionKey: "msgtype", fallbackUnion: "default") abstract class MessageContent extends Content with _$MessageContent { - static const type = "m.room.message"; - MessageContent._(); const factory MessageContent({ required String msgtype, -- 2.53.0 From e60e2470939d82956c844a2df50b1a1861ddd317 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 15 May 2026 20:59:30 -0400 Subject: [PATCH 012/108] add create event --- lib/models/content/content.dart | 7 +++++-- lib/models/content/create.dart | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 lib/models/content/create.dart diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index 0f931ea..be021ed 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -1,16 +1,18 @@ import "package:collection/collection.dart"; import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/message.dart"; class Content { Content(); - factory Content.fromJson(Map json) => Content(); + factory Content.fromJson(Map json) => Content(); Map toJson() => {}; + static Content fromEventJson(Map eventJson) => (EventType.values - .firstWhereOrNull((eventType) => eventType == eventJson["type"]) + .firstWhereOrNull((eventType) => eventType.type == eventJson["type"]) ?.contentFromJson ?? Content.fromJson)(eventJson); } @@ -19,6 +21,7 @@ class Content { enum EventType { encrypted("m.room.encrypted", Content.fromJson), membership("m.room.member", MembershipContent.fromJson), + create("m.room.create", CreateContent.fromJson), message("m.room.message", MessageContent.fromJson); final String type; diff --git a/lib/models/content/create.dart b/lib/models/content/create.dart new file mode 100644 index 0000000..78eef9f --- /dev/null +++ b/lib/models/content/create.dart @@ -0,0 +1,35 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "create.freezed.dart"; +part "create.g.dart"; + +@freezed +abstract class CreateContent extends Content with _$CreateContent { + CreateContent._(); + const factory CreateContent({ + @JsonKey(name: "creator") String? creatorId, + + @JsonKey(name: "additional_creators") + @Default(IList.empty()) + IList additionalCreatorIds, + + PreviousRoom? predecessor, + + @JsonKey(name: "m.federate") @Default(true) bool federated, + + @Default("1") String roomVersion, + required String type, + }) = _CreateContent; + + factory CreateContent.fromJson(Map json) => + _$CreateContentFromJson(json); +} + +@freezed +abstract class PreviousRoom with _$PreviousRoom { + const factory PreviousRoom({required int roomId}) = _PreviousRoom; + + factory PreviousRoom.fromJson(Map json) => + _$PreviousRoomFromJson(json); +} -- 2.53.0 From 17603f0d16182bd955aa971e3a04c0d5bdfa6eea Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 15 May 2026 21:18:05 -0400 Subject: [PATCH 013/108] add join rules event --- lib/models/content/canonical_alias.dart | 17 +++++++++++++ lib/models/content/content.dart | 4 +++ lib/models/content/join_rules.dart | 34 +++++++++++++++++++++++++ lib/models/join_rule.dart | 4 +++ 4 files changed, 59 insertions(+) create mode 100644 lib/models/content/canonical_alias.dart create mode 100644 lib/models/content/join_rules.dart create mode 100644 lib/models/join_rule.dart diff --git a/lib/models/content/canonical_alias.dart b/lib/models/content/canonical_alias.dart new file mode 100644 index 0000000..f675401 --- /dev/null +++ b/lib/models/content/canonical_alias.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "canonical_alias.freezed.dart"; +part "canonical_alias.g.dart"; + +@freezed +abstract class CanonicalAliasContent extends Content + with _$CanonicalAliasContent { + CanonicalAliasContent._(); + const factory CanonicalAliasContent({ + String? alias, + @Default([]) altAliases, + }) = _CanonicalAliasContent; + + factory CanonicalAliasContent.fromJson(Map json) => + _$CanonicalAliasContentFromJson(json); +} diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index be021ed..beff225 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -1,6 +1,8 @@ import "package:collection/collection.dart"; import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/join_rules.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/message.dart"; @@ -22,6 +24,8 @@ enum EventType { encrypted("m.room.encrypted", Content.fromJson), membership("m.room.member", MembershipContent.fromJson), create("m.room.create", CreateContent.fromJson), + canonicalAlias("m.room.canonical_alias", CanonicalAliasContent.fromJson), + joinRules("m.room.join_rules", JoinRulesContent.fromJson), message("m.room.message", MessageContent.fromJson); final String type; diff --git a/lib/models/content/join_rules.dart b/lib/models/content/join_rules.dart new file mode 100644 index 0000000..a890d5c --- /dev/null +++ b/lib/models/content/join_rules.dart @@ -0,0 +1,34 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/join_rule.dart"; +part "join_rules.freezed.dart"; +part "join_rules.g.dart"; + +@freezed +abstract class JoinRulesContent extends Content with _$JoinRulesContent { + JoinRulesContent._(); + const factory JoinRulesContent({ + required JoinRule joinRule, + @Default(IList.empty()) IList allow, + }) = _JoinRulesContent; + + factory JoinRulesContent.fromJson(Map json) => + _$JoinRulesContentFromJson(json); +} + +@freezed +abstract class AllowCondition with _$AllowCondition { + const factory AllowCondition({ + String? roomId, + required AllowConditionType type, + }) = _AllowCondition; + + factory AllowCondition.fromJson(Map json) => + _$AllowConditionFromJson(json); +} + +enum AllowConditionType { + @JsonValue("m.room_membership") + membership, +} diff --git a/lib/models/join_rule.dart b/lib/models/join_rule.dart new file mode 100644 index 0000000..3fade23 --- /dev/null +++ b/lib/models/join_rule.dart @@ -0,0 +1,4 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +@JsonEnum(fieldRename: FieldRename.snake) +enum JoinRule { public, knock, invite, private, restricted, knockRestricted } -- 2.53.0 From 7e2c90381cec10255c0cd18f7e11689d652f016a Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 16 May 2026 11:03:29 -0400 Subject: [PATCH 014/108] add quite a few more content types --- lib/models/content/avatar.dart | 14 ++++++++++ lib/models/content/content.dart | 12 +++++++++ lib/models/content/encryption.dart | 23 ++++++++++++++++ lib/models/content/name.dart | 13 +++++++++ lib/models/content/power_levels.dart | 38 ++++++++++++++++++++++++++ lib/models/content/server_acl.dart | 18 +++++++++++++ lib/models/content/topic.dart | 40 ++++++++++++++++++++++++++++ lib/models/event.dart | 2 +- 8 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 lib/models/content/avatar.dart create mode 100644 lib/models/content/encryption.dart create mode 100644 lib/models/content/name.dart create mode 100644 lib/models/content/power_levels.dart create mode 100644 lib/models/content/server_acl.dart create mode 100644 lib/models/content/topic.dart diff --git a/lib/models/content/avatar.dart b/lib/models/content/avatar.dart new file mode 100644 index 0000000..650e2e6 --- /dev/null +++ b/lib/models/content/avatar.dart @@ -0,0 +1,14 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/info/image.dart"; +part "avatar.freezed.dart"; +part "avatar.g.dart"; + +@freezed +abstract class AvatarContent extends Content with _$AvatarContent { + AvatarContent._(); + const factory AvatarContent({ImageInfo? info, Uri? url}) = _AvatarContent; + + factory AvatarContent.fromJson(Map json) => + _$AvatarContentFromJson(json); +} diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index beff225..5d3820f 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -1,10 +1,16 @@ import "package:collection/collection.dart"; import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/encryption.dart"; import "package:nexus/models/content/join_rules.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/name.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/content/server_acl.dart"; +import "package:nexus/models/content/topic.dart"; class Content { Content(); @@ -22,10 +28,16 @@ class Content { @JsonEnum(valueField: "type") enum EventType { encrypted("m.room.encrypted", Content.fromJson), + encryption("m.room.encryption", EncryptionContent.fromJson), membership("m.room.member", MembershipContent.fromJson), create("m.room.create", CreateContent.fromJson), canonicalAlias("m.room.canonical_alias", CanonicalAliasContent.fromJson), joinRules("m.room.join_rules", JoinRulesContent.fromJson), + powerLevels("m.room.power_levels", PowerLevelsContent.fromJson), + serverACL("m.room.server_acl", ServerACLContent.fromJson), + avatar("m.room.avatar", AvatarContent.fromJson), + topic("m.room.topic", TopicContent.fromJson), + name("m.room.name", NameContent.fromJson), message("m.room.message", MessageContent.fromJson); final String type; diff --git a/lib/models/content/encryption.dart b/lib/models/content/encryption.dart new file mode 100644 index 0000000..0fea339 --- /dev/null +++ b/lib/models/content/encryption.dart @@ -0,0 +1,23 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "encryption.freezed.dart"; +part "encryption.g.dart"; + +@freezed +abstract class EncryptionContent extends Content with _$EncryptionContent { + EncryptionContent._(); + const factory EncryptionContent({ + required String algorithm, + + @JsonKey(name: "rotation_period_ms") + @Default(604800000) + int rotationPeriodMS, + + @JsonKey(name: "rotation_period_msgs") + @Default(100) + int rotationPeriodMessages, + }) = _EncryptionContent; + + factory EncryptionContent.fromJson(Map json) => + _$EncryptionContentFromJson(json); +} diff --git a/lib/models/content/name.dart b/lib/models/content/name.dart new file mode 100644 index 0000000..35bac40 --- /dev/null +++ b/lib/models/content/name.dart @@ -0,0 +1,13 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "name.freezed.dart"; +part "name.g.dart"; + +@freezed +abstract class NameContent extends Content with _$NameContent { + NameContent._(); + const factory NameContent({required String name}) = _NameContent; + + factory NameContent.fromJson(Map json) => + _$NameContentFromJson(json); +} diff --git a/lib/models/content/power_levels.dart b/lib/models/content/power_levels.dart new file mode 100644 index 0000000..f2ab876 --- /dev/null +++ b/lib/models/content/power_levels.dart @@ -0,0 +1,38 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; + +part "power_levels.freezed.dart"; +part "power_levels.g.dart"; + +@freezed +abstract class PowerLevelsContent extends Content with _$PowerLevelsContent { + PowerLevelsContent._(); + + const factory PowerLevelsContent({ + @Default(IMap.empty()) IMap events, + @Default(IMap.empty()) IMap users, + Notifications? notifications, + @Default(50) int ban, + @Default(0) int eventsDefault, + @Default(0) int invite, + @Default(50) int kick, + @Default(50) int redact, + @Default(50) int stateDefault, + @Default(0) int usersDefault, + }) = _PowerLevelsContent; + + factory PowerLevelsContent.fromJson(Map json) => + _$PowerLevelsContentFromJson(json); +} + +@freezed +abstract class Notifications with _$Notifications { + const factory Notifications({ + @Default(50) int room, + @Default(IMapConst({})) IMap other, + }) = _Notifications; + + factory Notifications.fromJson(Map json) => + _$NotificationsFromJson(json); +} diff --git a/lib/models/content/server_acl.dart b/lib/models/content/server_acl.dart new file mode 100644 index 0000000..6ee5fea --- /dev/null +++ b/lib/models/content/server_acl.dart @@ -0,0 +1,18 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "server_acl.freezed.dart"; +part "server_acl.g.dart"; + +@freezed +abstract class ServerACLContent extends Content with _$ServerACLContent { + ServerACLContent._(); + const factory ServerACLContent({ + @Default(IList.empty()) IList allow, + @Default(IList.empty()) IList deny, + @Default(true) allowIpLiterals, + }) = _ServerACLContent; + + factory ServerACLContent.fromJson(Map json) => + _$ServerACLContentFromJson(json); +} diff --git a/lib/models/content/topic.dart b/lib/models/content/topic.dart new file mode 100644 index 0000000..ee561c7 --- /dev/null +++ b/lib/models/content/topic.dart @@ -0,0 +1,40 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "topic.freezed.dart"; +part "topic.g.dart"; + +@freezed +abstract class TopicContent extends Content with _$TopicContent { + TopicContent._(); + const factory TopicContent({ + required String topic, + @JsonKey(name: "m.topic") TopicContentBlock? content, + }) = _TopicContent; + + factory TopicContent.fromJson(Map json) => + _$TopicContentFromJson(json); +} + +@freezed +abstract class TopicContentBlock with _$TopicContentBlock { + const factory TopicContentBlock({ + @Default(IList.empty()) + @JsonKey(name: "m.text") + IList representations, + }) = _TopicContentBlock; + + factory TopicContentBlock.fromJson(Map json) => + _$TopicContentBlockFromJson(json); +} + +@freezed +abstract class TextualRepresentation with _$TextualRepresentation { + const factory TextualRepresentation({ + required String body, + @Default("text/plain") String mimetype, + }) = _TextualRepresentation; + + factory TextualRepresentation.fromJson(Map json) => + _$TextualRepresentationFromJson(json); +} diff --git a/lib/models/event.dart b/lib/models/event.dart index 7fbf729..798b505 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -19,7 +19,7 @@ abstract class Event with _$Event { required String roomId, required String eventId, required String sender, - required String type, + required EventType type, String? stateKey, @EpochDateTimeConverter() required DateTime timestamp, IMap? decrypted, -- 2.53.0 From 05b15c44ecaa4ab672fe5612075bd4a8f5693183 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 16 May 2026 11:09:05 -0400 Subject: [PATCH 015/108] add pinned events content type --- lib/models/content/content.dart | 2 ++ lib/models/content/message.dart | 5 ----- lib/models/content/pinned_events.dart | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 lib/models/content/pinned_events.dart diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index 5d3820f..9e145c1 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -8,6 +8,7 @@ import "package:nexus/models/content/join_rules.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/name.dart"; +import "package:nexus/models/content/pinned_events.dart"; import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/server_acl.dart"; import "package:nexus/models/content/topic.dart"; @@ -38,6 +39,7 @@ enum EventType { avatar("m.room.avatar", AvatarContent.fromJson), topic("m.room.topic", TopicContent.fromJson), name("m.room.name", NameContent.fromJson), + pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson), message("m.room.message", MessageContent.fromJson); final String type; diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index 3c0ebdc..c61fb25 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -18,7 +18,6 @@ abstract class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.image") const factory MessageContent.image({ - required String msgtype, required String body, String? format, String? formattedBody, @@ -30,7 +29,6 @@ abstract class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.file") const factory MessageContent.file({ - required String msgtype, required String body, String? format, String? formattedBody, @@ -42,7 +40,6 @@ abstract class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.audio") const factory MessageContent.audio({ - required String msgtype, required String body, String? format, String? formattedBody, @@ -54,7 +51,6 @@ abstract class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.video") const factory MessageContent.video({ - required String msgtype, required String body, String? format, String? formattedBody, @@ -66,7 +62,6 @@ abstract class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.location") const factory MessageContent.location({ - required String msgtype, required String body, required Uri geoUri, }) = _LocationMessageContent; diff --git a/lib/models/content/pinned_events.dart b/lib/models/content/pinned_events.dart new file mode 100644 index 0000000..a259ba4 --- /dev/null +++ b/lib/models/content/pinned_events.dart @@ -0,0 +1,16 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "pinned_events.freezed.dart"; +part "pinned_events.g.dart"; + +@freezed +abstract class PinnedEventsContent extends Content with _$PinnedEventsContent { + PinnedEventsContent._(); + const factory PinnedEventsContent({ + @Default(IList.empty()) IList pinned, + }) = _PinnedEventsContent; + + factory PinnedEventsContent.fromJson(Map json) => + _$PinnedEventsContentFromJson(json); +} -- 2.53.0 From 94f0d9e3465225cfad8a5acc915ff706e2e35783 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 16 May 2026 11:33:38 -0400 Subject: [PATCH 016/108] add reaction content type --- lib/controllers/room_chat_controller.dart | 28 ++++------------------- lib/models/content/content.dart | 2 ++ lib/models/content/reaction.dart | 16 +++++++++++++ 3 files changed, 22 insertions(+), 24 deletions(-) create mode 100644 lib/models/content/reaction.dart diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index a9e838b..e99b5a2 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -20,12 +20,12 @@ import "package:nexus/models/requests/send_event_request.dart"; import "package:nexus/models/requests/send_message_request.dart"; import "package:nexus/models/room.dart"; -class RoomChatController extends AsyncNotifier { +class RoomChatController extends AsyncNotifier> { final String roomId; RoomChatController(this.roomId); @override - Future build() async { + Future> build() async { final client = ref.watch(ClientController.provider.notifier); var room = ref.read(RoomsController.provider)[roomId]; if (room == null) return InMemoryChatController(); @@ -204,7 +204,7 @@ class RoomChatController extends AsyncNotifier { RedactEventRequest(eventId: message.id, roomId: roomId, reason: reason), ); - Future loadOlder([InMemoryChatController? chatController]) async { + Future loadOlder() async { final response = await ref .watch(ClientController.provider.notifier) .paginate( @@ -239,26 +239,6 @@ class RoomChatController extends AsyncNotifier { addToNewEvents: false, ); - final room = ref.read(RoomsController.provider)[roomId]; - if (room != null) { - final messages = await ref.watch( - MessagesController.provider( - MessagesConfig(room: room, events: response.events.reversed), - ).future, - ); - - final controller = chatController ?? await future; - await controller.insertAllMessages( - messages - .where( - (newMessage) => !controller.messages.any( - (message) => message.id == newMessage.id, - ), - ) - .toList(), - index: 0, - ); - } return response.hasMore; } @@ -381,7 +361,7 @@ class RoomChatController extends AsyncNotifier { } static final provider = AsyncNotifierProvider.family - .autoDispose( + .autoDispose, String>( RoomChatController.new, ); } diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index 9e145c1..ec729f5 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -10,6 +10,7 @@ import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/name.dart"; import "package:nexus/models/content/pinned_events.dart"; import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/content/server_acl.dart"; import "package:nexus/models/content/topic.dart"; @@ -39,6 +40,7 @@ enum EventType { avatar("m.room.avatar", AvatarContent.fromJson), topic("m.room.topic", TopicContent.fromJson), name("m.room.name", NameContent.fromJson), + reaction("m.reaction", ReactionContent.fromJson), pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson), message("m.room.message", MessageContent.fromJson); diff --git a/lib/models/content/reaction.dart b/lib/models/content/reaction.dart new file mode 100644 index 0000000..7cdec08 --- /dev/null +++ b/lib/models/content/reaction.dart @@ -0,0 +1,16 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "reaction.freezed.dart"; +part "reaction.g.dart"; + +String? keyFromJson(Map json) => json["m.relates_to"]?["key"]; + +@freezed +abstract class ReactionContent extends Content with _$ReactionContent { + ReactionContent._(); + const factory ReactionContent({@JsonKey(fromJson: keyFromJson) String? key}) = + _ReactionContent; + + factory ReactionContent.fromJson(Map json) => + _$ReactionContentFromJson(json); +} -- 2.53.0 From d0b148ad5bad5427f471c621e93f747ccf7f169a Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 16 May 2026 15:24:05 -0400 Subject: [PATCH 017/108] port all controllers to new event format --- .vscode/settings.json | 3 +- lib/controllers/author_controller.dart | 42 +--- lib/controllers/client_controller.dart | 9 +- .../members_by_type_controller.dart | 23 +- lib/controllers/members_controller.dart | 26 +- lib/controllers/message_controller.dart | 214 ---------------- lib/controllers/messages_controller.dart | 26 -- lib/controllers/power_level_controller.dart | 34 ++- lib/controllers/room_chat_controller.dart | 234 ++++-------------- lib/controllers/user_controller.dart | 24 +- lib/controllers/via_controller.dart | 22 +- lib/models/content/membership.dart | 6 +- lib/models/membership.dart | 32 --- lib/widgets/chat_page/room_chat.dart | 6 +- 14 files changed, 138 insertions(+), 563 deletions(-) delete mode 100644 lib/controllers/message_controller.dart delete mode 100644 lib/controllers/messages_controller.dart delete mode 100644 lib/models/membership.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 855ee97..a0d46c9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,6 +8,7 @@ "localpart", "msgtype", "muks", - "prefs" + "prefs", + "unban" ] } diff --git a/lib/controllers/author_controller.dart b/lib/controllers/author_controller.dart index 7dcdb23..8499775 100644 --- a/lib/controllers/author_controller.dart +++ b/lib/controllers/author_controller.dart @@ -1,46 +1,28 @@ import "dart:async"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/user_controller.dart"; -import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/models/membership.dart"; -import "package:nexus/models/membership_status.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/event.dart"; -class AuthorController extends AsyncNotifier { - final Message message; - AuthorController(this.message); +class AuthorController extends AsyncNotifier { + final Event event; + AuthorController(this.event); @override - Future build() async { + Future build() async { final member = await ref.watch( - UserController.provider(message.sender).future, + UserController.provider(event.sender).future, ); - final pmp = message.metadata?["pmp"] == null - ? null - : Membership.fromContent( - IMap(message.metadata?["pmp"]), - message.sender, - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - "", - ); - - return Membership( - status: member?.status ?? MembershipStatus.leave, - avatarUrl: pmp?.avatarUrl ?? member?.avatarUrl, - displayName: - pmp?.displayName ?? member?.displayName ?? message.sender.localpart, - userId: message.sender, + return MembershipContent( + status: member.status, + avatarUrl: event.pmp?.avatarUrl ?? member.avatarUrl, + displayName: event.pmp?.displayName ?? member.displayName, ); } static final provider = - AsyncNotifierProvider.family( + AsyncNotifierProvider.family( AuthorController.new, ); } diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index 4787b60..f26b8ca 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -9,7 +9,6 @@ import "package:flutter/foundation.dart"; import "package:nexus/controllers/account_data_controller.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/init_complete_controller.dart"; -import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/space_edges_controller.dart"; import "package:nexus/controllers/sync_status_controller.dart"; @@ -17,6 +16,7 @@ import "package:nexus/controllers/top_level_spaces_controller.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/main.dart"; import "package:nexus/models/client_state.dart"; +import "package:nexus/models/content/content.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/paginate.dart"; import "package:nexus/models/requests/get_event_request.dart"; @@ -81,11 +81,8 @@ class ClientController extends AsyncNotifier { case "send_complete": final event = Event.fromJson(decodedMuksEvent["event"]); - if (event.type == "m.room.message") { - final provider = RoomChatController.provider(event.roomId); - if (ref.exists(provider)) { - ref.watch(provider.notifier).addEvent(event); - } + if (event.type == EventType.message) { + // ref.watch(provider.notifier).addEvent(event); TODO } break; case "sync_complete": diff --git a/lib/controllers/members_by_type_controller.dart b/lib/controllers/members_by_type_controller.dart index cdc8d07..c96dc27 100644 --- a/lib/controllers/members_by_type_controller.dart +++ b/lib/controllers/members_by_type_controller.dart @@ -1,25 +1,32 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/members_controller.dart"; -import "package:nexus/models/membership.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/models/membership_status.dart"; -class MembersByTypeController extends AsyncNotifier> { - final MembershipStatus status; - MembersByTypeController(this.status); +class MembersByTypeController extends AsyncNotifier> { + final MembershipStatus filterStatus; + MembersByTypeController(this.filterStatus); @override - Future> build() => ref.watch( + Future> build() => ref.watch( MembersController.provider.selectAsync( - (members) => - members.where((membership) => membership.status == status).toIList(), + (members) => members + .where( + (membership) => switch (membership.content) { + MembershipContent(:final status) => filterStatus == status, + _ => false, + }, + ) + .toIList(), ), ); static final provider = AsyncNotifierProvider.family< MembersByTypeController, - IList, + IList, MembershipStatus >(MembersByTypeController.new); } diff --git a/lib/controllers/members_controller.dart b/lib/controllers/members_controller.dart index 39666d4..570a233 100644 --- a/lib/controllers/members_controller.dart +++ b/lib/controllers/members_controller.dart @@ -1,14 +1,14 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/models/membership.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_room_state_request.dart"; -class MembersController extends AsyncNotifier> { +class MembersController extends AsyncNotifier> { @override - Future> build() async { + Future> build() async { final data = ref.watch( SelectedRoomController.provider.select( (value) => value?.metadata == null @@ -28,25 +28,11 @@ class MembersController extends AsyncNotifier> { ), ); - return state.nonNulls - .where((state) => state.type == "m.room.member") - .map( - (membership) => Membership.fromContent( - membership.content, - membership.stateKey!, - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - "", - ), - ) - .toIList(); + return state.where((state) => state.type == EventType.membership).toIList(); } static final provider = - AsyncNotifierProvider>( + AsyncNotifierProvider>( MembersController.new, ); } diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart deleted file mode 100644 index d52a835..0000000 --- a/lib/controllers/message_controller.dart +++ /dev/null @@ -1,214 +0,0 @@ -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_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/models/configs/message_config.dart"; -import "package:nexus/models/requests/get_related_events_request.dart"; - -class MessageController extends AsyncNotifier { - final MessageConfig config; - MessageController(this.config); - - @override - Future build() async { - try { - final isEdit = config.event.relationType == "m.replace"; - if ((isEdit && !config.includeEdits) || config.room.metadata == null) { - return null; - } - - final event = config.event.lastEditRowId == null - ? config.event - : config.room.events.firstWhereOrNull( - (e) => e.rowId == config.event.lastEditRowId, - ) ?? - config.event; - - final decrypted = (event.decrypted ?? event.content); - final type = (config.event.decryptedType ?? config.event.type); - final content = decrypted["m.new_content"] == null - ? decrypted - : IMap(decrypted["m.new_content"]); - - final homeserver = ref - .read(ClientStateController.provider) - ?.homeserverUrl; - final source = homeserver == null || content["url"] == null - ? "null" - : Uri.parse(content["url"]).mxcToHttps(homeserver).toString(); - - final metadata = { - "body": config.event.redactedBy == null - ? (content["body"] ?? "") - : "Deleted Message", - "flashing": false, - "timelineId": event.timelineRowId, - "big": event.localContent?.bigEmoji == true, - "eventType": type, - "pmp": content["com.beeper.per_message_profile"], - "error": event.sendError, - "format": content["format"] ?? content["format"], - "editSource": event.localContent?.editSource ?? content["body"], - "txnId": config.event.transactionId, - }; - - final editedAt = event.relationType == "m.replace" - ? event.timestamp - : null; - - if ((event.redactedBy != null && !config.alwaysReturn) || - (!config.includeEdits && - (config.event.relationType == "m.replace"))) { - return null; - } - - final replyId = - config.event.content["m.relates_to"]?["m.in_reply_to"]?["event_id"]; - - final reactionEvents = config.event.reactions.isEmpty && !isEdit - ? null - : await ref - .watch(ClientController.provider.notifier) - .getRelatedEvents( - GetRelatedEventsRequest( - roomId: config.room.metadata!.id, - eventId: - (isEdit ? config.event.relatesTo : null) ?? - config.event.eventId, - relationType: "m.annotation", - ), - ); - - final reactions = reactionEvents - ?.where((event) => event.redactedBy == null) - .fold>>(IMap(), (acc, event) { - final key = event.content["m.relates_to"]?["key"]; - if (key == null) return acc; - - return acc.update( - key, - (list) => list.add(event.sender), - ifAbsent: () => IList([event.sender]), - ); - }) - .map((key, value) => MapEntry(key, value.unlock)) - .unlock; - - final asText = - Message.text( - metadata: metadata, - id: config.event.eventId, - reactions: reactions, - sender: event.sender, - text: content["formatted_body"] ?? content["body"] ?? "", - replyToMessageId: replyId, - deliveredAt: config.event.timestamp, - editedAt: editedAt, - ) - as TextMessage; - - Message toSystemMessage(String content) => Message.system( - metadata: {...metadata, "body": content}, - id: config.event.eventId, - reactions: reactions, - sender: event.sender, - deliveredAt: config.event.timestamp, - text: content, - ); - - return switch (type) { - "m.room.encrypted" => asText.copyWith( - text: "Unable to decrypt message.", - metadata: {...metadata, "body": "Unable to decrypt message."}, - ), - - // "org.matrix.msc3381.poll.start" => Message.custom( - // metadata: { - // ...metadata, - // "poll": event.parsedPollEventContent.pollStartContent, - // "responses": event.getPollResponses(timeline), - // }, - // id: eventId, - // deliveredAt: originServerTs, - // sender: senderId, - // ), - ("m.sticker" || "m.room.message") => switch (content["msgtype"]) { - null || "m.image" => Message.image( - id: config.event.eventId, - sender: event.sender, - reactions: reactions, - source: source, - replyToMessageId: replyId, - metadata: metadata, - text: asText.text, - deliveredAt: config.event.timestamp, - blurhash: (content["info"] as Map?)?["xyz.amorgan.blurhash"], - ), - "m.audio" || "m.file" => Message.file( - name: content["filename"].toString(), - size: content["info"]["size"], - metadata: metadata, - id: config.event.eventId, - reactions: reactions, - sender: event.sender, - source: source, - replyToMessageId: replyId, - deliveredAt: config.event.timestamp, - ), - _ => asText, - }, - "m.room.member" => - content["membership"] == event.unsigned["prev_content"]?["membership"] - ? null - : toSystemMessage( - "${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) { - "invite" => "was invited to", - "join" => "joined", - "leave" => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), - "ban" => "was banned from", - "knock" => "asked to join", - _ => "did something relating to", - }} the room. ${content["reason"] ?? ""}", - ), - - "m.room.server_acl" => toSystemMessage( - "${event.sender} updated the server ban list.", - ), - - "m.room.redaction" => - config.alwaysReturn - ? asText.copyWith( - metadata: { - ...(asText.metadata ?? {}), - "body": "Deleted Message", - }, - ) - : null, - _ => - config.alwaysReturn - ? asText - : ( - // Turn this on for debugging purposes - false - // ignore: dead_code - ? Message.unsupported( - metadata: metadata, - reactions: reactions, - id: config.event.eventId, - sender: event.sender, - replyToMessageId: replyId, - ) - : null), - }; - } catch (error) { - return null; - } - } - - static final provider = AsyncNotifierProvider.family - .autoDispose( - MessageController.new, - ); -} diff --git a/lib/controllers/messages_controller.dart b/lib/controllers/messages_controller.dart deleted file mode 100644 index 80eea26..0000000 --- a/lib/controllers/messages_controller.dart +++ /dev/null @@ -1,26 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/message_controller.dart"; -import "package:nexus/models/configs/message_config.dart"; -import "package:nexus/models/configs/messages_config.dart"; - -class MessagesController extends AsyncNotifier> { - final MessagesConfig config; - MessagesController(this.config); - - @override - Future> build() async => (await Future.wait( - config.events.map( - (event) => ref.watch( - MessageController.provider( - MessageConfig(event: event, room: config.room), - ).future, - ), - ), - )).nonNulls.toIList(); - - static final provider = AsyncNotifierProvider.family - .autoDispose, MessagesConfig>( - MessagesController.new, - ); -} diff --git a/lib/controllers/power_level_controller.dart b/lib/controllers/power_level_controller.dart index 41b5f19..7d377a5 100644 --- a/lib/controllers/power_level_controller.dart +++ b/lib/controllers/power_level_controller.dart @@ -3,6 +3,8 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/models/configs/power_level_config.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/requests/membership_action.dart"; class PowerLevelController extends Notifier { @@ -13,17 +15,15 @@ class PowerLevelController extends Notifier { bool build() { final room = ref.watch(SelectedRoomController.provider); final event = room?.events.firstWhereOrNull( - (event) => event.rowId == room.state["m.room.power_levels"]?[""], + (event) => event.rowId == room.state[EventType.powerLevels.type]?[""], ); final user = ref.watch(ClientStateController.provider)?.userId; - if (event == null || user == null) return false; + if (user == null || event?.content is! PowerLevelsContent) return false; - final users = (event.content["users"] as Map? ?? {}); - final events = (event.content["events"] as Map? ?? {}); + final content = event?.content as PowerLevelsContent; - int powerLevelOf(String userId) => users.containsKey(userId) - ? (users[userId] as int) - : (event.content["users_default"] as int? ?? 0); + int powerLevelOf(String userId) => + content.users[userId] ?? content.usersDefault; final userLevel = powerLevelOf(user); final targetLevel = config.targetUser != null @@ -32,33 +32,29 @@ class PowerLevelController extends Notifier { if (config.action != null) { return switch (config.action!) { - MembershipAction.invite => - userLevel >= (event.content["invite"] as int? ?? 0), + MembershipAction.invite => userLevel >= content.invite, MembershipAction.kick => targetLevel != null && - userLevel >= (event.content["kick"] as int? ?? 50) && + userLevel >= content.kick && userLevel > targetLevel, MembershipAction.ban => targetLevel != null && - userLevel >= (event.content["ban"] as int? ?? 50) && + userLevel >= content.ban && userLevel > targetLevel, - MembershipAction.unban => - userLevel >= (event.content["ban"] as int? ?? 50), + MembershipAction.unban => userLevel >= content.ban, }; } if (config.eventType == "m.room.redaction") { - return userLevel >= (event.content["redact"] as int? ?? 50); + return userLevel >= content.redact; } - final requiredLevel = events.containsKey(config.eventType) - ? (events[config.eventType] as int) - : (config.isStateEvent - ? (event.content["state_default"] as int? ?? 50) - : (event.content["events_default"] as int? ?? 0)); + final requiredLevel = + content.events[config.eventType] ?? + (config.isStateEvent ? content.stateDefault : content.eventsDefault); return userLevel >= requiredLevel; } diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index e99b5a2..7b970c7 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -4,12 +4,8 @@ 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/client_controller.dart"; -import "package:nexus/controllers/message_controller.dart"; -import "package:nexus/controllers/messages_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/models/configs/messages_config.dart"; -import "package:nexus/models/configs/message_config.dart"; +import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_related_events_request.dart"; import "package:nexus/models/requests/get_room_state_request.dart"; @@ -27,8 +23,9 @@ class RoomChatController extends AsyncNotifier> { @override Future> build() async { final client = ref.watch(ClientController.provider.notifier); - var room = ref.read(RoomsController.provider)[roomId]; - if (room == null) return InMemoryChatController(); + final room = ref.watch(RoomsController.provider)[roomId]; + if (room == null) return const IList.empty(); + final state = await client.getRoomState( GetRoomStateRequest(roomId: roomId), ); @@ -42,13 +39,14 @@ class RoomChatController extends AsyncNotifier> { state: state.fold( const IMap.empty(), (previousValue, stateEvent) => previousValue.add( - stateEvent.type, - (previousValue[stateEvent.type] ?? const IMap.empty()).addAll( - IMap({ - if (stateEvent.stateKey != null) - stateEvent.stateKey!: stateEvent.rowId, - }), - ), + stateEvent.type.type, + (previousValue[stateEvent.type.type] ?? const IMap.empty()) + .addAll( + IMap({ + if (stateEvent.stateKey != null) + stateEvent.stateKey!: stateEvent.rowId, + }), + ), ), ), ), @@ -56,152 +54,29 @@ class RoomChatController extends AsyncNotifier> { const ISet.empty(), ); - room = ref.read(RoomsController.provider)[roomId]; - if (room == null) return InMemoryChatController(); - - final messages = await ref.watch( - MessagesController.provider( - MessagesConfig( - room: room, - events: room.timeline - .map( - (timelineRowTuple) => room!.events.firstWhereOrNull( - (event) => event.rowId == timelineRowTuple.eventRowId, - ), - ) - .nonNulls - .toIList(), - ), - ).future, - ); - // While there are under 20 messages, try up to load more messages until there's no more or we have 20 messages. - final controller = InMemoryChatController(messages: messages.toList()); - for (var more = true; more == true && controller.messages.length < 20;) { - more = await loadOlder(controller); + if (room.hasMore && room.events.length < 20) { + loadOlder(); } - ref.onDispose(controller.dispose); - return controller; + return room.timeline + .map( + (timeline) => room.events.firstWhereOrNull( + (event) => event.rowId == timeline.eventRowId, + ), + ) + .nonNulls + .toIList(); } - Future addEvent(Event event) async { - final controller = await future; - if (event.type == "m.reaction") { - final message = controller.messages.firstWhereOrNull( - (message) => message.id == event.content["m.relates_to"]?["event_id"], - ); - final key = event.content["m.relates_to"]?["key"]; - if (message == null || key == null || !ref.mounted) return; - - return await controller.updateMessage( - message, - message.copyWith( - reactions: IMap(message.reactions) - .update( - key, - (reactors) => [...reactors, event.sender], - ifAbsent: () => [event.sender], - ) - .unlock, - ), - ); - } - - if (event.type == "m.room.redaction") { - final controller = await future; - final redactsId = event.content["redacts"]; - final originalMessage = controller.messages.firstWhereOrNull( - (message) => message.id == redactsId, - ); - if (!ref.mounted) return; - - if (originalMessage != null) { - return await controller.removeMessage(originalMessage); - } - - final redacts = ref - .read(SelectedRoomController.provider) - ?.events - .firstWhere((event) => event.eventId == redactsId); - - if (redacts?.type == "m.reaction") { - final message = controller.messages.firstWhereOrNull( - (message) => - message.id == redacts!.content["m.relates_to"]?["event_id"], - ); - final key = redacts!.content["m.relates_to"]?["key"]; - if (message == null || key == null || !ref.mounted) return; - - return await controller.updateMessage( - message, - message.copyWith( - reactions: IMap(message.reactions) - .update( - key, - (reactors) => IList(reactors).remove(redacts.sender).unlock, - ) - .where((_, value) => value.isNotEmpty) - .unlock, - ), - ); - } - } else { - final message = await ref.watch( - MessageController.provider( - MessageConfig( - event: event, - room: ref.read(RoomsController.provider)[roomId]!, - includeEdits: true, - ), - ).future, - ); - if (event.relationType == "m.replace") { - final controller = await future; - final oldMessage = controller.messages.firstWhereOrNull( - (element) => element.id == event.relatesTo, - ); - if (oldMessage == null || message == null || !ref.mounted) return; - - return await controller.updateMessage( - oldMessage, - message.copyWith( - id: oldMessage.id, - replyToMessageId: oldMessage.replyToMessageId, - metadata: { - ...(oldMessage.metadata ?? {}), - ...(message.metadata ?? {}) - .toIMap() - .where((key, value) => value != null) - .unlock, - }, - ), - ); - } - if (message != null && ref.mounted) { - await insertMessage(message); - } - } - } - - Future insertMessage(Message message) async { - final controller = await future; - final oldMessage = message.metadata?["txnId"] == null - ? null - : controller.messages.firstWhereOrNull( - (element) => - element.metadata?["txnId"] == message.metadata?["txnId"], - ); - - return oldMessage == null - ? controller.insertMessage(message) - : controller.updateMessage(oldMessage, message); - } - - Future deleteMessage(Message message, {String? reason}) => ref + Future deleteMessage(Event event, {String? reason}) => ref .watch(ClientController.provider.notifier) .redactEvent( - RedactEventRequest(eventId: message.id, roomId: roomId, reason: reason), + RedactEventRequest( + eventId: event.eventId, + roomId: roomId, + reason: reason, + ), ); Future loadOlder() async { @@ -247,7 +122,7 @@ class RoomChatController extends AsyncNotifier> { bool shouldMention = true, required IList tags, required RelationType relationType, - Message? relation, + Event? relation, }) async { var taggedMessage = text; @@ -262,7 +137,6 @@ class RoomChatController extends AsyncNotifier> { } final client = ref.watch(ClientController.provider.notifier); - final room = ref.read(RoomsController.provider)[roomId]; final event = await client.sendMessage( SendMessageRequest( roomId: roomId, @@ -278,45 +152,39 @@ class RoomChatController extends AsyncNotifier> { text: taggedMessage, relation: relation == null ? null - : Relation(eventId: relation.id, relationType: relationType), + : Relation(eventId: relation.eventId, relationType: relationType), ), ); - final message = room == null - ? null - : await ref.watch( - MessageController.provider( - MessageConfig(room: room, event: event), - ).future, - ); - if (message != null) insertMessage(message); + // state = state TODO } - Future scrollToMessage(Message message) async { - final controller = await future; - Future setFlashing(bool flashing) => controller.updateMessage( - message, - message.copyWith( - metadata: {...(message.metadata ?? {}), "flashing": flashing}, - ), - ); + Future scrollToEvent(Event event) async { + // TODO: Impl + // final controller = await future; + // Future setFlashing(bool flashing) => controller.updateMessage( + // message, + // message.copyWith( + // metadata: {...(message.metadata ?? {}), "flashing": flashing}, + // ), + // ); - await setFlashing(true); - Timer(Duration(seconds: 1), () => setFlashing(false)); + // await setFlashing(true); + // Timer(Duration(seconds: 1), () => setFlashing(false)); - return await controller.scrollToMessage(message.id); + // return await controller.scrollToMessage(message.id); } Future removeReaction( String reaction, - Message message, + Event event, String userId, ) async { final client = ref.watch(ClientController.provider.notifier); final allReactionEvents = await client.getRelatedEvents( GetRelatedEventsRequest( roomId: roomId, - eventId: message.id, + eventId: event.eventId, relationType: "m.annotation", ), ); @@ -326,9 +194,11 @@ class RoomChatController extends AsyncNotifier> { .toIList(); final reactionEvent = reactionEvents?.firstWhereOrNull( - (event) => - event.sender == userId && - event.content["m.relates_to"]?["key"] == reaction, + (event) => switch (event.content) { + ReactionContent(:final key) => + key == reaction && event.sender == userId, + _ => false, + }, ); if (reactionEvent != null) { @@ -340,7 +210,7 @@ class RoomChatController extends AsyncNotifier> { } } - Future sendReaction(String reaction, Message message) async { + Future sendReaction(String reaction, Event event) async { final client = ref.watch(ClientController.provider.notifier); await client.sendEvent( @@ -349,7 +219,7 @@ class RoomChatController extends AsyncNotifier> { type: "m.reaction", content: { "m.relates_to": { - "event_id": message.id, + "event_id": event.eventId, "rel_type": "m.annotation", "key": reaction, }, diff --git a/lib/controllers/user_controller.dart b/lib/controllers/user_controller.dart index e7ca973..d3976bd 100644 --- a/lib/controllers/user_controller.dart +++ b/lib/controllers/user_controller.dart @@ -4,37 +4,37 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/members_controller.dart"; import "package:nexus/controllers/profile_controller.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/models/membership.dart"; +import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; -class UserController extends AsyncNotifier { +class UserController extends AsyncNotifier { final String userId; UserController(this.userId); @override - Future build() async { + Future build() async { final member = await ref.watch( MembersController.provider.selectAsync( - (value) => - value.firstWhereOrNull((membership) => membership.userId == userId), + (value) => value.firstWhereOrNull( + (membership) => membership.stateKey == userId, + ), ), ); - if (member != null) return member; + if (member is MembershipContent) { + return member!.content as MembershipContent; + } final profile = await ref.watch(ProfileController.provider(userId).future); - return Membership( + return MembershipContent( status: MembershipStatus.leave, - avatarUrl: profile.avatarUrl == null - ? null - : Uri.tryParse(profile.avatarUrl!), + avatarUrl: profile.avatarUrl, displayName: profile.displayName ?? userId.localpart, - userId: userId, ); } static final provider = - AsyncNotifierProvider.family( + AsyncNotifierProvider.family( UserController.new, ); } diff --git a/lib/controllers/via_controller.dart b/lib/controllers/via_controller.dart index b423947..0c24487 100644 --- a/lib/controllers/via_controller.dart +++ b/lib/controllers/via_controller.dart @@ -2,6 +2,10 @@ 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_state_controller.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/membership_status.dart"; import "package:nexus/models/room.dart"; class ViaController extends Notifier { @@ -22,22 +26,26 @@ class ViaController extends Notifier { addUserId(ref.watch(ClientStateController.provider)?.userId); final powerLevels = room.events.firstWhereOrNull( - (event) => event.rowId == room.state["m.room.power_levels"]?[""], + (event) => event.rowId == room.state[EventType.powerLevels.type]?[""], ); - for (final userId in IMap(powerLevels?.content["users"]).keys) { - addUserId(userId); - if (servers.length >= 5) break; + if (powerLevels?.content case PowerLevelsContent(:final users)) { + for (final userId in users.keys) { + addUserId(userId); + if (servers.length >= 5) break; + } } - final members = room.state["m.room.member"]?.values.toIList(); + final members = room.state[EventType.membership.type]?.values.toIList(); for (var i = 0; servers.length < 5; i++) { final member = room.events.firstWhereOrNull( (event) => event.rowId == members?.getOrNull(i), ); - if (member?.content["membership"] == "join") { - addUserId(member?.stateKey); + if (member?.content case MembershipContent(:final status)) { + if (status == MembershipStatus.join) { + addUserId(member?.stateKey); + } } if (members?.getOrNull(i) == null) break; diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart index 6e4f875..ded5f4b 100644 --- a/lib/models/content/membership.dart +++ b/lib/models/content/membership.dart @@ -9,9 +9,9 @@ abstract class MembershipContent extends Content with _$MembershipContent { MembershipContent._(); const factory MembershipContent({ @JsonKey(name: "displayname") required String displayName, - required MembershipStatus membership, - required Uri? avatarUrl, - required String? reason, + @JsonKey(name: "membership") required MembershipStatus status, + Uri? avatarUrl, + String? reason, }) = _MembershipContent; factory MembershipContent.fromJson(Map json) => diff --git a/lib/models/membership.dart b/lib/models/membership.dart deleted file mode 100644 index ce0cc42..0000000 --- a/lib/models/membership.dart +++ /dev/null @@ -1,32 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/models/membership_status.dart"; -part "membership.freezed.dart"; - -@freezed -abstract class Membership with _$Membership { - const Membership._(); - const factory Membership({ - required MembershipStatus status, - required Uri? avatarUrl, - required String displayName, - required String userId, - }) = _Membership; - - factory Membership.fromContent( - IMap content, - String userId, - String homeserver, - ) => Membership( - status: MembershipStatus.values.firstWhere( - (status) => status.name == content["membership"], - orElse: () => MembershipStatus.leave, - ), - avatarUrl: Uri.tryParse( - content["avatar_url"] ?? "", - )?.mxcToHttps(homeserver), - userId: userId, - displayName: content["displayname"] ?? userId.substring(1).split(":").first, - ); -} diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index dc5cb11..3507d84 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -386,7 +386,7 @@ class RoomChat extends HookConsumerWidget { message, content: message.text, groupStatus: groupStatus, - onTapReply: notifier.scrollToMessage, + onTapReply: notifier.scrollToEvent, updateMessage: controller.updateMessage, isSentByMe: isSentByMe, ), @@ -402,7 +402,7 @@ class RoomChat extends HookConsumerWidget { message, content: message.text, groupStatus: groupStatus, - onTapReply: notifier.scrollToMessage, + onTapReply: notifier.scrollToEvent, updateMessage: controller.updateMessage, isSentByMe: isSentByMe, extra: ExpandableImageMessage(message), @@ -429,7 +429,7 @@ class RoomChat extends HookConsumerWidget { child: FlyerChatFileMessage( topWidget: ReplyWidget( message, - onTapReply: notifier.scrollToMessage, + onTapReply: notifier.scrollToEvent, groupStatus: groupStatus, ), message: message, -- 2.53.0 From 788900d8523b0b1bb379d177687466ba0a5bcada Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 16 May 2026 15:35:45 -0400 Subject: [PATCH 018/108] fix all helpers --- lib/helpers/extensions/show_user_popover.dart | 30 +++++++++++-------- lib/widgets/chat_page/user_popover.dart | 28 ++++++++--------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart index 1698879..1ef68e9 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -1,18 +1,24 @@ import "package:flutter/material.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart"; -import "package:nexus/models/membership.dart"; +import "package:nexus/models/content/membership.dart"; import "package:nexus/widgets/chat_page/user_popover.dart"; extension ShowUserPopover on BuildContext { - void showUserPopover(Membership member, {required Offset globalPosition}) => - showContextMenu( - globalPosition: globalPosition, - children: [ - PopupMenuItem( - enabled: false, - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: IconTheme(data: IconThemeData(), child: UserPopover(member)), - ), - ], - ); + void showUserPopover( + MembershipContent member, + String userId, { + required Offset globalPosition, + }) => showContextMenu( + globalPosition: globalPosition, + children: [ + PopupMenuItem( + enabled: false, + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: IconTheme( + data: IconThemeData(), + child: UserPopover(member, userId), + ), + ), + ], + ); } diff --git a/lib/widgets/chat_page/user_popover.dart b/lib/widgets/chat_page/user_popover.dart index a9a4799..115bcda 100644 --- a/lib/widgets/chat_page/user_popover.dart +++ b/lib/widgets/chat_page/user_popover.dart @@ -9,7 +9,7 @@ import "package:nexus/controllers/profile_controller.dart"; import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/models/configs/power_level_config.dart"; -import "package:nexus/models/membership.dart"; +import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; import "package:nexus/models/requests/membership_action.dart"; import "package:nexus/models/requests/set_membership_request.dart"; @@ -19,8 +19,9 @@ import "package:nexus/widgets/chat_page/expandable_image.dart"; import "package:nexus/widgets/form_text_input.dart"; class UserPopover extends ConsumerWidget { - final Membership member; - const UserPopover(this.member, {super.key}); + final MembershipContent member; + final String userId; + const UserPopover(this.member, this.userId, {super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -37,16 +38,12 @@ class UserPopover extends ConsumerWidget { builder: (context) { final actionReasonController = useTextEditingController(); return AlertDialog( - title: Text( - "${toBeginningOfSentenceCase(action.name)} ${member.userId}", - ), + title: Text("${toBeginningOfSentenceCase(action.name)} $userId"), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - "Are you sure you want to ${action.name} ${member.userId}?", - ), + Text("Are you sure you want to ${action.name} $userId?"), SizedBox(height: 12), FormTextInput( required: false, @@ -67,7 +64,7 @@ class UserPopover extends ConsumerWidget { client .setMembership( SetMembershipRequest( - userId: member.userId, + userId: userId, roomId: roomId!, action: action, reason: actionReasonController.text, @@ -107,10 +104,10 @@ class UserPopover extends ConsumerWidget { member.displayName, style: textTheme.headlineSmall, ), - SelectableText(member.userId, style: textTheme.titleSmall), + SelectableText(userId, style: textTheme.titleSmall), SizedBox(height: 4), ref - .watch(ProfileController.provider(member.userId)) + .watch(ProfileController.provider(userId)) .betterWhen( loading: SizedBox.shrink, data: (profile) => Wrap( @@ -145,8 +142,7 @@ class UserPopover extends ConsumerWidget { ), ], ), - if (member.userId != - ref.watch(ClientStateController.provider)?.userId && + if (userId != ref.watch(ClientStateController.provider)?.userId && roomId != null) Wrap( spacing: 8, @@ -160,7 +156,7 @@ class UserPopover extends ConsumerWidget { eventType: "m.room.member", action: MembershipAction.kick, isStateEvent: true, - targetUser: member.userId, + targetUser: userId, ), ), ) && @@ -184,7 +180,7 @@ class UserPopover extends ConsumerWidget { eventType: "m.room.member", action: MembershipAction.ban, isStateEvent: true, - targetUser: member.userId, + targetUser: userId, ), ), )) -- 2.53.0 From 49c09b3c35d20c4744ddb29926bf660314d3031f Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 16 May 2026 16:22:49 -0400 Subject: [PATCH 019/108] easy widgets ported to use new event format --- lib/widgets/chat_page/composer/chat_box.dart | 13 ++-- .../chat_page/composer/mention_overlay.dart | 60 ++++++++++++------- .../chat_page/composer/relation_preview.dart | 21 ++++--- lib/widgets/chat_page/html/mention_chip.dart | 53 ++++++++-------- .../lazy_loading/message_avatar.dart | 23 ++++--- .../lazy_loading/message_displayname.dart | 10 ++-- lib/widgets/chat_page/member_list.dart | 44 ++++++++------ lib/widgets/chat_page/room_chat.dart | 2 +- 8 files changed, 132 insertions(+), 94 deletions(-) diff --git a/lib/widgets/chat_page/composer/chat_box.dart b/lib/widgets/chat_page/composer/chat_box.dart index 340010c..f793fda 100644 --- a/lib/widgets/chat_page/composer/chat_box.dart +++ b/lib/widgets/chat_page/composer/chat_box.dart @@ -5,13 +5,14 @@ import "package:fluttertagger/fluttertagger.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/power_level_controller.dart"; import "package:nexus/models/configs/power_level_config.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/chat_page/composer/mention_overlay.dart"; import "package:nexus/widgets/chat_page/composer/relation_preview.dart"; import "package:nexus/widgets/chat_page/emoji_picker_button.dart"; class ChatBox extends HookConsumerWidget { - final Message? relatedMessage; + final Event? relatedEvent; final RelationType relationType; final VoidCallback onDismiss; final FocusNode? node; @@ -22,7 +23,7 @@ class ChatBox extends HookConsumerWidget { }) onSend; const ChatBox({ - required this.relatedMessage, + required this.relatedEvent, required this.relationType, required this.onDismiss, required this.onSend, @@ -38,10 +39,8 @@ class ChatBox extends HookConsumerWidget { final shouldMention = useState(true); final query = useState(""); - if (relationType == RelationType.edit && - relatedMessage is TextMessage && - controller.value.text.isEmpty) { - controller.value.text = relatedMessage?.metadata?["editSource"] ?? ""; + if (relationType == RelationType.edit && controller.value.text.isEmpty) { + controller.value.text = relatedEvent?.localContent?.editSource ?? ""; } void send() { @@ -72,7 +71,7 @@ class ChatBox extends HookConsumerWidget { child: Column( children: [ RelationPreview( - relatedMessage, + relatedEvent, shouldMention: shouldMention.value, toggleShouldMention: () => shouldMention.value = !shouldMention.value, diff --git a/lib/widgets/chat_page/composer/mention_overlay.dart b/lib/widgets/chat_page/composer/mention_overlay.dart index b650421..b2a5492 100644 --- a/lib/widgets/chat_page/composer/mention_overlay.dart +++ b/lib/widgets/chat_page/composer/mention_overlay.dart @@ -4,6 +4,7 @@ import "package:nexus/controllers/members_by_type_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/via_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/loading.dart"; @@ -43,33 +44,48 @@ class MentionOverlay extends ConsumerWidget { ? members : members.where( (member) => - member.userId.toLowerCase().contains( - query.toLowerCase(), - ) == - true || - member.displayName - .toLowerCase() + member.stateKey + ?.toLowerCase() .contains( query.toLowerCase(), ) == - true, + true || + switch (member.content) { + MembershipContent( + :final displayName, + ) => + displayName + .toLowerCase() + .contains( + query.toLowerCase(), + ) == + true, + _ => false, + }, )) .map( - (member) => ListTile( - leading: AvatarOrHash( - member.avatarUrl, - member.displayName, - ), - title: Text(member.displayName), - subtitle: Text(member.userId), - onTap: () => addTag( - id: "[@${member.displayName}](matrix:u/${member.userId.substring(1)})", - name: member.userId - .substring(1) - .split(":") - .first, - ), - ), + (member) => switch (member.content) { + MembershipContent( + :final displayName, + :final avatarUrl, + ) => + ListTile( + leading: AvatarOrHash( + avatarUrl, + displayName, + ), + title: Text(displayName), + subtitle: Text(member.stateKey!), + onTap: () => addTag( + id: "[@$displayName](matrix:u/${member.stateKey!.substring(1)})", + name: member.stateKey! + .substring(1) + .split(":") + .first, + ), + ), + _ => SizedBox.shrink(), + }, ) .toList(), ), diff --git a/lib/widgets/chat_page/composer/relation_preview.dart b/lib/widgets/chat_page/composer/relation_preview.dart index bd7dec1..d4d3649 100644 --- a/lib/widgets/chat_page/composer/relation_preview.dart +++ b/lib/widgets/chat_page/composer/relation_preview.dart @@ -1,18 +1,19 @@ import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; class RelationPreview extends ConsumerWidget { - final Message? relatedMessage; + final Event? relatedEvent; final RelationType relationType; final VoidCallback onDismiss; final bool shouldMention; final VoidCallback toggleShouldMention; const RelationPreview( - this.relatedMessage, { + this.relatedEvent, { required this.relationType, required this.onDismiss, required this.shouldMention, @@ -22,7 +23,7 @@ class RelationPreview extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - if (relatedMessage == null) return SizedBox.shrink(); + if (relatedEvent == null) return SizedBox.shrink(); final theme = Theme.of(context); return Container( @@ -37,7 +38,7 @@ class RelationPreview extends ConsumerWidget { style: TextStyle(fontWeight: FontWeight.bold), ), - MessageAvatar(relatedMessage!), + MessageAvatar(relatedEvent!), Expanded( child: Row( @@ -45,16 +46,20 @@ class RelationPreview extends ConsumerWidget { children: [ Flexible( child: MessageDisplayname( - relatedMessage!, + relatedEvent!, style: theme.textTheme.labelMedium?.copyWith( fontWeight: FontWeight.bold, ), ), ), Expanded( - child: Text( - relatedMessage?.metadata?["body"] ?? - relatedMessage?.metadata?["eventType"] ?? + child: Text(switch (relatedEvent?.content) { + + _ => "" + } + + relatedEvent?.metadata?["body"] ?? + relatedEvent?.metadata?["eventType"] ?? "", maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/widgets/chat_page/html/mention_chip.dart b/lib/widgets/chat_page/html/mention_chip.dart index 575ad03..059b997 100644 --- a/lib/widgets/chat_page/html/mention_chip.dart +++ b/lib/widgets/chat_page/html/mention_chip.dart @@ -10,35 +10,38 @@ class MentionChip extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final membership = content.mention!.startsWith("@") == true + final mention = content.mention; + final membership = mention?.startsWith("@") == true ? ref - .watch(UserController.provider(content.mention!)) + .watch(UserController.provider(mention!)) .whenOrNull(data: (data) => data) : null; - return InkWell( - onTapUp: (details) { - content.mention; - if (membership != null) { - context.showUserPopover( - membership, - globalPosition: details.globalPosition, - ); - } - }, - child: IgnorePointer( - child: Chip( - label: Text( - (membership == null ? null : "@${membership.displayName}") ?? - content.mention!, - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onPrimary, + return mention == null + ? SizedBox.shrink() + : InkWell( + onTapUp: (details) { + if (membership != null) { + context.showUserPopover( + membership, + mention, + globalPosition: details.globalPosition, + ); + } + }, + child: IgnorePointer( + child: Chip( + label: Text( + (membership == null ? null : "@${membership.displayName}") ?? + mention, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + backgroundColor: Theme.of(context).colorScheme.primary, + ), ), - ), - backgroundColor: Theme.of(context).colorScheme.primary, - ), - ), - ); + ); } } diff --git a/lib/widgets/chat_page/lazy_loading/message_avatar.dart b/lib/widgets/chat_page/lazy_loading/message_avatar.dart index 4cc6665..7615be0 100644 --- a/lib/widgets/chat_page/lazy_loading/message_avatar.dart +++ b/lib/widgets/chat_page/lazy_loading/message_avatar.dart @@ -3,22 +3,29 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/author_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; class MessageAvatar extends ConsumerWidget { - final Message message; + final Event event; final double height; - const MessageAvatar(this.message, {this.height = 16, super.key}); + const MessageAvatar(this.event, {this.height = 16, super.key}); @override Widget build(BuildContext context, WidgetRef ref) => ref - .watch(AuthorController.provider(message)) + .watch(AuthorController.provider(event)) .betterWhen( data: (membership) => InkWell( - onTapUp: (details) => context.showUserPopover( - membership, - globalPosition: details.globalPosition, - ), + onTapUp: (details) { + if (event.content is MembershipContent) { + context.showUserPopover( + event.content as MembershipContent, + event.stateKey!, + globalPosition: details.globalPosition, + ); + } + }, child: AvatarOrHash( membership.avatarUrl, membership.displayName, @@ -26,6 +33,6 @@ class MessageAvatar extends ConsumerWidget { ), ), loading: () => - AvatarOrHash(null, message.sender.substring(1), height: height), + AvatarOrHash(null, event.stateKey!.substring(1), height: height), ); } diff --git a/lib/widgets/chat_page/lazy_loading/message_displayname.dart b/lib/widgets/chat_page/lazy_loading/message_displayname.dart index 72565e6..aa198ae 100644 --- a/lib/widgets/chat_page/lazy_loading/message_displayname.dart +++ b/lib/widgets/chat_page/lazy_loading/message_displayname.dart @@ -3,13 +3,14 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/author_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/event.dart"; class MessageDisplayname extends ConsumerWidget { - final Message message; + final Event event; final TextStyle? style; final bool clickable; const MessageDisplayname( - this.message, { + this.event, { this.clickable = true, this.style, super.key, @@ -17,17 +18,18 @@ class MessageDisplayname extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => ref - .watch(AuthorController.provider(message)) + .watch(AuthorController.provider(event)) .betterWhen( data: (membership) => InkWell( onTapUp: clickable ? (details) => context.showUserPopover( membership, + event.stateKey!, globalPosition: details.globalPosition, ) : null, child: Text( - "${membership.displayName}${message.metadata?["pmp"] == null ? "" : " (via ${message.sender})"}", + "${membership.displayName}${event.pmp == null ? "" : " (via ${event.stateKey})"}", style: style, overflow: TextOverflow.ellipsis, ), diff --git a/lib/widgets/chat_page/member_list.dart b/lib/widgets/chat_page/member_list.dart index 8be1ddd..3af1f0a 100644 --- a/lib/widgets/chat_page/member_list.dart +++ b/lib/widgets/chat_page/member_list.dart @@ -4,6 +4,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/members_by_type_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; @@ -62,26 +63,31 @@ class MemberList extends HookConsumerWidget { child: ListView( children: members .map( - (member) => InkWell( - onTapUp: (details) => context.showUserPopover( - member, - globalPosition: details.globalPosition, - ), - child: ListTile( - leading: AvatarOrHash( - member.avatarUrl, - member.displayName, + (member) => switch (member.content) { + MembershipContent( + :final avatarUrl, + :final displayName, + ) => + InkWell( + onTapUp: (details) => context.showUserPopover( + member.content as MembershipContent, + member.stateKey!, + globalPosition: details.globalPosition, + ), + child: ListTile( + leading: AvatarOrHash(avatarUrl, displayName), + title: Text( + displayName, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + member.stateKey!, + overflow: TextOverflow.ellipsis, + ), + ), ), - title: Text( - member.displayName, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - member.userId, - overflow: TextOverflow.ellipsis, - ), - ), - ), + _ => SizedBox.shrink(), + }, ) .toList(), ), diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 3507d84..b4639e5 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -371,7 +371,7 @@ class RoomChat extends HookConsumerWidget { ) .onError(showError), relationType: relationType.value, - relatedMessage: relatedMessage.value, + relatedEvent: relatedMessage.value, onDismiss: () => relatedMessage.value = null, ), -- 2.53.0 From ad14f2207ee169f35a9a9fc5f84dffddd7d22aa7 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 16 May 2026 21:21:33 -0400 Subject: [PATCH 020/108] Fixes to power level controller --- lib/controllers/power_level_controller.dart | 47 +++++++++----------- lib/models/configs/message_config.dart | 28 ------------ lib/models/configs/messages_config.dart | 17 ------- lib/models/configs/power_level_config.dart | 24 +++++----- lib/models/content/content.dart | 2 + lib/models/content/message.dart | 12 ++--- lib/models/content/redaction.dart | 14 ++++++ lib/widgets/chat_page/composer/chat_box.dart | 3 +- lib/widgets/chat_page/room_chat.dart | 7 +-- lib/widgets/chat_page/user_popover.dart | 8 +--- 10 files changed, 65 insertions(+), 97 deletions(-) delete mode 100644 lib/models/configs/message_config.dart delete mode 100644 lib/models/configs/messages_config.dart create mode 100644 lib/models/content/redaction.dart diff --git a/lib/controllers/power_level_controller.dart b/lib/controllers/power_level_controller.dart index 7d377a5..b165d9c 100644 --- a/lib/controllers/power_level_controller.dart +++ b/lib/controllers/power_level_controller.dart @@ -26,37 +26,32 @@ class PowerLevelController extends Notifier { content.users[userId] ?? content.usersDefault; final userLevel = powerLevelOf(user); - final targetLevel = config.targetUser != null - ? powerLevelOf(config.targetUser!) - : null; - if (config.action != null) { - return switch (config.action!) { - MembershipAction.invite => userLevel >= content.invite, + return switch (config) { + EventPowerLevelConfig(:final eventType) => + userLevel > (content.events[eventType.type] ?? content.eventsDefault), + MembershipActionPowerLevelConfig(:final action, :final targetUser) => + switch (action) { + MembershipAction.invite => userLevel >= content.invite, - MembershipAction.kick => - targetLevel != null && - userLevel >= content.kick && - userLevel > targetLevel, + MembershipAction.kick => + userLevel >= content.kick && userLevel > powerLevelOf(targetUser), - MembershipAction.ban => - targetLevel != null && - userLevel >= content.ban && - userLevel > targetLevel, + MembershipAction.ban => + userLevel >= content.ban && userLevel > powerLevelOf(targetUser), - MembershipAction.unban => userLevel >= content.ban, - }; - } + MembershipAction.unban => userLevel >= content.ban, + }, - if (config.eventType == "m.room.redaction") { - return userLevel >= content.redact; - } - - final requiredLevel = - content.events[config.eventType] ?? - (config.isStateEvent ? content.stateDefault : content.eventsDefault); - - return userLevel >= requiredLevel; + StatePowerLevelConfig(:final eventType) => + userLevel > (content.events[eventType.type] ?? content.stateDefault), + RedactPowerLevelConfig(:final targetUser) => + userLevel >= + (targetUser == user + ? (content.events[EventType.redaction.type] ?? + content.eventsDefault) + : content.redact), + }; } static final provider = NotifierProvider.autoDispose diff --git a/lib/models/configs/message_config.dart b/lib/models/configs/message_config.dart deleted file mode 100644 index 66a437c..0000000 --- a/lib/models/configs/message_config.dart +++ /dev/null @@ -1,28 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/room.dart"; -part "message_config.freezed.dart"; -part "message_config.g.dart"; - -@freezed -abstract class MessageConfig with _$MessageConfig { - const MessageConfig._(); - const factory MessageConfig({ - @Default(false) bool alwaysReturn, - @Default(false) bool includeEdits, - required Room room, - required Event event, - }) = _MessageConfig; - - @override - bool operator ==(Object other) => - other.runtimeType == runtimeType && - other is MessageConfig && - other.event == event; - - @override - int get hashCode => Object.hash(runtimeType, event); - - factory MessageConfig.fromJson(Map json) => - _$MessageConfigFromJson(json); -} diff --git a/lib/models/configs/messages_config.dart b/lib/models/configs/messages_config.dart deleted file mode 100644 index b33a71c..0000000 --- a/lib/models/configs/messages_config.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/room.dart"; -part "messages_config.freezed.dart"; -part "messages_config.g.dart"; - -@freezed -abstract class MessagesConfig with _$MessagesConfig { - const factory MessagesConfig({ - required Room room, - required IList events, - }) = _MessagesConfig; - - factory MessagesConfig.fromJson(Map json) => - _$MessagesConfigFromJson(json); -} diff --git a/lib/models/configs/power_level_config.dart b/lib/models/configs/power_level_config.dart index 31cc08c..a4bb9c1 100644 --- a/lib/models/configs/power_level_config.dart +++ b/lib/models/configs/power_level_config.dart @@ -1,17 +1,21 @@ import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; import "package:nexus/models/requests/membership_action.dart"; part "power_level_config.freezed.dart"; -part "power_level_config.g.dart"; @freezed -abstract class PowerLevelConfig with _$PowerLevelConfig { - const factory PowerLevelConfig({ - @Default(false) bool isStateEvent, - required String eventType, - MembershipAction? action, - String? targetUser, - }) = _PowerLevelConfig; +sealed class PowerLevelConfig with _$PowerLevelConfig { + const factory PowerLevelConfig({required EventType eventType}) = + EventPowerLevelConfig; - factory PowerLevelConfig.fromJson(Map json) => - _$PowerLevelConfigFromJson(json); + const factory PowerLevelConfig.membershipAction({ + required MembershipAction action, + required String targetUser, + }) = MembershipActionPowerLevelConfig; + + const factory PowerLevelConfig.state({required EventType eventType}) = + StatePowerLevelConfig; + + const factory PowerLevelConfig.redact({required String targetUser}) = + RedactPowerLevelConfig; } diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index ec729f5..760a90c 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -11,6 +11,7 @@ import "package:nexus/models/content/name.dart"; import "package:nexus/models/content/pinned_events.dart"; import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/reaction.dart"; +import "package:nexus/models/content/redaction.dart"; import "package:nexus/models/content/server_acl.dart"; import "package:nexus/models/content/topic.dart"; @@ -30,6 +31,7 @@ class Content { @JsonEnum(valueField: "type") enum EventType { encrypted("m.room.encrypted", Content.fromJson), + redaction("m.room.redaction", RedactionContent.fromJson), encryption("m.room.encryption", EncryptionContent.fromJson), membership("m.room.member", MembershipContent.fromJson), create("m.room.create", CreateContent.fromJson), diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index c61fb25..cca3af4 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -14,7 +14,7 @@ abstract class MessageContent extends Content with _$MessageContent { required String body, String? format, String? formattedBody, - }) = _TextMessageContent; + }) = TextMessageContent; @FreezedUnionValue("m.image") const factory MessageContent.image({ @@ -25,7 +25,7 @@ abstract class MessageContent extends Content with _$MessageContent { String? filename, ImageInfo? info, String? url, - }) = _ImageMessageContent; + }) = ImageMessageContent; @FreezedUnionValue("m.file") const factory MessageContent.file({ @@ -36,7 +36,7 @@ abstract class MessageContent extends Content with _$MessageContent { String? filename, FileInfo? info, String? url, - }) = _FileMessageContent; + }) = FileMessageContent; @FreezedUnionValue("m.audio") const factory MessageContent.audio({ @@ -47,7 +47,7 @@ abstract class MessageContent extends Content with _$MessageContent { String? filename, AudioInfo? info, String? url, - }) = _AudioMessageContent; + }) = AudioMessageContent; @FreezedUnionValue("m.video") const factory MessageContent.video({ @@ -58,13 +58,13 @@ abstract class MessageContent extends Content with _$MessageContent { String? filename, AudioInfo? info, String? url, - }) = _AudioMessageContent; + }) = AudioMessageContent; @FreezedUnionValue("m.location") const factory MessageContent.location({ required String body, required Uri geoUri, - }) = _LocationMessageContent; + }) = LocationMessageContent; factory MessageContent.fromJson(Map json) => _$MessageContentFromJson(json); diff --git a/lib/models/content/redaction.dart b/lib/models/content/redaction.dart new file mode 100644 index 0000000..289d4bf --- /dev/null +++ b/lib/models/content/redaction.dart @@ -0,0 +1,14 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "redaction.freezed.dart"; +part "redaction.g.dart"; + +@freezed +abstract class RedactionContent extends Content with _$RedactionContent { + RedactionContent._(); + const factory RedactionContent({String? reason, String? redacts}) = + _RedactionContent; + + factory RedactionContent.fromJson(Map json) => + _$RedactionContentFromJson(json); +} diff --git a/lib/widgets/chat_page/composer/chat_box.dart b/lib/widgets/chat_page/composer/chat_box.dart index f793fda..e0aaca9 100644 --- a/lib/widgets/chat_page/composer/chat_box.dart +++ b/lib/widgets/chat_page/composer/chat_box.dart @@ -5,6 +5,7 @@ import "package:fluttertagger/fluttertagger.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/power_level_controller.dart"; import "package:nexus/models/configs/power_level_config.dart"; +import "package:nexus/models/content/content.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/chat_page/composer/mention_overlay.dart"; @@ -87,7 +88,7 @@ class ChatBox extends HookConsumerWidget { children: ref.watch( PowerLevelController.provider( - PowerLevelConfig(eventType: "m.room.message"), + PowerLevelConfig(eventType: EventType.message), ), ) ? [ diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index b4639e5..48d1784 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -13,6 +13,7 @@ import "package:nexus/controllers/via_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/models/configs/power_level_config.dart"; +import "package:nexus/models/content/content.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/models/requests/report_request.dart"; import "package:nexus/widgets/chat_page/composer/chat_box.dart"; @@ -85,7 +86,7 @@ class RoomChat extends HookConsumerWidget { return [ if (ref.watch( PowerLevelController.provider( - PowerLevelConfig(eventType: "m.reaction"), + PowerLevelConfig(eventType: EventType.reaction), ), )) PopupMenuItem( @@ -129,7 +130,7 @@ class RoomChat extends HookConsumerWidget { ), if (ref.watch( PowerLevelController.provider( - PowerLevelConfig(eventType: "m.room.message"), + PowerLevelConfig(eventType: EventType.message), ), )) PopupMenuItem( @@ -167,7 +168,7 @@ class RoomChat extends HookConsumerWidget { ), if (ref.watch( PowerLevelController.provider( - PowerLevelConfig(eventType: "m.room.redaction"), + PowerLevelConfig.redact(targetUser: message.authorId), ), )) PopupMenuItem( diff --git a/lib/widgets/chat_page/user_popover.dart b/lib/widgets/chat_page/user_popover.dart index 115bcda..5ff4110 100644 --- a/lib/widgets/chat_page/user_popover.dart +++ b/lib/widgets/chat_page/user_popover.dart @@ -152,10 +152,8 @@ class UserPopover extends ConsumerWidget { if (ref.watch( PowerLevelController.provider( - PowerLevelConfig( - eventType: "m.room.member", + PowerLevelConfig.membershipAction( action: MembershipAction.kick, - isStateEvent: true, targetUser: userId, ), ), @@ -176,10 +174,8 @@ class UserPopover extends ConsumerWidget { ), if (ref.watch( PowerLevelController.provider( - PowerLevelConfig( - eventType: "m.room.member", + PowerLevelConfig.membershipAction( action: MembershipAction.ban, - isStateEvent: true, targetUser: userId, ), ), -- 2.53.0 From a2e0b6bdb1a8ca581566d98a8d364428d5d346ba Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 17 May 2026 16:42:12 -0400 Subject: [PATCH 021/108] add assertion for PowerLevelConfig.redaction --- lib/controllers/power_level_controller.dart | 9 ++++++++- lib/models/configs/power_level_config.dart | 4 ++-- lib/widgets/chat_page/composer/relation_preview.dart | 10 ++-------- lib/widgets/chat_page/room_chat.dart | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/lib/controllers/power_level_controller.dart b/lib/controllers/power_level_controller.dart index b165d9c..93e4ba3 100644 --- a/lib/controllers/power_level_controller.dart +++ b/lib/controllers/power_level_controller.dart @@ -13,6 +13,13 @@ class PowerLevelController extends Notifier { @override bool build() { + if (config case EventPowerLevelConfig(:final eventType)) { + assert( + eventType != EventType.redaction, + "Checking power level for a redaction should use [PowerLevelConfig.redaction].", + ); + } + final room = ref.watch(SelectedRoomController.provider); final event = room?.events.firstWhereOrNull( (event) => event.rowId == room.state[EventType.powerLevels.type]?[""], @@ -45,7 +52,7 @@ class PowerLevelController extends Notifier { StatePowerLevelConfig(:final eventType) => userLevel > (content.events[eventType.type] ?? content.stateDefault), - RedactPowerLevelConfig(:final targetUser) => + RedactionPowerLevelConfig(:final targetUser) => userLevel >= (targetUser == user ? (content.events[EventType.redaction.type] ?? diff --git a/lib/models/configs/power_level_config.dart b/lib/models/configs/power_level_config.dart index a4bb9c1..2ae7804 100644 --- a/lib/models/configs/power_level_config.dart +++ b/lib/models/configs/power_level_config.dart @@ -16,6 +16,6 @@ sealed class PowerLevelConfig with _$PowerLevelConfig { const factory PowerLevelConfig.state({required EventType eventType}) = StatePowerLevelConfig; - const factory PowerLevelConfig.redact({required String targetUser}) = - RedactPowerLevelConfig; + const factory PowerLevelConfig.redaction({required String targetUser}) = + RedactionPowerLevelConfig; } diff --git a/lib/widgets/chat_page/composer/relation_preview.dart b/lib/widgets/chat_page/composer/relation_preview.dart index d4d3649..5992bfc 100644 --- a/lib/widgets/chat_page/composer/relation_preview.dart +++ b/lib/widgets/chat_page/composer/relation_preview.dart @@ -53,14 +53,8 @@ class RelationPreview extends ConsumerWidget { ), ), Expanded( - child: Text(switch (relatedEvent?.content) { - - _ => "" - } - - relatedEvent?.metadata?["body"] ?? - relatedEvent?.metadata?["eventType"] ?? - "", + child: Text( + switch (relatedEvent?.content) {}, maxLines: 1, overflow: TextOverflow.ellipsis, softWrap: false, diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 48d1784..e47fc46 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -168,7 +168,7 @@ class RoomChat extends HookConsumerWidget { ), if (ref.watch( PowerLevelController.provider( - PowerLevelConfig.redact(targetUser: message.authorId), + PowerLevelConfig.redaction(targetUser: message.authorId), ), )) PopupMenuItem( -- 2.53.0 From 0be5336065ae5454d64f1a5e51705736e39b8753 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 17 May 2026 17:58:02 -0400 Subject: [PATCH 022/108] add a placeholder EventText widget --- lib/widgets/chat_page/event_text.dart | 11 ++ .../wrappers/text_message_wrapper.dart | 164 ------------------ 2 files changed, 11 insertions(+), 164 deletions(-) create mode 100644 lib/widgets/chat_page/event_text.dart delete mode 100644 lib/widgets/chat_page/wrappers/text_message_wrapper.dart diff --git a/lib/widgets/chat_page/event_text.dart b/lib/widgets/chat_page/event_text.dart new file mode 100644 index 0000000..54ecf66 --- /dev/null +++ b/lib/widgets/chat_page/event_text.dart @@ -0,0 +1,11 @@ +import "package:flutter/material.dart"; + +class EventText extends StatelessWidget { + final bool textOnly; + const EventText({this.textOnly = false, super.key}); + + @override + Widget build(BuildContext context) { + throw UnimplementedError(); // NEXT TODO + } +} diff --git a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart b/lib/widgets/chat_page/wrappers/text_message_wrapper.dart deleted file mode 100644 index 2870d23..0000000 --- a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart +++ /dev/null @@ -1,164 +0,0 @@ -import "package:cross_cache/cross_cache.dart"; -import "package:flutter/material.dart"; -import "package:flutter_linkify/flutter_linkify.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/controllers/url_preview_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/helpers/launch_helper.dart"; -import "package:nexus/widgets/chat_page/html/html.dart"; -import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart"; -import "package:nexus/widgets/chat_page/reply_widget.dart"; - -class TextMessageWrapper extends ConsumerWidget { - final Message message; - final String? content; - final MessageGroupStatus? groupStatus; - final Future Function(Message oldMessage, Message newMessage) - updateMessage; - final bool isSentByMe; - final Widget? extra; - final OnTapReply onTapReply; - - const TextMessageWrapper( - this.message, { - this.content, - this.onTapReply, - required this.updateMessage, - required this.groupStatus, - required this.isSentByMe, - this.extra, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final textMessage = message is TextMessage ? message as TextMessage : null; - - final link = textMessage == null - ? null - : RegExp( - r'''https?://[^\s"'<>]+''', - ).allMatches(textMessage.text).firstOrNull?.group(0); - - return MessageWrapper( - message, - ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(8)), - child: Container( - padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), - decoration: BoxDecoration( - color: isSentByMe - ? (message.id.startsWith("~") - ? colorScheme.onPrimary - : colorScheme.primaryContainer) - : colorScheme.surfaceContainer, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ReplyWidget( - message, - groupStatus: groupStatus, - onTapReply: onTapReply, - ), - if (content != null) - message.metadata?["format"] == "org.matrix.custom.html" - ? Html( - textStyle: message.metadata?["big"] == true - ? TextStyle(fontSize: 32) - : null, - content!.replaceAllMapped( - RegExp( - "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", - caseSensitive: false, - dotAll: true, - ), - (m) { - // If it's already an tag, leave it unchanged - if (m.group(1) != null) { - return m.group(1)!; - } - - // Otherwise, wrap the bare URL - final url = m.group(2)!; - return "$url"; - }, - ), - ) - : Linkify( - text: content!, - options: LinkifyOptions(humanize: false), - onOpen: (link) => ref - .watch(LaunchHelper.provider) - .launchUrl(Uri.parse(link.url)), - linkStyle: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - ), - if (textMessage?.editedAt != null) - Text("(edited)", style: theme.textTheme.labelSmall), - if (link != null) - ref - .watch(UrlPreviewController.provider(link)) - .betterWhen( - loading: SizedBox.shrink, - data: (preview) => preview == null - ? SizedBox.shrink() - : InkWell( - onTap: () => ref - .watch(LaunchHelper.provider) - .launchUrl(Uri.parse(link)), - child: Card( - child: Column( - children: [ - if (preview.title != null) - Text( - preview.title!, - style: theme.textTheme.labelLarge, - ), - if (preview.description != null) - Text(preview.description!), - if (preview.imageUrl != null) - Image( - errorBuilder: (_, _, _) => - SizedBox.shrink(), - width: preview.width, - height: preview.height, - image: CachedNetworkImage( - preview.imageUrl!, - ref.watch( - CrossCacheController.provider, - ), - headers: ref.headers, - ), - fit: BoxFit.cover, - ), - ], - ), - // text: link, - // backgroundColor: isSentByMe - // ? colorScheme.inversePrimary - // : colorScheme.surfaceContainerLow, - // outsidePadding: EdgeInsets.only(top: 4), - // insidePadding: EdgeInsets.symmetric( - // vertical: 8, - // horizontal: 16, - // ), - // linkPreviewData: preview, - // onLinkPreviewDataFetched: (_) => null, - ), - ), - ), - ?extra, - ], - ), - ), - ), - groupStatus, - ); - } -} -- 2.53.0 From 1fa050e7aeafb605a4dcdbfcf1dbbbd685f40601 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 17 May 2026 18:41:34 -0400 Subject: [PATCH 023/108] flesh out EventText a little more --- lib/widgets/chat_page/composer/relation_preview.dart | 9 ++------- lib/widgets/chat_page/event_text.dart | 10 +++++++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/widgets/chat_page/composer/relation_preview.dart b/lib/widgets/chat_page/composer/relation_preview.dart index 5992bfc..e8dabb1 100644 --- a/lib/widgets/chat_page/composer/relation_preview.dart +++ b/lib/widgets/chat_page/composer/relation_preview.dart @@ -2,6 +2,7 @@ import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; +import "package:nexus/widgets/chat_page/event_text.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; @@ -53,13 +54,7 @@ class RelationPreview extends ConsumerWidget { ), ), Expanded( - child: Text( - switch (relatedEvent?.content) {}, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: theme.textTheme.labelMedium, - ), + child: EventText(relatedEvent!, textOnly: true, maxLines: 1), ), ], ), diff --git a/lib/widgets/chat_page/event_text.dart b/lib/widgets/chat_page/event_text.dart index 54ecf66..17761ac 100644 --- a/lib/widgets/chat_page/event_text.dart +++ b/lib/widgets/chat_page/event_text.dart @@ -1,8 +1,16 @@ import "package:flutter/material.dart"; +import "package:nexus/models/event.dart"; class EventText extends StatelessWidget { + final Event event; final bool textOnly; - const EventText({this.textOnly = false, super.key}); + final int? maxLines; + const EventText( + this.event, { + this.textOnly = false, + this.maxLines, + super.key, + }); @override Widget build(BuildContext context) { -- 2.53.0 From cf5d1ad5d9eebd429a575de885d13bfaaac8aa2c Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 17 May 2026 21:08:17 -0400 Subject: [PATCH 024/108] building, but not yet working Still a lot to re-implement --- lib/controllers/client_controller.dart | 10 +- lib/controllers/room_chat_controller.dart | 15 +- lib/models/content/avatar.dart | 2 +- lib/models/content/canonical_alias.dart | 6 +- lib/models/content/content.dart | 2 - lib/models/content/create.dart | 2 +- lib/models/content/encryption.dart | 2 +- lib/models/content/join_rules.dart | 2 +- lib/models/content/membership.dart | 2 +- lib/models/content/message.dart | 18 +- lib/models/content/name.dart | 2 +- lib/models/content/pinned_events.dart | 5 +- lib/models/content/power_levels.dart | 2 +- lib/models/content/reaction.dart | 2 +- lib/models/content/redaction.dart | 2 +- lib/models/content/server_acl.dart | 2 +- lib/models/content/topic.dart | 6 +- lib/models/event.dart | 8 +- lib/widgets/chat_page/event_text.dart | 5 + lib/widgets/chat_page/reply_widget.dart | 100 ------- lib/widgets/chat_page/room_chat.dart | 277 +++++------------- .../chat_page/wrappers/message_wrapper.dart | 51 ++-- .../chat_page/wrappers/reaction_row.dart | 189 ++++++------ pubspec.lock | 8 + pubspec.yaml | 1 + 25 files changed, 255 insertions(+), 466 deletions(-) delete mode 100644 lib/widgets/chat_page/reply_widget.dart diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index f26b8ca..4539632 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -1,4 +1,3 @@ -import "dart:developer"; import "dart:ffi"; import "dart:io"; import "dart:isolate"; @@ -123,9 +122,12 @@ class ClientController extends AsyncNotifier { } debugPrint("Finished handling $muksEventType..."); } catch (error, stackTrace) { - debugPrintStack(stackTrace: stackTrace, label: error.toString()); - debugger(); - showError(error, stackTrace); + if (kDebugMode) { + debugPrintStack(stackTrace: stackTrace, label: error.toString()); + rethrow; + } else { + showError(error, stackTrace); + } } }); diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 7b970c7..5fada80 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -39,14 +39,13 @@ class RoomChatController extends AsyncNotifier> { state: state.fold( const IMap.empty(), (previousValue, stateEvent) => previousValue.add( - stateEvent.type.type, - (previousValue[stateEvent.type.type] ?? const IMap.empty()) - .addAll( - IMap({ - if (stateEvent.stateKey != null) - stateEvent.stateKey!: stateEvent.rowId, - }), - ), + stateEvent.type, + (previousValue[stateEvent.type] ?? const IMap.empty()).addAll( + IMap({ + if (stateEvent.stateKey != null) + stateEvent.stateKey!: stateEvent.rowId, + }), + ), ), ), ), diff --git a/lib/models/content/avatar.dart b/lib/models/content/avatar.dart index 650e2e6..66d4c47 100644 --- a/lib/models/content/avatar.dart +++ b/lib/models/content/avatar.dart @@ -7,7 +7,7 @@ part "avatar.g.dart"; @freezed abstract class AvatarContent extends Content with _$AvatarContent { AvatarContent._(); - const factory AvatarContent({ImageInfo? info, Uri? url}) = _AvatarContent; + factory AvatarContent({ImageInfo? info, Uri? url}) = _AvatarContent; factory AvatarContent.fromJson(Map json) => _$AvatarContentFromJson(json); diff --git a/lib/models/content/canonical_alias.dart b/lib/models/content/canonical_alias.dart index f675401..c6ac400 100644 --- a/lib/models/content/canonical_alias.dart +++ b/lib/models/content/canonical_alias.dart @@ -7,10 +7,8 @@ part "canonical_alias.g.dart"; abstract class CanonicalAliasContent extends Content with _$CanonicalAliasContent { CanonicalAliasContent._(); - const factory CanonicalAliasContent({ - String? alias, - @Default([]) altAliases, - }) = _CanonicalAliasContent; + factory CanonicalAliasContent({String? alias, @Default([]) altAliases}) = + _CanonicalAliasContent; factory CanonicalAliasContent.fromJson(Map json) => _$CanonicalAliasContentFromJson(json); diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index 760a90c..f388181 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -1,5 +1,4 @@ import "package:collection/collection.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/create.dart"; @@ -28,7 +27,6 @@ class Content { Content.fromJson)(eventJson); } -@JsonEnum(valueField: "type") enum EventType { encrypted("m.room.encrypted", Content.fromJson), redaction("m.room.redaction", RedactionContent.fromJson), diff --git a/lib/models/content/create.dart b/lib/models/content/create.dart index 78eef9f..08d1c22 100644 --- a/lib/models/content/create.dart +++ b/lib/models/content/create.dart @@ -7,7 +7,7 @@ part "create.g.dart"; @freezed abstract class CreateContent extends Content with _$CreateContent { CreateContent._(); - const factory CreateContent({ + factory CreateContent({ @JsonKey(name: "creator") String? creatorId, @JsonKey(name: "additional_creators") diff --git a/lib/models/content/encryption.dart b/lib/models/content/encryption.dart index 0fea339..3380632 100644 --- a/lib/models/content/encryption.dart +++ b/lib/models/content/encryption.dart @@ -6,7 +6,7 @@ part "encryption.g.dart"; @freezed abstract class EncryptionContent extends Content with _$EncryptionContent { EncryptionContent._(); - const factory EncryptionContent({ + factory EncryptionContent({ required String algorithm, @JsonKey(name: "rotation_period_ms") diff --git a/lib/models/content/join_rules.dart b/lib/models/content/join_rules.dart index a890d5c..1d14eee 100644 --- a/lib/models/content/join_rules.dart +++ b/lib/models/content/join_rules.dart @@ -8,7 +8,7 @@ part "join_rules.g.dart"; @freezed abstract class JoinRulesContent extends Content with _$JoinRulesContent { JoinRulesContent._(); - const factory JoinRulesContent({ + factory JoinRulesContent({ required JoinRule joinRule, @Default(IList.empty()) IList allow, }) = _JoinRulesContent; diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart index ded5f4b..c963ed7 100644 --- a/lib/models/content/membership.dart +++ b/lib/models/content/membership.dart @@ -7,7 +7,7 @@ part "membership.g.dart"; @freezed abstract class MembershipContent extends Content with _$MembershipContent { MembershipContent._(); - const factory MembershipContent({ + factory MembershipContent({ @JsonKey(name: "displayname") required String displayName, @JsonKey(name: "membership") required MembershipStatus status, Uri? avatarUrl, diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index cca3af4..2408993 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -9,7 +9,7 @@ part "message.g.dart"; @Freezed(unionKey: "msgtype", fallbackUnion: "default") abstract class MessageContent extends Content with _$MessageContent { MessageContent._(); - const factory MessageContent({ + factory MessageContent({ required String msgtype, required String body, String? format, @@ -17,7 +17,7 @@ abstract class MessageContent extends Content with _$MessageContent { }) = TextMessageContent; @FreezedUnionValue("m.image") - const factory MessageContent.image({ + factory MessageContent.image({ required String body, String? format, String? formattedBody, @@ -28,7 +28,7 @@ abstract class MessageContent extends Content with _$MessageContent { }) = ImageMessageContent; @FreezedUnionValue("m.file") - const factory MessageContent.file({ + factory MessageContent.file({ required String body, String? format, String? formattedBody, @@ -39,7 +39,7 @@ abstract class MessageContent extends Content with _$MessageContent { }) = FileMessageContent; @FreezedUnionValue("m.audio") - const factory MessageContent.audio({ + factory MessageContent.audio({ required String body, String? format, String? formattedBody, @@ -50,7 +50,7 @@ abstract class MessageContent extends Content with _$MessageContent { }) = AudioMessageContent; @FreezedUnionValue("m.video") - const factory MessageContent.video({ + factory MessageContent.video({ required String body, String? format, String? formattedBody, @@ -58,13 +58,11 @@ abstract class MessageContent extends Content with _$MessageContent { String? filename, AudioInfo? info, String? url, - }) = AudioMessageContent; + }) = VideoMessageContent; @FreezedUnionValue("m.location") - const factory MessageContent.location({ - required String body, - required Uri geoUri, - }) = LocationMessageContent; + factory MessageContent.location({required String body, required Uri geoUri}) = + LocationMessageContent; factory MessageContent.fromJson(Map json) => _$MessageContentFromJson(json); diff --git a/lib/models/content/name.dart b/lib/models/content/name.dart index 35bac40..205f6bb 100644 --- a/lib/models/content/name.dart +++ b/lib/models/content/name.dart @@ -6,7 +6,7 @@ part "name.g.dart"; @freezed abstract class NameContent extends Content with _$NameContent { NameContent._(); - const factory NameContent({required String name}) = _NameContent; + factory NameContent({required String name}) = _NameContent; factory NameContent.fromJson(Map json) => _$NameContentFromJson(json); diff --git a/lib/models/content/pinned_events.dart b/lib/models/content/pinned_events.dart index a259ba4..d17a0de 100644 --- a/lib/models/content/pinned_events.dart +++ b/lib/models/content/pinned_events.dart @@ -7,9 +7,8 @@ part "pinned_events.g.dart"; @freezed abstract class PinnedEventsContent extends Content with _$PinnedEventsContent { PinnedEventsContent._(); - const factory PinnedEventsContent({ - @Default(IList.empty()) IList pinned, - }) = _PinnedEventsContent; + factory PinnedEventsContent({@Default(IList.empty()) IList pinned}) = + _PinnedEventsContent; factory PinnedEventsContent.fromJson(Map json) => _$PinnedEventsContentFromJson(json); diff --git a/lib/models/content/power_levels.dart b/lib/models/content/power_levels.dart index f2ab876..594dac0 100644 --- a/lib/models/content/power_levels.dart +++ b/lib/models/content/power_levels.dart @@ -9,7 +9,7 @@ part "power_levels.g.dart"; abstract class PowerLevelsContent extends Content with _$PowerLevelsContent { PowerLevelsContent._(); - const factory PowerLevelsContent({ + factory PowerLevelsContent({ @Default(IMap.empty()) IMap events, @Default(IMap.empty()) IMap users, Notifications? notifications, diff --git a/lib/models/content/reaction.dart b/lib/models/content/reaction.dart index 7cdec08..0361df8 100644 --- a/lib/models/content/reaction.dart +++ b/lib/models/content/reaction.dart @@ -8,7 +8,7 @@ String? keyFromJson(Map json) => json["m.relates_to"]?["key"]; @freezed abstract class ReactionContent extends Content with _$ReactionContent { ReactionContent._(); - const factory ReactionContent({@JsonKey(fromJson: keyFromJson) String? key}) = + factory ReactionContent({@JsonKey(fromJson: keyFromJson) String? key}) = _ReactionContent; factory ReactionContent.fromJson(Map json) => diff --git a/lib/models/content/redaction.dart b/lib/models/content/redaction.dart index 289d4bf..e9c1a90 100644 --- a/lib/models/content/redaction.dart +++ b/lib/models/content/redaction.dart @@ -6,7 +6,7 @@ part "redaction.g.dart"; @freezed abstract class RedactionContent extends Content with _$RedactionContent { RedactionContent._(); - const factory RedactionContent({String? reason, String? redacts}) = + factory RedactionContent({String? reason, String? redacts}) = _RedactionContent; factory RedactionContent.fromJson(Map json) => diff --git a/lib/models/content/server_acl.dart b/lib/models/content/server_acl.dart index 6ee5fea..1e50988 100644 --- a/lib/models/content/server_acl.dart +++ b/lib/models/content/server_acl.dart @@ -7,7 +7,7 @@ part "server_acl.g.dart"; @freezed abstract class ServerACLContent extends Content with _$ServerACLContent { ServerACLContent._(); - const factory ServerACLContent({ + factory ServerACLContent({ @Default(IList.empty()) IList allow, @Default(IList.empty()) IList deny, @Default(true) allowIpLiterals, diff --git a/lib/models/content/topic.dart b/lib/models/content/topic.dart index ee561c7..8fa5229 100644 --- a/lib/models/content/topic.dart +++ b/lib/models/content/topic.dart @@ -7,7 +7,7 @@ part "topic.g.dart"; @freezed abstract class TopicContent extends Content with _$TopicContent { TopicContent._(); - const factory TopicContent({ + factory TopicContent({ required String topic, @JsonKey(name: "m.topic") TopicContentBlock? content, }) = _TopicContent; @@ -18,7 +18,7 @@ abstract class TopicContent extends Content with _$TopicContent { @freezed abstract class TopicContentBlock with _$TopicContentBlock { - const factory TopicContentBlock({ + factory TopicContentBlock({ @Default(IList.empty()) @JsonKey(name: "m.text") IList representations, @@ -30,7 +30,7 @@ abstract class TopicContentBlock with _$TopicContentBlock { @freezed abstract class TextualRepresentation with _$TextualRepresentation { - const factory TextualRepresentation({ + factory TextualRepresentation({ required String body, @Default("text/plain") String mimetype, }) = _TextualRepresentation; diff --git a/lib/models/event.dart b/lib/models/event.dart index 798b505..21fbedb 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -6,8 +6,8 @@ import "package:nexus/models/profile.dart"; part "event.freezed.dart"; part "event.g.dart"; -Profile? pmpFromJson(Map json) { - final pmp = json["content"]?["com.beeper.per_message_profile"]; +Profile? pmpFromJson(Map? json) { + final pmp = json?["content"]?["com.beeper.per_message_profile"]; return pmp == null ? null : Profile.fromJson(pmp); } @@ -19,7 +19,7 @@ abstract class Event with _$Event { required String roomId, required String eventId, required String sender, - required EventType type, + required String type, String? stateKey, @EpochDateTimeConverter() required DateTime timestamp, IMap? decrypted, @@ -36,7 +36,7 @@ abstract class Event with _$Event { @JsonKey(name: "last_edit_rowid") int? lastEditRowId, @UnreadTypeConverter() UnreadType? unreadType, @JsonKey(fromJson: pmpFromJson) Profile? pmp, - @JsonKey(fromJson: Content.fromJson) required Content content, + @JsonKey(fromJson: Content.fromEventJson) required Content content, }) = _Event; factory Event.fromJson(Map json) => _$EventFromJson(json); diff --git a/lib/widgets/chat_page/event_text.dart b/lib/widgets/chat_page/event_text.dart index 17761ac..ca24584 100644 --- a/lib/widgets/chat_page/event_text.dart +++ b/lib/widgets/chat_page/event_text.dart @@ -1,3 +1,4 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:nexus/models/event.dart"; @@ -5,10 +6,14 @@ class EventText extends StatelessWidget { final Event event; final bool textOnly; final int? maxLines; + final VoidCallback? onTapReply; + final IList Function(Event event)? getEventOptions; const EventText( this.event, { + this.onTapReply, this.textOnly = false, this.maxLines, + this.getEventOptions, super.key, }); diff --git a/lib/widgets/chat_page/reply_widget.dart b/lib/widgets/chat_page/reply_widget.dart deleted file mode 100644 index 24fcdd7..0000000 --- a/lib/widgets/chat_page/reply_widget.dart +++ /dev/null @@ -1,100 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/event_controller.dart"; -import "package:nexus/controllers/message_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/configs/message_config.dart"; -import "package:nexus/models/requests/get_event_request.dart"; -import "package:nexus/widgets/chat_page/html/quoted.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; - -typedef OnTapReply = void Function(Message message)?; - -class ReplyWidget extends ConsumerWidget { - final Message message; - final bool alwaysShow; - final MessageGroupStatus? groupStatus; - final OnTapReply onTapReply; - const ReplyWidget( - this.message, { - required this.groupStatus, - this.onTapReply, - this.alwaysShow = false, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final room = ref.watch(SelectedRoomController.provider); - return message.replyToMessageId == null || room == null - ? SizedBox.shrink() - : Padding( - padding: EdgeInsets.only(bottom: 12), - child: Quoted( - ref - .watch( - EventController.provider( - GetEventRequest( - room: room, - eventId: message.replyToMessageId!, - ), - ), - ) - .betterWhen( - loading: () => Text("Fetching event..."), - data: (event) => event == null - ? SizedBox.shrink() - : ref - .watch( - MessageController.provider( - MessageConfig(room: room, event: event), - ), - ) - .betterWhen( - loading: () => Text("Parsing message..."), - data: (replyMessage) { - if (replyMessage == null) { - return SizedBox.shrink(); - } - - return InkWell( - onTap: () => onTapReply?.call(replyMessage), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - MessageAvatar(replyMessage), - Flexible( - child: MessageDisplayname( - replyMessage, - clickable: false, - style: Theme.of(context) - .textTheme - .labelMedium - ?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - Flexible( - child: Text( - replyMessage.metadata!["body"], - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.labelMedium, - maxLines: 1, - ), - ), - ], - ), - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index e47fc46..7e875f6 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -11,21 +11,21 @@ import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/controllers/via_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/models/configs/power_level_config.dart"; import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/models/requests/report_request.dart"; import "package:nexus/widgets/chat_page/composer/chat_box.dart"; import "package:nexus/widgets/chat_page/emoji_picker_button.dart"; -import "package:nexus/widgets/chat_page/expandable_image_message.dart"; +import "package:nexus/widgets/chat_page/event_text.dart"; import "package:nexus/widgets/chat_page/member_list.dart"; -import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart"; import "package:nexus/widgets/chat_page/room_appbar.dart"; -import "package:nexus/widgets/chat_page/wrappers/text_message_wrapper.dart"; -import "package:nexus/widgets/chat_page/reply_widget.dart"; +import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart"; import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/main.dart"; +import "package:super_sliver_list/super_sliver_list.dart"; class RoomChat extends HookConsumerWidget { final bool isDesktop; @@ -38,17 +38,21 @@ class RoomChat extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final client = ref.watch(ClientController.provider.notifier); - final relatedMessage = useState(null); - final memberListOpened = useState(showMembersByDefault); + final relatedEvent = useState(null); final relationType = useState(RelationType.reply); + + final memberListOpened = useState(showMembersByDefault); + + final listController = useRef(ListController()); + final scrollController = useScrollController(); + // TODO: Do things on scroll to top or bottom + final userId = ref.watch(ClientStateController.provider)?.userId; final roomId = ref.watch( SelectedRoomController.provider.select((value) => value?.metadata?.id), ); final theme = Theme.of(context); - final danger = theme.colorScheme.error; if (roomId == null || userId == null) { return Scaffold( @@ -73,7 +77,7 @@ class RoomChat extends HookConsumerWidget { onKeyEvent: (_, event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) { - relatedMessage.value = null; + relatedEvent.value = null; return KeyEventResult.handled; } @@ -81,8 +85,9 @@ class RoomChat extends HookConsumerWidget { }, ); - List getMessageOptions(Message message) { - final isSentByMe = message.sender == userId; + IList getEventOptions(Event event) { + final danger = theme.colorScheme.error; + final isSentByMe = event.sender == userId; return [ if (ref.watch( PowerLevelController.provider( @@ -113,7 +118,7 @@ class RoomChat extends HookConsumerWidget { onPressed: () async { Navigator.of(context).pop(); await notifier - .sendReaction(emoji, message) + .sendReaction(emoji, event) .onError(showError); }, icon: Text(emoji), @@ -123,7 +128,7 @@ class RoomChat extends HookConsumerWidget { context: context, onPressed: Navigator.of(context).pop, onSelection: (emoji) => - notifier.sendReaction(emoji, message).onError(showError), + notifier.sendReaction(emoji, event).onError(showError), ), ], ), @@ -135,16 +140,16 @@ class RoomChat extends HookConsumerWidget { )) PopupMenuItem( onTap: () { - relatedMessage.value = message; + relatedEvent.value = event; relationType.value = RelationType.reply; composerNode.requestFocus(); }, child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")), ), - if (message is TextMessage && isSentByMe) + if (event.content is MessageContent && isSentByMe) PopupMenuItem( onTap: () { - relatedMessage.value = message; + relatedEvent.value = event; relationType.value = RelationType.edit; composerNode.requestFocus(); }, @@ -160,7 +165,7 @@ class RoomChat extends HookConsumerWidget { await Clipboard.setData( ClipboardData( text: - "matrix:roomid/${room.metadata?.id.substring(1)}/e/${message.id}$vias)", + "matrix:roomid/${room.metadata?.id.substring(1)}/e/${event.eventId}$vias)", ), ); }, @@ -168,7 +173,7 @@ class RoomChat extends HookConsumerWidget { ), if (ref.watch( PowerLevelController.provider( - PowerLevelConfig.redaction(targetUser: message.authorId), + PowerLevelConfig.redaction(targetUser: event.sender), ), )) PopupMenuItem( @@ -205,7 +210,7 @@ class RoomChat extends HookConsumerWidget { Navigator.of(context).pop(); await notifier .deleteMessage( - message, + event, reason: deleteReasonController.text, ) .onError(showError); @@ -254,15 +259,17 @@ class RoomChat extends HookConsumerWidget { ), TextButton( onPressed: () { - client.reportEvent( - ReportRequest( - roomId: roomId, - eventId: message.id, - reason: reasonController.text.isEmpty - ? null - : reasonController.text, - ), - ); + ref + .watch(ClientController.provider.notifier) + .reportEvent( + ReportRequest( + roomId: roomId, + eventId: event.eventId, + reason: reasonController.text.isEmpty + ? null + : reasonController.text, + ), + ); Navigator.of(context).pop(); }, child: Text("Report"), @@ -277,16 +284,9 @@ class RoomChat extends HookConsumerWidget { title: Text("Report", style: TextStyle(color: danger)), ), ), - ]; + ].toIList(); } - final chatTheme = ChatTheme.fromThemeData(theme).copyWith( - colors: ChatColors.fromThemeData(theme).copyWith( - primary: theme.colorScheme.primaryContainer, - onPrimary: theme.colorScheme.onPrimaryContainer, - ), - ); - return Scaffold( appBar: RoomAppbar( isDesktop: isDesktop, @@ -299,178 +299,55 @@ class RoomChat extends HookConsumerWidget { body: Row( children: [ Expanded( - child: Column( + child: Stack( children: [ - Expanded( + Positioned.fill( child: ref .watch(controllerProvider) .betterWhen( - data: (controller) => Chat( - currentUserId: userId, - theme: chatTheme, - onMessageSecondaryTap: - ( - context, - message, { - required index, - TapUpDetails? details, - }) => details?.globalPosition == null - ? null - : context.showContextMenu( - globalPosition: details!.globalPosition, - children: getMessageOptions(message), - ), - onMessageLongPress: - ( - context, - message, { - required details, - required index, - }) => context.showContextMenu( - globalPosition: details.globalPosition, - children: getMessageOptions(message), - ), - builders: Builders( - loadMoreBuilder: (_) => SizedBox.shrink(), - - chatAnimatedListBuilder: (_, itemBuilder) => - ChatAnimatedList( - itemBuilder: itemBuilder, - onEndReached: - ref.watch( - SelectedRoomController.provider.select( - (room) => room?.hasMore == true, - ), - ) - ? notifier.loadOlder - : null, - onStartReached: () async { - final room = ref.watch( - SelectedRoomController.provider, - ); - return room == null - ? null - : await client.markRead(room); - }, - bottomPadding: 72, - ), - - composerBuilder: (_) => ChatBox( - node: composerNode, - onSend: - ( - text, { - required shouldMention, - required tags, - }) => notifier - .send( - text, - tags: tags, - relationType: relationType.value, - shouldMention: shouldMention, - relation: relatedMessage.value, - ) - .onError(showError), - relationType: relationType.value, - relatedEvent: relatedMessage.value, - onDismiss: () => relatedMessage.value = null, + data: (events) => SuperListView.builder( + controller: scrollController, + listController: listController.value, + itemCount: events.length, + itemBuilder: (_, index) => MessageWrapper( + events[index], + EventText( + events[index], + onTapReply: () => + listController.value.animateToItem( + index: index, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => + Duration(milliseconds: 250), + curve: (_) => Curves.easeInOut, + ), + getEventOptions: getEventOptions, ), - - textMessageBuilder: - ( - context, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => TextMessageWrapper( - message, - content: message.text, - groupStatus: groupStatus, - onTapReply: notifier.scrollToEvent, - updateMessage: controller.updateMessage, - isSentByMe: isSentByMe, - ), - - imageMessageBuilder: - ( - context, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => TextMessageWrapper( - message, - content: message.text, - groupStatus: groupStatus, - onTapReply: notifier.scrollToEvent, - updateMessage: controller.updateMessage, - isSentByMe: isSentByMe, - extra: ExpandableImageMessage(message), - ), - - fileMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => MessageWrapper( - message, - InkWell( - onTap: () => showDialog( - context: context, - builder: (_) => Dialog( - child: Text( - "TODO: Download Attachments", - ), - ), - ), - child: FlyerChatFileMessage( - topWidget: ReplyWidget( - message, - onTapReply: notifier.scrollToEvent, - groupStatus: groupStatus, - ), - message: message, - index: index, - ), - ), - groupStatus, - ), - - systemMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => FlyerChatSystemMessage( - message: message, - index: index, - ), - - unsupportedMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => Text( - "${message.sender} sent ${message.metadata?["eventType"]}", - style: theme.textTheme.labelSmall?.copyWith( - color: Colors.grey, - ), - ), + // TODO: Reimplement grouping + isGrouped: false, + // TODO: Reimplement flashing + isFlashing: false, ), - resolveUser: (_) async => null, - chatController: controller, ), ), ), + ChatBox( + node: composerNode, + onSend: (text, {required shouldMention, required tags}) => + notifier + .send( + text, + tags: tags, + relationType: relationType.value, + shouldMention: shouldMention, + relation: relatedEvent.value, + ) + .onError(showError), + relationType: relationType.value, + relatedEvent: relatedEvent.value, + onDismiss: () => relatedEvent.value = null, + ), ], ), ), diff --git a/lib/widgets/chat_page/wrappers/message_wrapper.dart b/lib/widgets/chat_page/wrappers/message_wrapper.dart index 472f4a2..620d859 100644 --- a/lib/widgets/chat_page/wrappers/message_wrapper.dart +++ b/lib/widgets/chat_page/wrappers/message_wrapper.dart @@ -1,27 +1,32 @@ import "package:flutter/material.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/chat_page/wrappers/reaction_row.dart"; import "package:timeago/timeago.dart"; class MessageWrapper extends StatelessWidget { - final Message message; + final Event event; final Widget child; - final MessageGroupStatus? groupStatus; - const MessageWrapper(this.message, this.child, this.groupStatus, {super.key}); + final bool isGrouped; + final bool isFlashing; + const MessageWrapper( + this.event, + this.child, { + this.isGrouped = false, + this.isFlashing = false, + super.key, + }); @override Widget build(BuildContext context) { final theme = Theme.of(context); - final error = message.metadata?["error"]; return ClipRRect( borderRadius: BorderRadius.all(Radius.circular(12)), child: AnimatedContainer( - padding: message.metadata?["flashing"] == true - ? EdgeInsets.all(8) - : EdgeInsets.all(0), - color: message.metadata?["flashing"] == true + padding: isFlashing ? EdgeInsets.all(8) : EdgeInsets.all(0), + color: isFlashing ? Theme.of(context).colorScheme.onSurface.withAlpha(50) : Colors.transparent, duration: Duration(milliseconds: 250), @@ -29,48 +34,44 @@ class MessageWrapper extends StatelessWidget { spacing: 8, crossAxisAlignment: CrossAxisAlignment.start, children: [ - groupStatus?.isFirst != false - ? MessageAvatar(message, height: 40) - : SizedBox(width: 40), + isGrouped ? SizedBox(width: 40) : MessageAvatar(event, height: 40), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 4, children: [ - if (groupStatus?.isFirst != false) + if (!isGrouped) Row( spacing: 4, children: [ Flexible( child: MessageDisplayname( - message, + event, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), ), - if (message.deliveredAt != null && - groupStatus?.isFirst != false) - Tooltip( - message: message.deliveredAt!.toString(), - child: Text( - format(message.deliveredAt!), - style: theme.textTheme.labelSmall?.copyWith( - color: Colors.grey, - ), + Tooltip( + message: event.timestamp.toString(), + child: Text( + format(event.timestamp), + style: theme.textTheme.labelSmall?.copyWith( + color: Colors.grey, ), ), + ), ], ), child, - if (error != null && error != "not sent") + if (event.sendError != null && event.sendError != "not sent") Text( - error, + event.sendError!, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.error, ), ), - ReactionRow(message), + ReactionRow(event), ], ), ), diff --git a/lib/widgets/chat_page/wrappers/reaction_row.dart b/lib/widgets/chat_page/wrappers/reaction_row.dart index da7c825..5786134 100644 --- a/lib/widgets/chat_page/wrappers/reaction_row.dart +++ b/lib/widgets/chat_page/wrappers/reaction_row.dart @@ -1,5 +1,4 @@ import "package:cross_cache/cross_cache.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; @@ -10,106 +9,110 @@ import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/main.dart"; +import "package:nexus/models/event.dart"; class ReactionRow extends ConsumerWidget { - final Message message; - const ReactionRow(this.message, {super.key}); + final Event event; + const ReactionRow(this.event, {super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final clientState = ref.watch(ClientStateController.provider); - return Wrap( - spacing: 4, - runSpacing: 4, - children: clientState?.homeserverUrl == null || message.reactions == null - ? [] - : message.reactions! - .mapTo( - (reaction, reactors) => HookBuilder( - builder: (context) { - final enabled = useState(true); - final selected = reactors.contains(clientState!.userId); - return Tooltip( - message: reactors.join(", "), - child: ChoiceChip( - showCheckmark: false, - selected: selected, - label: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - Flexible( - child: reaction.startsWith("mxc://") - ? Image( - height: 20, - image: CachedNetworkImage( - headers: ref.headers, - Uri.parse(reaction) - .mxcToHttps( - clientState.homeserverUrl!, - ) - .toString(), - ref.watch( - CrossCacheController.provider, - ), - ), - ) - : Text( - reaction, - overflow: TextOverflow.ellipsis, - ), - ), - Text( - reactors.length.toString(), - overflow: TextOverflow.ellipsis, - ), - ], - ), - onSelected: enabled.value - ? (value) async { - enabled.value = false; - try { - final roomId = ref.watch( - SelectedRoomController.provider.select( - (value) => value?.metadata?.id, - ), - ); - if (roomId == null || - clientState.userId == null) { - return; - } + return SizedBox.shrink(); - final controller = ref.watch( - RoomChatController.provider( - roomId, - ).notifier, - ); + // TODO: IMPL + // return Wrap( + // spacing: 4, + // runSpacing: 4, + // children: clientState?.homeserverUrl == null + // ? [] + // : event.reactions + // .mapTo( + // (reaction, reactors) => HookBuilder( + // builder: (context) { + // final enabled = useState(true); + // final selected = reactors.contains(clientState!.userId); + // return Tooltip( + // message: reactors.join(", "), + // child: ChoiceChip( + // showCheckmark: false, + // selected: selected, + // label: Row( + // mainAxisSize: MainAxisSize.min, + // spacing: 8, + // children: [ + // Flexible( + // child: reaction.startsWith("mxc://") + // ? Image( + // height: 20, + // image: CachedNetworkImage( + // headers: ref.headers, + // Uri.parse(reaction) + // .mxcToHttps( + // clientState.homeserverUrl!, + // ) + // .toString(), + // ref.watch( + // CrossCacheController.provider, + // ), + // ), + // ) + // : Text( + // reaction, + // overflow: TextOverflow.ellipsis, + // ), + // ), + // Text( + // reactors.length.toString(), + // overflow: TextOverflow.ellipsis, + // ), + // ], + // ), + // onSelected: enabled.value + // ? (value) async { + // enabled.value = false; + // try { + // final roomId = ref.watch( + // SelectedRoomController.provider.select( + // (value) => value?.metadata?.id, + // ), + // ); + // if (roomId == null || + // clientState.userId == null) { + // return; + // } - if (selected) { - await controller - .removeReaction( - reaction, - message, - clientState.userId!, - ) - .onError(showError); - } else { - await controller - .sendReaction(reaction, message) - .onError(showError); - } - } finally { - enabled.value = true; - } - } - : null, - ), - ); - }, - ), - ) - .toList(), - ); + // final controller = ref.watch( + // RoomChatController.provider( + // roomId, + // ).notifier, + // ); + + // if (selected) { + // await controller + // .removeReaction( + // reaction, + // event, + // clientState.userId!, + // ) + // .onError(showError); + // } else { + // await controller + // .sendReaction(reaction, event) + // .onError(showError); + // } + // } finally { + // enabled.value = true; + // } + // } + // : null, + // ), + // ); + // }, + // ), + // ) + // .toList(), + // ); } } diff --git a/pubspec.lock b/pubspec.lock index 10f28bd..9ccfe8d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1196,6 +1196,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + super_sliver_list: + dependency: "direct main" + description: + name: super_sliver_list + sha256: b1e1e64d08ce40e459b9bb5d9f8e361617c26b8c9f3bb967760b0f436b6e3f56 + url: "https://pub.dev" + source: hosted + version: "0.4.1" synchronized: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4e8d609..d511fe6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,6 +52,7 @@ dependencies: git: url: https://github.com/Henry-Hiles/emoji_text_field flutter_blurhash: ^0.9.1 + super_sliver_list: ^0.4.1 dev_dependencies: build_runner: 2.15.0 -- 2.53.0 From 161a9d2f13c108d9ac5c3725ba8effa64d2563ed Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 17 May 2026 21:25:04 -0400 Subject: [PATCH 025/108] Displaying something now Just Event IDs so far --- lib/controllers/room_chat_controller.dart | 5 +++-- lib/widgets/chat_page/event_text.dart | 2 +- .../chat_page/lazy_loading/message_avatar.dart | 14 ++++++-------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 5fada80..9f4796b 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -23,8 +23,6 @@ class RoomChatController extends AsyncNotifier> { @override Future> build() async { final client = ref.watch(ClientController.provider.notifier); - final room = ref.watch(RoomsController.provider)[roomId]; - if (room == null) return const IList.empty(); final state = await client.getRoomState( GetRoomStateRequest(roomId: roomId), @@ -53,6 +51,9 @@ class RoomChatController extends AsyncNotifier> { const ISet.empty(), ); + final room = ref.watch(RoomsController.provider)[roomId]; + if (room == null) return const IList.empty(); + // While there are under 20 messages, try up to load more messages until there's no more or we have 20 messages. if (room.hasMore && room.events.length < 20) { loadOlder(); diff --git a/lib/widgets/chat_page/event_text.dart b/lib/widgets/chat_page/event_text.dart index ca24584..bc9e984 100644 --- a/lib/widgets/chat_page/event_text.dart +++ b/lib/widgets/chat_page/event_text.dart @@ -19,6 +19,6 @@ class EventText extends StatelessWidget { @override Widget build(BuildContext context) { - throw UnimplementedError(); // NEXT TODO + return Text(event.eventId); // NEXT TODO } } diff --git a/lib/widgets/chat_page/lazy_loading/message_avatar.dart b/lib/widgets/chat_page/lazy_loading/message_avatar.dart index 7615be0..da6ecec 100644 --- a/lib/widgets/chat_page/lazy_loading/message_avatar.dart +++ b/lib/widgets/chat_page/lazy_loading/message_avatar.dart @@ -18,13 +18,11 @@ class MessageAvatar extends ConsumerWidget { .betterWhen( data: (membership) => InkWell( onTapUp: (details) { - if (event.content is MembershipContent) { - context.showUserPopover( - event.content as MembershipContent, - event.stateKey!, - globalPosition: details.globalPosition, - ); - } + context.showUserPopover( + membership, + event.sender, + globalPosition: details.globalPosition, + ); }, child: AvatarOrHash( membership.avatarUrl, @@ -33,6 +31,6 @@ class MessageAvatar extends ConsumerWidget { ), ), loading: () => - AvatarOrHash(null, event.stateKey!.substring(1), height: height), + AvatarOrHash(null, event.sender.substring(1), height: height), ); } -- 2.53.0 From 91d573e4877ba5d369a7e41c163cf1fca584b948 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 17 May 2026 21:32:49 -0400 Subject: [PATCH 026/108] fix constant refreshing --- lib/widgets/chat_page/room_chat.dart | 64 +++++++++++++++------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 7e875f6..a637b4f 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -23,8 +23,10 @@ import "package:nexus/widgets/chat_page/event_text.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/wrappers/message_wrapper.dart"; +import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/main.dart"; +import "package:nexus/widgets/loading.dart"; import "package:super_sliver_list/super_sliver_list.dart"; class RoomChat extends HookConsumerWidget { @@ -287,6 +289,30 @@ class RoomChat extends HookConsumerWidget { ].toIList(); } + SuperListView messageView(IList events) => SuperListView.builder( + controller: scrollController, + listController: listController.value, + itemCount: events.length, + itemBuilder: (_, index) => MessageWrapper( + events[index], + EventText( + events[index], + onTapReply: () => listController.value.animateToItem( + index: index, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => Duration(milliseconds: 250), + curve: (_) => Curves.easeInOut, + ), + getEventOptions: getEventOptions, + ), + // TODO: Reimplement grouping + isGrouped: false, + // TODO: Reimplement flashing + isFlashing: false, + ), + ); + return Scaffold( appBar: RoomAppbar( isDesktop: isDesktop, @@ -302,35 +328,15 @@ class RoomChat extends HookConsumerWidget { child: Stack( children: [ Positioned.fill( - child: ref - .watch(controllerProvider) - .betterWhen( - data: (events) => SuperListView.builder( - controller: scrollController, - listController: listController.value, - itemCount: events.length, - itemBuilder: (_, index) => MessageWrapper( - events[index], - EventText( - events[index], - onTapReply: () => - listController.value.animateToItem( - index: index, - scrollController: scrollController, - alignment: 0.5, - duration: (_) => - Duration(milliseconds: 250), - curve: (_) => Curves.easeInOut, - ), - getEventOptions: getEventOptions, - ), - // TODO: Reimplement grouping - isGrouped: false, - // TODO: Reimplement flashing - isFlashing: false, - ), - ), - ), + child: switch (ref.watch(controllerProvider)) { + AsyncData(:final value) => messageView(value), + AsyncLoading(:final value) => + value == null ? Loading() : messageView(value), + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + }, ), ChatBox( node: composerNode, -- 2.53.0 From c65e8e056260b75fd66d490f105d8e09f2244ff2 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 17 May 2026 21:33:38 -0400 Subject: [PATCH 027/108] fix displayname widget --- lib/widgets/chat_page/lazy_loading/message_displayname.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/widgets/chat_page/lazy_loading/message_displayname.dart b/lib/widgets/chat_page/lazy_loading/message_displayname.dart index aa198ae..c3bb8e4 100644 --- a/lib/widgets/chat_page/lazy_loading/message_displayname.dart +++ b/lib/widgets/chat_page/lazy_loading/message_displayname.dart @@ -24,12 +24,12 @@ class MessageDisplayname extends ConsumerWidget { onTapUp: clickable ? (details) => context.showUserPopover( membership, - event.stateKey!, + event.sender, globalPosition: details.globalPosition, ) : null, child: Text( - "${membership.displayName}${event.pmp == null ? "" : " (via ${event.stateKey})"}", + "${membership.displayName}${event.pmp == null ? "" : " (via ${event.sender})"}", style: style, overflow: TextOverflow.ellipsis, ), -- 2.53.0 From 292a219ed20be7b9c80b3174677da67139181382 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 17 May 2026 21:37:43 -0400 Subject: [PATCH 028/108] pattern matching is awesome --- lib/widgets/chat_page/room_chat.dart | 53 +++++++++++++--------------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index a637b4f..a2add10 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -10,7 +10,6 @@ import "package:nexus/controllers/power_level_controller.dart"; import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/controllers/via_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/models/configs/power_level_config.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/message.dart"; @@ -289,30 +288,6 @@ class RoomChat extends HookConsumerWidget { ].toIList(); } - SuperListView messageView(IList events) => SuperListView.builder( - controller: scrollController, - listController: listController.value, - itemCount: events.length, - itemBuilder: (_, index) => MessageWrapper( - events[index], - EventText( - events[index], - onTapReply: () => listController.value.animateToItem( - index: index, - scrollController: scrollController, - alignment: 0.5, - duration: (_) => Duration(milliseconds: 250), - curve: (_) => Curves.easeInOut, - ), - getEventOptions: getEventOptions, - ), - // TODO: Reimplement grouping - isGrouped: false, - // TODO: Reimplement flashing - isFlashing: false, - ), - ); - return Scaffold( appBar: RoomAppbar( isDesktop: isDesktop, @@ -329,9 +304,31 @@ class RoomChat extends HookConsumerWidget { children: [ Positioned.fill( child: switch (ref.watch(controllerProvider)) { - AsyncData(:final value) => messageView(value), - AsyncLoading(:final value) => - value == null ? Loading() : messageView(value), + AsyncData(:final value) || + AsyncLoading(:final value?) => SuperListView.builder( + controller: scrollController, + listController: listController.value, + itemCount: value.length, + itemBuilder: (_, index) => MessageWrapper( + value[index], + EventText( + value[index], + onTapReply: () => listController.value.animateToItem( + index: index, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => Duration(milliseconds: 250), + curve: (_) => Curves.easeInOut, + ), + getEventOptions: getEventOptions, + ), + // TODO: Reimplement grouping + isGrouped: false, + // TODO: Reimplement flashing + isFlashing: false, + ), + ), + AsyncLoading() => Loading(), AsyncError(:final error, :final stackTrace) => ErrorDialog( error, stackTrace, -- 2.53.0 From 8d9645b4608b43aa07cc183c3b301c945dd35735 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 17 May 2026 21:42:14 -0400 Subject: [PATCH 029/108] fix room watch --- lib/controllers/room_chat_controller.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 9f4796b..f37716f 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -51,11 +51,13 @@ class RoomChatController extends AsyncNotifier> { const ISet.empty(), ); - final room = ref.watch(RoomsController.provider)[roomId]; + final room = ref.watch( + RoomsController.provider.select((rooms) => rooms[roomId]), + ); if (room == null) return const IList.empty(); - // While there are under 20 messages, try up to load more messages until there's no more or we have 20 messages. - if (room.hasMore && room.events.length < 20) { + // While there are under 30 messages, try up to load more messages until there's no more or we have 20 messages. + if (room.hasMore && room.events.length < 30) { loadOlder(); } -- 2.53.0 From cb22ed982201bafb0a3bc03f4c8bc35596834f11 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 17 May 2026 22:24:41 -0400 Subject: [PATCH 030/108] fix up bugs related to new architecture --- lib/controllers/client_controller.dart | 2 +- lib/controllers/members_controller.dart | 4 +++- lib/controllers/room_chat_controller.dart | 2 +- lib/helpers/extensions/get_localpart.dart | 2 +- lib/models/content/content.dart | 4 ++-- lib/models/content/create.dart | 4 ++-- lib/models/content/membership.dart | 2 +- lib/models/content/message.dart | 13 +++++++++++-- lib/models/event.dart | 7 +++++-- .../chat_page/composer/mention_overlay.dart | 16 +++++++++------- .../chat_page/lazy_loading/message_avatar.dart | 6 +++--- lib/widgets/chat_page/member_list.dart | 8 ++++++-- lib/widgets/chat_page/user_popover.dart | 5 +++-- 13 files changed, 48 insertions(+), 27 deletions(-) diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index 4539632..b09cd1c 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -80,7 +80,7 @@ class ClientController extends AsyncNotifier { case "send_complete": final event = Event.fromJson(decodedMuksEvent["event"]); - if (event.type == EventType.message) { + if (event.type == EventType.message.type) { // ref.watch(provider.notifier).addEvent(event); TODO } break; diff --git a/lib/controllers/members_controller.dart b/lib/controllers/members_controller.dart index 570a233..91da138 100644 --- a/lib/controllers/members_controller.dart +++ b/lib/controllers/members_controller.dart @@ -28,7 +28,9 @@ class MembersController extends AsyncNotifier> { ), ); - return state.where((state) => state.type == EventType.membership).toIList(); + return state + .where((state) => state.type == EventType.membership.type) + .toIList(); } static final provider = diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index f37716f..e4e4a72 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -57,7 +57,7 @@ class RoomChatController extends AsyncNotifier> { if (room == null) return const IList.empty(); // While there are under 30 messages, try up to load more messages until there's no more or we have 20 messages. - if (room.hasMore && room.events.length < 30) { + if (room.hasMore && room.timeline.length < 30) { loadOlder(); } diff --git a/lib/helpers/extensions/get_localpart.dart b/lib/helpers/extensions/get_localpart.dart index 445351f..fa3d285 100644 --- a/lib/helpers/extensions/get_localpart.dart +++ b/lib/helpers/extensions/get_localpart.dart @@ -1,3 +1,3 @@ extension GetLocalpart on String { - String get localpart => substring(1).split(":").first; + String get localpart => length > 1 ? substring(1).split(":").first : "?"; } diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index f388181..5277eeb 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -20,9 +20,9 @@ class Content { factory Content.fromJson(Map json) => Content(); Map toJson() => {}; - static Content fromEventJson(Map eventJson) => + static Content fromEventJson(Map eventJson, String type) => (EventType.values - .firstWhereOrNull((eventType) => eventType.type == eventJson["type"]) + .firstWhereOrNull((eventType) => eventType.type == type) ?.contentFromJson ?? Content.fromJson)(eventJson); } diff --git a/lib/models/content/create.dart b/lib/models/content/create.dart index 08d1c22..f20a713 100644 --- a/lib/models/content/create.dart +++ b/lib/models/content/create.dart @@ -19,7 +19,7 @@ abstract class CreateContent extends Content with _$CreateContent { @JsonKey(name: "m.federate") @Default(true) bool federated, @Default("1") String roomVersion, - required String type, + String? type, }) = _CreateContent; factory CreateContent.fromJson(Map json) => @@ -28,7 +28,7 @@ abstract class CreateContent extends Content with _$CreateContent { @freezed abstract class PreviousRoom with _$PreviousRoom { - const factory PreviousRoom({required int roomId}) = _PreviousRoom; + const factory PreviousRoom({required String roomId}) = _PreviousRoom; factory PreviousRoom.fromJson(Map json) => _$PreviousRoomFromJson(json); diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart index c963ed7..aa5a36d 100644 --- a/lib/models/content/membership.dart +++ b/lib/models/content/membership.dart @@ -8,7 +8,7 @@ part "membership.g.dart"; abstract class MembershipContent extends Content with _$MembershipContent { MembershipContent._(); factory MembershipContent({ - @JsonKey(name: "displayname") required String displayName, + @JsonKey(name: "displayname") required String? displayName, @JsonKey(name: "membership") required MembershipStatus status, Uri? avatarUrl, String? reason, diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index 2408993..82b4604 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -9,13 +9,22 @@ part "message.g.dart"; @Freezed(unionKey: "msgtype", fallbackUnion: "default") abstract class MessageContent extends Content with _$MessageContent { MessageContent._(); - factory MessageContent({ - required String msgtype, + factory MessageContent({required String body}) = UnknownMessageContent; + + @FreezedUnionValue("m.text") + factory MessageContent.text({ required String body, String? format, String? formattedBody, }) = TextMessageContent; + @FreezedUnionValue("m.notice") + factory MessageContent.notice({ + required String body, + String? format, + String? formattedBody, + }) = NoticeMessageContent; + @FreezedUnionValue("m.image") factory MessageContent.image({ required String body, diff --git a/lib/models/event.dart b/lib/models/event.dart index 21fbedb..58bd388 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -36,10 +36,13 @@ abstract class Event with _$Event { @JsonKey(name: "last_edit_rowid") int? lastEditRowId, @UnreadTypeConverter() UnreadType? unreadType, @JsonKey(fromJson: pmpFromJson) Profile? pmp, - @JsonKey(fromJson: Content.fromEventJson) required Content content, + @JsonKey(fromJson: Content.fromJson) required Content content, }) = _Event; - factory Event.fromJson(Map json) => _$EventFromJson(json); + factory Event.fromJson(Map json) => + _$EventFromJson(json).copyWith( + content: Content.fromEventJson(json["content"], json["type"] as String), + ); } @freezed diff --git a/lib/widgets/chat_page/composer/mention_overlay.dart b/lib/widgets/chat_page/composer/mention_overlay.dart index b2a5492..b303ea1 100644 --- a/lib/widgets/chat_page/composer/mention_overlay.dart +++ b/lib/widgets/chat_page/composer/mention_overlay.dart @@ -4,6 +4,7 @@ import "package:nexus/controllers/members_by_type_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/via_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; @@ -55,7 +56,7 @@ class MentionOverlay extends ConsumerWidget { :final displayName, ) => displayName - .toLowerCase() + ?.toLowerCase() .contains( query.toLowerCase(), ) == @@ -72,16 +73,17 @@ class MentionOverlay extends ConsumerWidget { ListTile( leading: AvatarOrHash( avatarUrl, - displayName, + displayName ?? + member.stateKey!.localpart, + ), + title: Text( + displayName ?? + member.stateKey!.localpart, ), - title: Text(displayName), subtitle: Text(member.stateKey!), onTap: () => addTag( id: "[@$displayName](matrix:u/${member.stateKey!.substring(1)})", - name: member.stateKey! - .substring(1) - .split(":") - .first, + name: member.stateKey!.localpart, ), ), _ => SizedBox.shrink(), diff --git a/lib/widgets/chat_page/lazy_loading/message_avatar.dart b/lib/widgets/chat_page/lazy_loading/message_avatar.dart index da6ecec..529edaa 100644 --- a/lib/widgets/chat_page/lazy_loading/message_avatar.dart +++ b/lib/widgets/chat_page/lazy_loading/message_avatar.dart @@ -2,8 +2,8 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/author_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; -import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; @@ -26,11 +26,11 @@ class MessageAvatar extends ConsumerWidget { }, child: AvatarOrHash( membership.avatarUrl, - membership.displayName, + membership.displayName ?? event.sender.localpart, height: height, ), ), loading: () => - AvatarOrHash(null, event.sender.substring(1), height: height), + AvatarOrHash(null, event.sender.localpart, height: height), ); } diff --git a/lib/widgets/chat_page/member_list.dart b/lib/widgets/chat_page/member_list.dart index 3af1f0a..a59146c 100644 --- a/lib/widgets/chat_page/member_list.dart +++ b/lib/widgets/chat_page/member_list.dart @@ -3,6 +3,7 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/members_by_type_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; @@ -75,9 +76,12 @@ class MemberList extends HookConsumerWidget { globalPosition: details.globalPosition, ), child: ListTile( - leading: AvatarOrHash(avatarUrl, displayName), + leading: AvatarOrHash( + avatarUrl, + displayName ?? member.sender.localpart, + ), title: Text( - displayName, + displayName ?? member.stateKey!.localpart, overflow: TextOverflow.ellipsis, ), subtitle: Text( diff --git a/lib/widgets/chat_page/user_popover.dart b/lib/widgets/chat_page/user_popover.dart index 5ff4110..5d438ff 100644 --- a/lib/widgets/chat_page/user_popover.dart +++ b/lib/widgets/chat_page/user_popover.dart @@ -8,6 +8,7 @@ import "package:nexus/controllers/power_level_controller.dart"; import "package:nexus/controllers/profile_controller.dart"; import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/models/configs/power_level_config.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; @@ -93,7 +94,7 @@ class UserPopover extends ConsumerWidget { member.avatarUrl?.toString(), child: AvatarOrHash( member.avatarUrl, - member.displayName, + member.displayName ?? userId.localpart, height: 80, ), ), @@ -101,7 +102,7 @@ class UserPopover extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ SelectableText( - member.displayName, + member.displayName ?? userId.localpart, style: textTheme.headlineSmall, ), SelectableText(userId, style: textTheme.titleSmall), -- 2.53.0 From 14ec487bbe8d03cc1c82206b32d28d56d0ce76d8 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 18 May 2026 10:12:53 -0400 Subject: [PATCH 031/108] fix error handling in models --- lib/controllers/client_controller.dart | 2 +- lib/models/content/content.dart | 18 +++++++++++------- lib/models/event.dart | 2 +- lib/models/profile.dart | 11 ++++++++++- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index b09cd1c..612efa6 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -230,7 +230,7 @@ class ClientController extends AsyncNotifier { Future paginate(PaginateRequest request) async => Paginate.fromJson(await _sendCommand("paginate", request.toJson())); - Future getProfile(String userId) async => Profile.fromJson({ + Future getProfile(String userId) async => Profile.fromJsonWithCatch({ ...(await _sendCommand("get_profile", {"user_id": userId})), "id": userId, }); diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index 5277eeb..dbe3695 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -1,4 +1,3 @@ -import "package:collection/collection.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/create.dart"; @@ -15,16 +14,21 @@ import "package:nexus/models/content/server_acl.dart"; import "package:nexus/models/content/topic.dart"; class Content { - Content(); + final String? parseError; + Content({this.parseError}); factory Content.fromJson(Map json) => Content(); Map toJson() => {}; - static Content fromEventJson(Map eventJson, String type) => - (EventType.values - .firstWhereOrNull((eventType) => eventType.type == type) - ?.contentFromJson ?? - Content.fromJson)(eventJson); + static Content fromEventJson(Map eventJson, String type) { + try { + return EventType.values + .firstWhere((eventType) => eventType.type == type) + .contentFromJson(eventJson); + } catch (error) { + return Content(parseError: error.toString()); + } + } } enum EventType { diff --git a/lib/models/event.dart b/lib/models/event.dart index 58bd388..d77911b 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -8,7 +8,7 @@ part "event.g.dart"; Profile? pmpFromJson(Map? json) { final pmp = json?["content"]?["com.beeper.per_message_profile"]; - return pmp == null ? null : Profile.fromJson(pmp); + return pmp == null ? null : Profile.fromJsonWithCatch(pmp); } @freezed diff --git a/lib/models/profile.dart b/lib/models/profile.dart index f0937f7..6ba2656 100644 --- a/lib/models/profile.dart +++ b/lib/models/profile.dart @@ -13,6 +13,7 @@ Object? readTimezone(Map map, _) => abstract class Profile with _$Profile { const factory Profile({ required String id, + String? parseError, Uri? avatarUrl, @JsonKey(name: "displayname") String? displayName, @@ -23,8 +24,16 @@ abstract class Profile with _$Profile { IList pronouns, }) = _Profile; - factory Profile.fromJson(Map json) => + factory Profile.fromJson(Map json) => _$ProfileFromJson(json); + + factory Profile.fromJsonWithCatch(Map json) { + try { + return Profile.fromJson(json); + } catch (error) { + return Profile(id: json["id"], parseError: error.toString()); + } + } } @freezed -- 2.53.0 From a5ddce3d08bc9628a44f8c918d6c007867802b3c Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 18 May 2026 10:17:15 -0400 Subject: [PATCH 032/108] fix padding --- lib/widgets/chat_page/room_chat.dart | 68 ++++++++++++++++------------ 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index a2add10..ca7dbb7 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -303,37 +303,47 @@ class RoomChat extends HookConsumerWidget { child: Stack( children: [ Positioned.fill( - child: switch (ref.watch(controllerProvider)) { - AsyncData(:final value) || - AsyncLoading(:final value?) => SuperListView.builder( - controller: scrollController, - listController: listController.value, - itemCount: value.length, - itemBuilder: (_, index) => MessageWrapper( - value[index], - EventText( - value[index], - onTapReply: () => listController.value.animateToItem( - index: index, - scrollController: scrollController, - alignment: 0.5, - duration: (_) => Duration(milliseconds: 250), - curve: (_) => Curves.easeInOut, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: switch (ref.watch(controllerProvider)) { + AsyncData(:final value) || + AsyncLoading(:final value?) => CustomScrollView( + controller: scrollController, + slivers: [ + SuperSliverList.builder( + listController: listController.value, + itemCount: value.length, + itemBuilder: (_, index) => MessageWrapper( + value[index], + EventText( + value[index], + onTapReply: () => + listController.value.animateToItem( + index: index, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => + Duration(milliseconds: 250), + curve: (_) => Curves.easeInOut, + ), + getEventOptions: getEventOptions, + ), + // TODO: Reimplement grouping + isGrouped: false, + // TODO: Reimplement flashing + isFlashing: false, + ), ), - getEventOptions: getEventOptions, - ), - // TODO: Reimplement grouping - isGrouped: false, - // TODO: Reimplement flashing - isFlashing: false, + SliverPadding( + padding: EdgeInsetsGeometry.only(bottom: 64), + ), + ], ), - ), - AsyncLoading() => Loading(), - AsyncError(:final error, :final stackTrace) => ErrorDialog( - error, - stackTrace, - ), - }, + AsyncLoading() => Loading(), + AsyncError(:final error, :final stackTrace) => + ErrorDialog(error, stackTrace), + }, + ), ), ChatBox( node: composerNode, -- 2.53.0 From 46d7ec4202b8f67ec23dff5cb13254faae6e8fe0 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 18 May 2026 10:25:13 -0400 Subject: [PATCH 033/108] fix avatar parsing --- lib/controllers/rooms_controller.dart | 24 +++--------------------- lib/controllers/user_controller.dart | 2 +- lib/widgets/avatar_or_hash.dart | 14 ++++++++++++-- lib/widgets/chat_page/room_chat.dart | 2 +- 4 files changed, 17 insertions(+), 25 deletions(-) diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart index 3c34fc9..7eaf49f 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -1,8 +1,6 @@ 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_state_controller.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/models/read_receipt.dart"; import "package:nexus/models/room.dart"; @@ -15,13 +13,6 @@ class RoomsController extends Notifier> { ISet leftRooms, { bool addToNewEvents = true, }) { - final homeserver = - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - ""; final merged = rooms.entries.fold(state, (acc, entry) { final roomId = entry.key; final incoming = entry.value; @@ -36,13 +27,7 @@ class RoomsController extends Notifier> { roomId, existing?.copyWith( hasMore: incoming.hasMore, - metadata: - incoming.metadata?.copyWith( - avatar: - incoming.metadata?.avatar?.mxcToHttps(homeserver) ?? - existing.metadata?.avatar, - ) ?? - existing.metadata, + metadata: incoming.metadata ?? existing.metadata, events: events!, state: incoming.state.entries.fold( existing.state, @@ -72,11 +57,7 @@ class RoomsController extends Notifier> { ), ), ) ?? - incoming.copyWith( - metadata: incoming.metadata?.copyWith( - avatar: incoming.metadata?.avatar?.mxcToHttps(homeserver), - ), - ), + incoming, ); }); @@ -84,6 +65,7 @@ class RoomsController extends Notifier> { merged, (acc, roomId) => acc.remove(roomId), ); + state = prunedList; } diff --git a/lib/controllers/user_controller.dart b/lib/controllers/user_controller.dart index d3976bd..4f0993b 100644 --- a/lib/controllers/user_controller.dart +++ b/lib/controllers/user_controller.dart @@ -21,7 +21,7 @@ class UserController extends AsyncNotifier { ), ); - if (member is MembershipContent) { + if (member?.content is MembershipContent) { return member!.content as MembershipContent; } diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatar_or_hash.dart index 28662e2..88cf7ca 100644 --- a/lib/widgets/avatar_or_hash.dart +++ b/lib/widgets/avatar_or_hash.dart @@ -2,8 +2,10 @@ import "package:color_hash/color_hash.dart"; import "package:cross_cache/cross_cache.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/cross_cache_controller.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/helpers/extensions/mxc_to_https.dart"; class AvatarOrHash extends ConsumerWidget { final Uri? avatar; @@ -28,6 +30,14 @@ class AvatarOrHash extends ConsumerWidget { color: ColorHash(title).color, child: Center(child: Text(title.isEmpty ? "" : title[0])), ); + final parsedAvatar = avatar?.mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value?.homeserverUrl, + ), + ) ?? + "", + ); return SizedBox( width: height, height: height, @@ -42,11 +52,11 @@ class AvatarOrHash extends ConsumerWidget { child: SizedBox( width: height, height: height, - child: avatar == null + child: parsedAvatar == null ? fallback ?? box : Image( image: CachedNetworkImage( - avatar.toString(), + parsedAvatar.toString(), ref.watch(CrossCacheController.provider), headers: ref.headers, ), diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index ca7dbb7..443ac68 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -304,7 +304,7 @@ class RoomChat extends HookConsumerWidget { children: [ Positioned.fill( child: Padding( - padding: EdgeInsets.symmetric(horizontal: 8), + padding: EdgeInsets.symmetric(horizontal: 12), child: switch (ref.watch(controllerProvider)) { AsyncData(:final value) || AsyncLoading(:final value?) => CustomScrollView( -- 2.53.0 From 9303fee0dec4646846af79ac71566c5c9d27bec0 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 18 May 2026 10:49:31 -0400 Subject: [PATCH 034/108] fix memberships constantly reloading --- lib/controllers/room_chat_controller.dart | 2 +- lib/widgets/chat_page/event_text.dart | 3 ++- .../lazy_loading/message_displayname.dart | 3 ++- lib/widgets/chat_page/room_chat.dart | 14 +++++++++----- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index e4e4a72..82d5da4 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -61,7 +61,7 @@ class RoomChatController extends AsyncNotifier> { loadOlder(); } - return room.timeline + return room.timeline.reversed .map( (timeline) => room.events.firstWhereOrNull( (event) => event.rowId == timeline.eventRowId, diff --git a/lib/widgets/chat_page/event_text.dart b/lib/widgets/chat_page/event_text.dart index bc9e984..924dd79 100644 --- a/lib/widgets/chat_page/event_text.dart +++ b/lib/widgets/chat_page/event_text.dart @@ -1,3 +1,4 @@ +import "dart:convert"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:nexus/models/event.dart"; @@ -19,6 +20,6 @@ class EventText extends StatelessWidget { @override Widget build(BuildContext context) { - return Text(event.eventId); // NEXT TODO + return Text(json.encode(event.toJson())); // NEXT TODO } } diff --git a/lib/widgets/chat_page/lazy_loading/message_displayname.dart b/lib/widgets/chat_page/lazy_loading/message_displayname.dart index c3bb8e4..9c6abd1 100644 --- a/lib/widgets/chat_page/lazy_loading/message_displayname.dart +++ b/lib/widgets/chat_page/lazy_loading/message_displayname.dart @@ -2,6 +2,7 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/author_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/models/event.dart"; @@ -29,7 +30,7 @@ class MessageDisplayname extends ConsumerWidget { ) : null, child: Text( - "${membership.displayName}${event.pmp == null ? "" : " (via ${event.sender})"}", + "${membership.displayName ?? event.sender.localpart}${event.pmp == null ? "" : " (via ${event.sender})"}", style: style, overflow: TextOverflow.ellipsis, ), diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 443ac68..dbd6f44 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -45,8 +45,11 @@ class RoomChat extends HookConsumerWidget { final memberListOpened = useState(showMembersByDefault); final listController = useRef(ListController()); - final scrollController = useScrollController(); - // TODO: Do things on scroll to top or bottom + final scrollController = useScrollController( + onAttach: (position) => position.addListener(() { + // TODO: Do things on scroll to top or bottom + }), + ); final userId = ref.watch(ClientStateController.provider)?.userId; final roomId = ref.watch( @@ -308,8 +311,12 @@ class RoomChat extends HookConsumerWidget { child: switch (ref.watch(controllerProvider)) { AsyncData(:final value) || AsyncLoading(:final value?) => CustomScrollView( + reverse: true, controller: scrollController, slivers: [ + SliverPadding( + padding: EdgeInsetsGeometry.only(bottom: 64), + ), SuperSliverList.builder( listController: listController.value, itemCount: value.length, @@ -334,9 +341,6 @@ class RoomChat extends HookConsumerWidget { isFlashing: false, ), ), - SliverPadding( - padding: EdgeInsetsGeometry.only(bottom: 64), - ), ], ), AsyncLoading() => Loading(), -- 2.53.0 From 5d1db60a9fb054990f1a945f08bcbda02a918b8a Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 18 May 2026 10:54:58 -0400 Subject: [PATCH 035/108] remove now unused method on room chat controller --- lib/controllers/room_chat_controller.dart | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 82d5da4..10273a0 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -161,22 +161,6 @@ class RoomChatController extends AsyncNotifier> { // state = state TODO } - Future scrollToEvent(Event event) async { - // TODO: Impl - // final controller = await future; - // Future setFlashing(bool flashing) => controller.updateMessage( - // message, - // message.copyWith( - // metadata: {...(message.metadata ?? {}), "flashing": flashing}, - // ), - // ); - - // await setFlashing(true); - // Timer(Duration(seconds: 1), () => setFlashing(false)); - - // return await controller.scrollToMessage(message.id); - } - Future removeReaction( String reaction, Event event, -- 2.53.0 From 22f9e61c7cdaf0a6017af5eae163b8f44a3d23ec Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 18 May 2026 12:57:30 -0400 Subject: [PATCH 036/108] fix power level logic --- lib/controllers/power_level_controller.dart | 11 +-- lib/models/content/membership.dart | 1 + lib/models/content/power_levels.dart | 1 - .../chat_page/composer/relation_preview.dart | 8 +- lib/widgets/chat_page/event_text.dart | 85 ++++++++++++++++++- .../chat_page/expandable_image_message.dart | 48 ----------- lib/widgets/chat_page/room_chat.dart | 39 +++++---- .../chat_page/wrappers/event_wrapper.dart | 46 ++++++++++ .../chat_page/wrappers/message_wrapper.dart | 83 ------------------ 9 files changed, 164 insertions(+), 158 deletions(-) delete mode 100644 lib/widgets/chat_page/expandable_image_message.dart create mode 100644 lib/widgets/chat_page/wrappers/event_wrapper.dart delete mode 100644 lib/widgets/chat_page/wrappers/message_wrapper.dart diff --git a/lib/controllers/power_level_controller.dart b/lib/controllers/power_level_controller.dart index 93e4ba3..cd7075c 100644 --- a/lib/controllers/power_level_controller.dart +++ b/lib/controllers/power_level_controller.dart @@ -24,10 +24,9 @@ class PowerLevelController extends Notifier { final event = room?.events.firstWhereOrNull( (event) => event.rowId == room.state[EventType.powerLevels.type]?[""], ); + final content = event?.content ?? PowerLevelsContent(); final user = ref.watch(ClientStateController.provider)?.userId; - if (user == null || event?.content is! PowerLevelsContent) return false; - - final content = event?.content as PowerLevelsContent; + if (user == null || content is! PowerLevelsContent) return false; int powerLevelOf(String userId) => content.users[userId] ?? content.usersDefault; @@ -36,7 +35,8 @@ class PowerLevelController extends Notifier { return switch (config) { EventPowerLevelConfig(:final eventType) => - userLevel > (content.events[eventType.type] ?? content.eventsDefault), + userLevel >= (content.events[eventType.type] ?? content.eventsDefault), + MembershipActionPowerLevelConfig(:final action, :final targetUser) => switch (action) { MembershipAction.invite => userLevel >= content.invite, @@ -51,7 +51,8 @@ class PowerLevelController extends Notifier { }, StatePowerLevelConfig(:final eventType) => - userLevel > (content.events[eventType.type] ?? content.stateDefault), + userLevel >= (content.events[eventType.type] ?? content.stateDefault), + RedactionPowerLevelConfig(:final targetUser) => userLevel >= (targetUser == user diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart index aa5a36d..7e00811 100644 --- a/lib/models/content/membership.dart +++ b/lib/models/content/membership.dart @@ -7,6 +7,7 @@ part "membership.g.dart"; @freezed abstract class MembershipContent extends Content with _$MembershipContent { MembershipContent._(); + factory MembershipContent({ @JsonKey(name: "displayname") required String? displayName, @JsonKey(name: "membership") required MembershipStatus status, diff --git a/lib/models/content/power_levels.dart b/lib/models/content/power_levels.dart index 594dac0..bff41b0 100644 --- a/lib/models/content/power_levels.dart +++ b/lib/models/content/power_levels.dart @@ -1,7 +1,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/content/content.dart"; - part "power_levels.freezed.dart"; part "power_levels.g.dart"; diff --git a/lib/widgets/chat_page/composer/relation_preview.dart b/lib/widgets/chat_page/composer/relation_preview.dart index e8dabb1..f795d6d 100644 --- a/lib/widgets/chat_page/composer/relation_preview.dart +++ b/lib/widgets/chat_page/composer/relation_preview.dart @@ -54,7 +54,13 @@ class RelationPreview extends ConsumerWidget { ), ), Expanded( - child: EventText(relatedEvent!, textOnly: true, maxLines: 1), + child: IgnorePointer( + child: EventText( + relatedEvent!, + textOnly: true, + maxLines: 1, + ), + ), ), ], ), diff --git a/lib/widgets/chat_page/event_text.dart b/lib/widgets/chat_page/event_text.dart index 924dd79..e35b06f 100644 --- a/lib/widgets/chat_page/event_text.dart +++ b/lib/widgets/chat_page/event_text.dart @@ -1,11 +1,16 @@ -import "dart:convert"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; +import "package:nexus/models/content/avatar.dart"; +import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; +import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; +import "package:timeago/timeago.dart"; class EventText extends StatelessWidget { final Event event; final bool textOnly; + final bool isGrouped; final int? maxLines; final VoidCallback? onTapReply; final IList Function(Event event)? getEventOptions; @@ -13,6 +18,7 @@ class EventText extends StatelessWidget { this.event, { this.onTapReply, this.textOnly = false, + this.isGrouped = false, this.maxLines, this.getEventOptions, super.key, @@ -20,6 +26,81 @@ class EventText extends StatelessWidget { @override Widget build(BuildContext context) { - return Text(json.encode(event.toJson())); // NEXT TODO + final theme = Theme.of(context); + final timestamp = Tooltip( + message: event.timestamp.toString(), + child: Text( + format(event.timestamp), + style: theme.textTheme.labelSmall?.copyWith(color: Colors.grey), + ), + ); + + return switch (event.content) { + MessageContent() => Row( + spacing: 8, + children: [ + isGrouped ? SizedBox(width: 40) : MessageAvatar(event, height: 40), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isGrouped) + Row( + spacing: 4, + children: [ + MessageDisplayname( + event, + style: TextStyle(fontWeight: FontWeight.bold), + ), + Expanded(child: timestamp), + ], + ), + Text("data"), + ], + ), + ), + ], + ), + AvatarContent() => Row( + spacing: 4, + children: [ + SizedBox(width: 4), + Icon(Icons.numbers), + MessageDisplayname( + event, + style: TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + Text("changed the room avatar"), + ], + ), + _ => Text("AAAAA"), + }; } } + +// ExpandableImage( +// url, +// child: ClipRRect( +// borderRadius: BorderRadius.all(Radius.circular(8)), +// child: Image( +// image: CachedNetworkImage( +// url, +// ref.watch(CrossCacheController.provider), +// headers: ref.headers, +// ), +// width: width, +// height: height, +// loadingBuilder: (context, child, loadingProgress) => +// blurHash == null ? Loading() : BlurHash(hash: blurHash!), +// errorBuilder: (context, error, stackTrace) => Center( +// child: Text( +// "Image Failed to Load", +// style: TextStyle(color: Theme.of(context).colorScheme.error), +// ), +// ), +// ), +// ), +// ) diff --git a/lib/widgets/chat_page/expandable_image_message.dart b/lib/widgets/chat_page/expandable_image_message.dart deleted file mode 100644 index 5bc2c20..0000000 --- a/lib/widgets/chat_page/expandable_image_message.dart +++ /dev/null @@ -1,48 +0,0 @@ -import "package:cross_cache/cross_cache.dart"; -import "package:flutter/material.dart"; -import "package:flutter_blurhash/flutter_blurhash.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/widgets/chat_page/expandable_image.dart"; -import "package:nexus/widgets/loading.dart"; - -class ExpandableImageMessage extends ConsumerWidget { - final String url; - final double? width; - final double? height; - final String? blurHash; - - const ExpandableImageMessage( - this.url, { - this.width, - this.height, - this.blurHash, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) => ExpandableImage( - url, - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(8)), - child: Image( - image: CachedNetworkImage( - url, - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - width: width, - height: height, - loadingBuilder: (context, child, loadingProgress) => - blurHash == null ? Loading() : BlurHash(hash: blurHash!), - errorBuilder: (context, error, stackTrace) => Center( - child: Text( - "Image Failed to Load", - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ), - ), - ), - ); -} diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index dbd6f44..41a5d08 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -21,7 +21,7 @@ import "package:nexus/widgets/chat_page/emoji_picker_button.dart"; import "package:nexus/widgets/chat_page/event_text.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/wrappers/message_wrapper.dart"; +import "package:nexus/widgets/chat_page/wrappers/event_wrapper.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/main.dart"; @@ -320,25 +320,28 @@ class RoomChat extends HookConsumerWidget { SuperSliverList.builder( listController: listController.value, itemCount: value.length, - itemBuilder: (_, index) => MessageWrapper( - value[index], - EventText( + itemBuilder: (_, index) => Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: EventWrapper( value[index], - onTapReply: () => - listController.value.animateToItem( - index: index, - scrollController: scrollController, - alignment: 0.5, - duration: (_) => - Duration(milliseconds: 250), - curve: (_) => Curves.easeInOut, - ), - getEventOptions: getEventOptions, + EventText( + value[index], + onTapReply: () => + listController.value.animateToItem( + index: index, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => + Duration(milliseconds: 250), + curve: (_) => Curves.easeInOut, + ), + getEventOptions: getEventOptions, + // TODO: Reimplement grouping + isGrouped: false, + ), + // TODO: Reimplement flashing + isFlashing: false, ), - // TODO: Reimplement grouping - isGrouped: false, - // TODO: Reimplement flashing - isFlashing: false, ), ), ], diff --git a/lib/widgets/chat_page/wrappers/event_wrapper.dart b/lib/widgets/chat_page/wrappers/event_wrapper.dart new file mode 100644 index 0000000..fb765da --- /dev/null +++ b/lib/widgets/chat_page/wrappers/event_wrapper.dart @@ -0,0 +1,46 @@ +import "package:flutter/material.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/chat_page/wrappers/reaction_row.dart"; + +class EventWrapper extends StatelessWidget { + final Event event; + final Widget child; + final bool isFlashing; + const EventWrapper( + this.event, + this.child, { + this.isFlashing = false, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(12)), + child: AnimatedContainer( + padding: isFlashing ? EdgeInsets.all(8) : EdgeInsets.all(0), + color: isFlashing + ? Theme.of(context).colorScheme.onSurface.withAlpha(50) + : Colors.transparent, + duration: Duration(milliseconds: 250), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + child, + if (event.sendError != null && event.sendError != "not sent") + Text( + event.sendError!, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ReactionRow(event), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/chat_page/wrappers/message_wrapper.dart b/lib/widgets/chat_page/wrappers/message_wrapper.dart deleted file mode 100644 index 620d859..0000000 --- a/lib/widgets/chat_page/wrappers/message_wrapper.dart +++ /dev/null @@ -1,83 +0,0 @@ -import "package:flutter/material.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; -import "package:nexus/widgets/chat_page/wrappers/reaction_row.dart"; -import "package:timeago/timeago.dart"; - -class MessageWrapper extends StatelessWidget { - final Event event; - final Widget child; - final bool isGrouped; - final bool isFlashing; - const MessageWrapper( - this.event, - this.child, { - this.isGrouped = false, - this.isFlashing = false, - super.key, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), - child: AnimatedContainer( - padding: isFlashing ? EdgeInsets.all(8) : EdgeInsets.all(0), - color: isFlashing - ? Theme.of(context).colorScheme.onSurface.withAlpha(50) - : Colors.transparent, - duration: Duration(milliseconds: 250), - child: Row( - spacing: 8, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - isGrouped ? SizedBox(width: 40) : MessageAvatar(event, height: 40), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, - children: [ - if (!isGrouped) - Row( - spacing: 4, - children: [ - Flexible( - child: MessageDisplayname( - event, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - Tooltip( - message: event.timestamp.toString(), - child: Text( - format(event.timestamp), - style: theme.textTheme.labelSmall?.copyWith( - color: Colors.grey, - ), - ), - ), - ], - ), - child, - if (event.sendError != null && event.sendError != "not sent") - Text( - event.sendError!, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ReactionRow(event), - ], - ), - ), - ], - ), - ), - ); - } -} -- 2.53.0 From fd46dbda697df3b771e33aea0d725a7f037ed878 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 18 May 2026 13:02:20 -0400 Subject: [PATCH 037/108] fix defaults if power level event malformed --- lib/controllers/power_level_controller.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/controllers/power_level_controller.dart b/lib/controllers/power_level_controller.dart index cd7075c..ea8a05e 100644 --- a/lib/controllers/power_level_controller.dart +++ b/lib/controllers/power_level_controller.dart @@ -24,7 +24,9 @@ class PowerLevelController extends Notifier { final event = room?.events.firstWhereOrNull( (event) => event.rowId == room.state[EventType.powerLevels.type]?[""], ); - final content = event?.content ?? PowerLevelsContent(); + final content = event?.content is PowerLevelsContent + ? event!.content + : PowerLevelsContent(); final user = ref.watch(ClientStateController.provider)?.userId; if (user == null || content is! PowerLevelsContent) return false; -- 2.53.0 From cb20cb38fd45937407ca4ded544e9d259710f663 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 18 May 2026 14:20:35 -0400 Subject: [PATCH 038/108] text message rendering --- lib/controllers/power_level_controller.dart | 4 +- lib/models/content/membership.dart | 1 - lib/models/content/power_levels.dart | 1 - lib/widgets/chat_page/event_text.dart | 111 +++++++++++++++++++- lib/widgets/link_preview.dart | 61 +++++++++++ 5 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 lib/widgets/link_preview.dart diff --git a/lib/controllers/power_level_controller.dart b/lib/controllers/power_level_controller.dart index ea8a05e..d751378 100644 --- a/lib/controllers/power_level_controller.dart +++ b/lib/controllers/power_level_controller.dart @@ -27,7 +27,9 @@ class PowerLevelController extends Notifier { final content = event?.content is PowerLevelsContent ? event!.content : PowerLevelsContent(); - final user = ref.watch(ClientStateController.provider)?.userId; + final user = ref.watch( + ClientStateController.provider.select((value) => value?.userId), + ); if (user == null || content is! PowerLevelsContent) return false; int powerLevelOf(String userId) => diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart index 7e00811..aa5a36d 100644 --- a/lib/models/content/membership.dart +++ b/lib/models/content/membership.dart @@ -7,7 +7,6 @@ part "membership.g.dart"; @freezed abstract class MembershipContent extends Content with _$MembershipContent { MembershipContent._(); - factory MembershipContent({ @JsonKey(name: "displayname") required String? displayName, @JsonKey(name: "membership") required MembershipStatus status, diff --git a/lib/models/content/power_levels.dart b/lib/models/content/power_levels.dart index bff41b0..3709c38 100644 --- a/lib/models/content/power_levels.dart +++ b/lib/models/content/power_levels.dart @@ -7,7 +7,6 @@ part "power_levels.g.dart"; @freezed abstract class PowerLevelsContent extends Content with _$PowerLevelsContent { PowerLevelsContent._(); - factory PowerLevelsContent({ @Default(IMap.empty()) IMap events, @Default(IMap.empty()) IMap users, diff --git a/lib/widgets/chat_page/event_text.dart b/lib/widgets/chat_page/event_text.dart index e35b06f..d4624be 100644 --- a/lib/widgets/chat_page/event_text.dart +++ b/lib/widgets/chat_page/event_text.dart @@ -1,13 +1,26 @@ +import "package:cross_cache/cross_cache.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_state_controller.dart"; +import "package:nexus/controllers/cross_cache_controller.dart"; +import "package:nexus/controllers/url_preview_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/models/content/avatar.dart"; +import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/widgets/chat_page/html/html.dart"; +import "package:nexus/widgets/chat_page/html/quoted.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/link_preview.dart"; import "package:timeago/timeago.dart"; +import "package:flutter_linkify/flutter_linkify.dart"; -class EventText extends StatelessWidget { +class EventText extends ConsumerWidget { final Event event; final bool textOnly; final bool isGrouped; @@ -25,8 +38,10 @@ class EventText extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final timestamp = Tooltip( message: event.timestamp.toString(), child: Text( @@ -55,7 +70,97 @@ class EventText extends StatelessWidget { Expanded(child: timestamp), ], ), - Text("data"), + ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: Container( + padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: BoxDecoration( + color: + ref.watch( + ClientStateController.provider.select( + (value) => value?.userId, + ), + ) == + event.sender + ? (event.eventId.startsWith("~") + ? colorScheme.onPrimary + : colorScheme.primaryContainer) + : colorScheme.surfaceContainer, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Quoted( // TODO: Show replies + // EventText(replyEvent textOnly: true, maxLines: 1,) + // ), + switch (event.content) { + Content(:final parseError?) => SelectableText( + "An error occurred while parsing this message:\n$parseError", + style: TextStyle(color: colorScheme.error), + ), + TextMessageContent( + :final body, + :final formattedBody, + :final format, + ) => + Column( + children: [ + format == "org.matrix.custom.html" + ? Html( + textStyle: + event.localContent?.bigEmoji == true + ? TextStyle(fontSize: 32) + : null, + formattedBody!.replaceAllMapped( + RegExp( + "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", + caseSensitive: false, + dotAll: true, + ), + (m) { + // If it's already an tag, leave it unchanged + if (m.group(1) != null) { + return m.group(1)!; + } + + // Otherwise, wrap the bare URL + final url = m.group(2)!; + return "$url"; + }, + ), + ) + : Linkify( + text: body, + options: LinkifyOptions( + humanize: false, + ), + onOpen: (link) => ref + .watch(LaunchHelper.provider) + .launchUrl(Uri.parse(link.url)), + linkStyle: TextStyle( + color: Theme.of( + context, + ).colorScheme.primary, + ), + ), + if (event.lastEditRowId != null) + Text( + "(edited)", + style: theme.textTheme.labelSmall, + ), + if (RegExp( + r'''https?://[^\s"'<>]+''', + ).allMatches(body).firstOrNull?.group(0) + case final link?) + LinkPreview(link), + ], + ), + _ => SizedBox.shrink(), + }, + ], + ), + ), + ), ], ), ), diff --git a/lib/widgets/link_preview.dart b/lib/widgets/link_preview.dart new file mode 100644 index 0000000..1186097 --- /dev/null +++ b/lib/widgets/link_preview.dart @@ -0,0 +1,61 @@ +import "package:cross_cache/cross_cache.dart"; +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/cross_cache_controller.dart"; +import "package:nexus/controllers/url_preview_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/helpers/launch_helper.dart"; + +class LinkPreview extends ConsumerWidget { + final String link; + const LinkPreview(this.link, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) => ref + .watch(UrlPreviewController.provider(link)) + .betterWhen( + data: (preview) => preview == null + ? SizedBox.shrink() + : InkWell( + onTap: () => + ref.watch(LaunchHelper.provider).launchUrl(Uri.parse(link)), + child: Card( + child: Column( + children: [ + if (preview.title != null) + Text( + preview.title!, + style: Theme.of(context).textTheme.labelLarge, + ), + if (preview.description != null) + Text(preview.description!), + if (preview.imageUrl != null) + Image( + errorBuilder: (_, _, _) => SizedBox.shrink(), + width: preview.width, + height: preview.height, + image: CachedNetworkImage( + preview.imageUrl!, + ref.watch(CrossCacheController.provider), + headers: ref.headers, + ), + fit: BoxFit.cover, + ), + ], + ), + // text: link, + // backgroundColor: isSentByMe + // ? colorScheme.inversePrimary + // : colorScheme.surfaceContainerLow, + // outsidePadding: EdgeInsets.only(top: 4), + // insidePadding: EdgeInsets.symmetric( + // vertical: 8, + // horizontal: 16, + // ), + // linkPreviewData: preview, + // onLinkPreviewDataFetched: (_) => null, + ), + ), + ); +} -- 2.53.0 From 061c280387a2368f290e1959e0a531457b198a87 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 18 May 2026 14:21:36 -0400 Subject: [PATCH 039/108] make timestamps flexible not expanded --- lib/widgets/chat_page/event_text.dart | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/widgets/chat_page/event_text.dart b/lib/widgets/chat_page/event_text.dart index d4624be..cd957f6 100644 --- a/lib/widgets/chat_page/event_text.dart +++ b/lib/widgets/chat_page/event_text.dart @@ -1,19 +1,13 @@ -import "package:cross_cache/cross_cache.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/controllers/url_preview_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/chat_page/html/html.dart"; -import "package:nexus/widgets/chat_page/html/quoted.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/link_preview.dart"; @@ -67,7 +61,7 @@ class EventText extends ConsumerWidget { event, style: TextStyle(fontWeight: FontWeight.bold), ), - Expanded(child: timestamp), + Flexible(child: timestamp), ], ), ClipRRect( -- 2.53.0 From c9b5b3dda8f77af9dd00e3e3a2a1263a333532b1 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 18 May 2026 14:30:44 -0400 Subject: [PATCH 040/108] various fixes --- lib/controllers/user_controller.dart | 4 +- lib/widgets/chat_page/event_text.dart | 281 ++++++++++++++------------ lib/widgets/chat_page/room_chat.dart | 73 +++---- 3 files changed, 195 insertions(+), 163 deletions(-) diff --git a/lib/controllers/user_controller.dart b/lib/controllers/user_controller.dart index 4f0993b..e7f5fe0 100644 --- a/lib/controllers/user_controller.dart +++ b/lib/controllers/user_controller.dart @@ -21,8 +21,8 @@ class UserController extends AsyncNotifier { ), ); - if (member?.content is MembershipContent) { - return member!.content as MembershipContent; + if (member?.content case final MembershipContent content) { + return content; } final profile = await ref.watch(ProfileController.provider(userId).future); diff --git a/lib/widgets/chat_page/event_text.dart b/lib/widgets/chat_page/event_text.dart index cd957f6..d274dac 100644 --- a/lib/widgets/chat_page/event_text.dart +++ b/lib/widgets/chat_page/event_text.dart @@ -2,6 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state_controller.dart"; +import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/content.dart"; @@ -35,6 +36,7 @@ class EventText extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final errorStyle = TextStyle(color: colorScheme.error); final timestamp = Tooltip( message: event.timestamp.toString(), @@ -43,140 +45,165 @@ class EventText extends ConsumerWidget { style: theme.textTheme.labelSmall?.copyWith(color: Colors.grey), ), ); + final contextMenuCallback = getEventOptions == null + ? null + : (details) => context.showContextMenu( + globalPosition: details.globalPosition, + children: getEventOptions!(event).toList(), + ); - return switch (event.content) { - MessageContent() => Row( - spacing: 8, - children: [ - isGrouped ? SizedBox(width: 40) : MessageAvatar(event, height: 40), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isGrouped) - Row( - spacing: 4, - children: [ - MessageDisplayname( - event, - style: TextStyle(fontWeight: FontWeight.bold), - ), - Flexible(child: timestamp), - ], - ), - ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(8)), - child: Container( - padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), - decoration: BoxDecoration( - color: - ref.watch( - ClientStateController.provider.select( - (value) => value?.userId, - ), - ) == - event.sender - ? (event.eventId.startsWith("~") - ? colorScheme.onPrimary - : colorScheme.primaryContainer) - : colorScheme.surfaceContainer, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return GestureDetector( + onSecondaryTapUp: contextMenuCallback, + onLongPressStart: contextMenuCallback, + child: switch (event.content) { + MessageContent() => Row( + spacing: 8, + children: [ + isGrouped ? SizedBox(width: 40) : MessageAvatar(event, height: 40), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isGrouped) + Row( + spacing: 4, children: [ - // Quoted( // TODO: Show replies - // EventText(replyEvent textOnly: true, maxLines: 1,) - // ), - switch (event.content) { - Content(:final parseError?) => SelectableText( - "An error occurred while parsing this message:\n$parseError", - style: TextStyle(color: colorScheme.error), - ), - TextMessageContent( - :final body, - :final formattedBody, - :final format, - ) => - Column( - children: [ - format == "org.matrix.custom.html" - ? Html( - textStyle: - event.localContent?.bigEmoji == true - ? TextStyle(fontSize: 32) - : null, - formattedBody!.replaceAllMapped( - RegExp( - "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", - caseSensitive: false, - dotAll: true, - ), - (m) { - // If it's already an tag, leave it unchanged - if (m.group(1) != null) { - return m.group(1)!; - } - - // Otherwise, wrap the bare URL - final url = m.group(2)!; - return "$url"; - }, - ), - ) - : Linkify( - text: body, - options: LinkifyOptions( - humanize: false, - ), - onOpen: (link) => ref - .watch(LaunchHelper.provider) - .launchUrl(Uri.parse(link.url)), - linkStyle: TextStyle( - color: Theme.of( - context, - ).colorScheme.primary, - ), - ), - if (event.lastEditRowId != null) - Text( - "(edited)", - style: theme.textTheme.labelSmall, - ), - if (RegExp( - r'''https?://[^\s"'<>]+''', - ).allMatches(body).firstOrNull?.group(0) - case final link?) - LinkPreview(link), - ], - ), - _ => SizedBox.shrink(), - }, + MessageDisplayname( + event, + style: TextStyle(fontWeight: FontWeight.bold), + ), + Flexible(child: timestamp), ], ), + ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: Container( + padding: EdgeInsets.symmetric( + vertical: 8, + horizontal: 12, + ), + decoration: BoxDecoration( + color: + ref.watch( + ClientStateController.provider.select( + (value) => value?.userId, + ), + ) == + event.sender + ? (event.eventId.startsWith("~") + ? colorScheme.onPrimary + : colorScheme.primaryContainer) + : colorScheme.surfaceContainer, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Quoted( // TODO: Show replies + // EventText(replyEvent textOnly: true, maxLines: 1,) + // ), + switch (event.content) { + Content(:final parseError?) => SelectableText( + "An error occurred while parsing this message:\n$parseError", + style: errorStyle, + ), + TextMessageContent( + :final body, + :final formattedBody, + :final format, + ) => + Column( + children: [ + format == "org.matrix.custom.html" && + !textOnly + ? Html( + textStyle: + event.localContent?.bigEmoji == + true + ? TextStyle(fontSize: 32) + : null, + formattedBody!.replaceAllMapped( + RegExp( + "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", + caseSensitive: false, + dotAll: true, + ), + (m) { + // If it's already an tag, leave it unchanged + if (m.group(1) != null) { + return m.group(1)!; + } + + // Otherwise, wrap the bare URL + final url = m.group(2)!; + return "$url"; + }, + ), + ) + : Linkify( + text: body, + maxLines: maxLines, + options: LinkifyOptions( + humanize: false, + ), + onOpen: (link) => ref + .watch(LaunchHelper.provider) + .launchUrl(Uri.parse(link.url)), + linkStyle: TextStyle( + color: Theme.of( + context, + ).colorScheme.primary, + ), + ), + if (event.lastEditRowId != null) + Text( + "(edited)", + style: theme.textTheme.labelSmall, + ), + if (RegExp( + r'''https?://[^\s"'<>]+''', + ).allMatches(body).firstOrNull?.group(0) + case final link?) + LinkPreview(link), + ], + ), + _ => + textOnly + ? Text( + "Unknown message type", + style: errorStyle, + ) + : SizedBox.shrink(), + }, + ], + ), + ), ), - ), - ], + ], + ), ), - ), - ], - ), - AvatarContent() => Row( - spacing: 4, - children: [ - SizedBox(width: 4), - Icon(Icons.numbers), - MessageDisplayname( - event, - style: TextStyle( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, + ], + ), + AvatarContent() => Row( + spacing: 4, + children: [ + SizedBox(width: 4), + Icon(Icons.numbers), + MessageDisplayname( + event, + style: TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), ), - ), - Text("changed the room avatar"), - ], - ), - _ => Text("AAAAA"), - }; + Text("changed the room avatar"), + ], + ), + _ => + textOnly + ? Text("Unknown event type", style: errorStyle) + : SizedBox.shrink(), + }, + ); } } diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 41a5d08..7ca5efc 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -99,42 +99,47 @@ class RoomChat extends HookConsumerWidget { ), )) PopupMenuItem( - child: Row( - children: [ - ...{ - ...ref.watch( - AccountDataController.provider.select( - (value) => IList( - value["m.recent_emoji"]?.content["recent_emoji"] ?? - [], - ).map((entry) => entry["emoji"]), + enabled: false, + child: IconTheme( + data: theme.iconTheme, + child: Row( + children: [ + ...{ + ...ref.watch( + AccountDataController.provider.select( + (value) => IList( + value["m.recent_emoji"] + ?.content["recent_emoji"] ?? + [], + ).map((entry) => entry["emoji"]), + ), + ), + "👍", + "🤣", + "😭", + "🤔", + } + .toIList() + .sublist(0, 4) + .map( + (emoji) => IconButton( + onPressed: () async { + Navigator.of(context).pop(); + await notifier + .sendReaction(emoji, event) + .onError(showError); + }, + icon: Text(emoji), ), ), - "👍", - "🤣", - "😭", - "🤔", - } - .toIList() - .sublist(0, 4) - .map( - (emoji) => IconButton( - onPressed: () async { - Navigator.of(context).pop(); - await notifier - .sendReaction(emoji, event) - .onError(showError); - }, - icon: Text(emoji), - ), - ), - EmojiPickerButton( - context: context, - onPressed: Navigator.of(context).pop, - onSelection: (emoji) => - notifier.sendReaction(emoji, event).onError(showError), - ), - ], + EmojiPickerButton( + context: context, + onPressed: Navigator.of(context).pop, + onSelection: (emoji) => + notifier.sendReaction(emoji, event).onError(showError), + ), + ], + ), ), ), if (ref.watch( -- 2.53.0 From 2aae141c2769bf248e856c655e3faadbf3729d73 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 18 May 2026 14:46:22 -0400 Subject: [PATCH 041/108] im drunk on the power of pattern matching this pr will be squash merged so commit messages dont really matter --- lib/controllers/url_preview_controller.dart | 4 +- lib/models/content/message.dart | 8 +- lib/models/info/image.dart | 5 +- lib/widgets/chat_page/event_text.dart | 197 ++++++++++++-------- 4 files changed, 130 insertions(+), 84 deletions(-) diff --git a/lib/controllers/url_preview_controller.dart b/lib/controllers/url_preview_controller.dart index dab3d97..15eac11 100644 --- a/lib/controllers/url_preview_controller.dart +++ b/lib/controllers/url_preview_controller.dart @@ -12,7 +12,9 @@ class UrlPreviewController extends AsyncNotifier { @override Future build() async { - final homeserver = ref.watch(ClientStateController.provider)?.homeserverUrl; + final homeserver = ref.watch( + ClientStateController.provider.select((value) => value?.homeserverUrl), + ); if (homeserver != null && !link.contains("matrix.to")) { { diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index 82b4604..1140809 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -33,7 +33,7 @@ abstract class MessageContent extends Content with _$MessageContent { // EncryptedFile? file String? filename, ImageInfo? info, - String? url, + Uri? url, }) = ImageMessageContent; @FreezedUnionValue("m.file") @@ -44,7 +44,7 @@ abstract class MessageContent extends Content with _$MessageContent { // EncryptedFile? file String? filename, FileInfo? info, - String? url, + Uri? url, }) = FileMessageContent; @FreezedUnionValue("m.audio") @@ -55,7 +55,7 @@ abstract class MessageContent extends Content with _$MessageContent { // EncryptedFile? file String? filename, AudioInfo? info, - String? url, + Uri? url, }) = AudioMessageContent; @FreezedUnionValue("m.video") @@ -66,7 +66,7 @@ abstract class MessageContent extends Content with _$MessageContent { // EncryptedFile? file String? filename, AudioInfo? info, - String? url, + Uri? url, }) = VideoMessageContent; @FreezedUnionValue("m.location") diff --git a/lib/models/info/image.dart b/lib/models/info/image.dart index 9397aa8..9833016 100644 --- a/lib/models/info/image.dart +++ b/lib/models/info/image.dart @@ -6,9 +6,10 @@ part "image.g.dart"; abstract class ImageInfo with _$ImageInfo { /// Information for images, [size] is in bytes. const factory ImageInfo({ - @JsonKey(name: "h") int? height, - @JsonKey(name: "w") int? width, + @JsonKey(name: "h") double? height, + @JsonKey(name: "w") double? width, @JsonKey(name: "mimetype") String? mimeType, + @JsonKey(name: "xyz.amorgan.blurhash") String? blurHash, int? size, }) = _ImageInfo; diff --git a/lib/widgets/chat_page/event_text.dart b/lib/widgets/chat_page/event_text.dart index d274dac..cf2ed80 100644 --- a/lib/widgets/chat_page/event_text.dart +++ b/lib/widgets/chat_page/event_text.dart @@ -1,17 +1,24 @@ +import "package:cross_cache/cross_cache.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; +import "package:flutter_blurhash/flutter_blurhash.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state_controller.dart"; +import "package:nexus/controllers/cross_cache_controller.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/widgets/chat_page/expandable_image.dart"; import "package:nexus/widgets/chat_page/html/html.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/link_preview.dart"; +import "package:nexus/widgets/loading.dart"; import "package:timeago/timeago.dart"; import "package:flutter_linkify/flutter_linkify.dart"; @@ -110,62 +117,122 @@ class EventText extends ConsumerWidget { :final body, :final formattedBody, :final format, - ) => - Column( - children: [ - format == "org.matrix.custom.html" && - !textOnly - ? Html( - textStyle: - event.localContent?.bigEmoji == - true - ? TextStyle(fontSize: 32) - : null, - formattedBody!.replaceAllMapped( - RegExp( - "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", - caseSensitive: false, - dotAll: true, - ), - (m) { - // If it's already an tag, leave it unchanged - if (m.group(1) != null) { - return m.group(1)!; - } + ) || + ImageMessageContent( + :final body, + :final formattedBody, + :final format, + ) => Column( + children: [ + format == "org.matrix.custom.html" && !textOnly + ? Html( + textStyle: + event.localContent?.bigEmoji == true + ? TextStyle(fontSize: 32) + : null, + formattedBody!.replaceAllMapped( + RegExp( + "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", + caseSensitive: false, + dotAll: true, + ), + (m) { + // If it's already an tag, leave it unchanged + if (m.group(1) != null) { + return m.group(1)!; + } - // Otherwise, wrap the bare URL - final url = m.group(2)!; - return "$url"; - }, - ), - ) - : Linkify( - text: body, - maxLines: maxLines, - options: LinkifyOptions( - humanize: false, - ), - onOpen: (link) => ref - .watch(LaunchHelper.provider) - .launchUrl(Uri.parse(link.url)), - linkStyle: TextStyle( - color: Theme.of( - context, - ).colorScheme.primary, - ), + // Otherwise, wrap the bare URL + final url = m.group(2)!; + return "$url"; + }, ), - if (event.lastEditRowId != null) - Text( - "(edited)", - style: theme.textTheme.labelSmall, + ) + : Linkify( + text: body, + maxLines: maxLines, + options: LinkifyOptions( + humanize: false, + ), + onOpen: (link) => ref + .watch(LaunchHelper.provider) + .launchUrl(Uri.parse(link.url)), + linkStyle: TextStyle( + color: Theme.of( + context, + ).colorScheme.primary, + ), + ), + if (event.content case ImageMessageContent( + :final url, + :final info, + )) + switch (url?.mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value!.homeserverUrl!, + ), ), - if (RegExp( - r'''https?://[^\s"'<>]+''', - ).allMatches(body).firstOrNull?.group(0) - case final link?) - LinkPreview(link), - ], - ), + )) { + final url? => ExpandableImage( + url.toString(), + child: ClipRRect( + borderRadius: BorderRadius.all( + Radius.circular(8), + ), + child: Image( + image: CachedNetworkImage( + url.toString(), + ref.watch( + CrossCacheController.provider, + ), + headers: ref.headers, + ), + width: info?.width, + height: info?.height, + loadingBuilder: + ( + context, + child, + loadingProgress, + ) => switch (info?.blurHash) { + final blurHash? => BlurHash( + hash: blurHash, + ), + _ => Loading(), + }, + errorBuilder: + (context, error, stackTrace) => + Center( + child: Text( + "Image Failed to Load", + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.error, + ), + ), + ), + ), + ), + ), + _ => Text( + "Nexus currently cannot handle encrypted media", + style: errorStyle, + ), + }, + if (event.lastEditRowId != null) + Text( + "(edited)", + style: theme.textTheme.labelSmall, + ), + if (RegExp( + r'''https?://[^\s"'<>]+''', + ).allMatches(body).firstOrNull?.group(0) + case final link?) + LinkPreview(link), + ], + ), _ => textOnly ? Text( @@ -206,27 +273,3 @@ class EventText extends ConsumerWidget { ); } } - -// ExpandableImage( -// url, -// child: ClipRRect( -// borderRadius: BorderRadius.all(Radius.circular(8)), -// child: Image( -// image: CachedNetworkImage( -// url, -// ref.watch(CrossCacheController.provider), -// headers: ref.headers, -// ), -// width: width, -// height: height, -// loadingBuilder: (context, child, loadingProgress) => -// blurHash == null ? Loading() : BlurHash(hash: blurHash!), -// errorBuilder: (context, error, stackTrace) => Center( -// child: Text( -// "Image Failed to Load", -// style: TextStyle(color: Theme.of(context).colorScheme.error), -// ), -// ), -// ), -// ), -// ) -- 2.53.0 From 8aae2c29cb2fde290cea6e55455b1d0c3b00f906 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 18 May 2026 14:52:53 -0400 Subject: [PATCH 042/108] Working image rendering --- .../chat_page/composer/relation_preview.dart | 4 +- .../{event_text.dart => render_event.dart} | 43 +++++++++++++------ lib/widgets/chat_page/room_chat.dart | 4 +- 3 files changed, 33 insertions(+), 18 deletions(-) rename lib/widgets/chat_page/{event_text.dart => render_event.dart} (86%) diff --git a/lib/widgets/chat_page/composer/relation_preview.dart b/lib/widgets/chat_page/composer/relation_preview.dart index f795d6d..2df8a3d 100644 --- a/lib/widgets/chat_page/composer/relation_preview.dart +++ b/lib/widgets/chat_page/composer/relation_preview.dart @@ -2,7 +2,7 @@ import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/chat_page/event_text.dart"; +import "package:nexus/widgets/chat_page/render_event.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; @@ -55,7 +55,7 @@ class RelationPreview extends ConsumerWidget { ), Expanded( child: IgnorePointer( - child: EventText( + child: RenderEvent( relatedEvent!, textOnly: true, maxLines: 1, diff --git a/lib/widgets/chat_page/event_text.dart b/lib/widgets/chat_page/render_event.dart similarity index 86% rename from lib/widgets/chat_page/event_text.dart rename to lib/widgets/chat_page/render_event.dart index cf2ed80..957f6d7 100644 --- a/lib/widgets/chat_page/event_text.dart +++ b/lib/widgets/chat_page/render_event.dart @@ -22,14 +22,14 @@ import "package:nexus/widgets/loading.dart"; import "package:timeago/timeago.dart"; import "package:flutter_linkify/flutter_linkify.dart"; -class EventText extends ConsumerWidget { +class RenderEvent extends ConsumerWidget { final Event event; final bool textOnly; final bool isGrouped; final int? maxLines; final VoidCallback? onTapReply; final IList Function(Event event)? getEventOptions; - const EventText( + const RenderEvent( this.event, { this.onTapReply, this.textOnly = false, @@ -64,14 +64,18 @@ class EventText extends ConsumerWidget { onLongPressStart: contextMenuCallback, child: switch (event.content) { MessageContent() => Row( + crossAxisAlignment: CrossAxisAlignment.start, spacing: 8, children: [ - isGrouped ? SizedBox(width: 40) : MessageAvatar(event, height: 40), + isGrouped || textOnly + ? SizedBox(width: 40) + : MessageAvatar(event, height: 40), Expanded( child: Column( + spacing: 4, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (!isGrouped) + if (!isGrouped && !textOnly) Row( spacing: 4, children: [ @@ -123,6 +127,7 @@ class EventText extends ConsumerWidget { :final formattedBody, :final format, ) => Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ format == "org.matrix.custom.html" && !textOnly ? Html( @@ -191,16 +196,26 @@ class EventText extends ConsumerWidget { width: info?.width, height: info?.height, loadingBuilder: - ( - context, - child, - loadingProgress, - ) => switch (info?.blurHash) { - final blurHash? => BlurHash( - hash: blurHash, - ), - _ => Loading(), - }, + (_, child, loadingProgress) => + loadingProgress == null + ? child + : switch (info?.blurHash) { + final blurHash? => + SizedBox( + width: + info?.width ?? + info?.height ?? + 200, + height: + info?.height ?? + info?.width ?? + 200, + child: BlurHash( + hash: blurHash, + ), + ), + _ => Loading(), + }, errorBuilder: (context, error, stackTrace) => Center( diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 7ca5efc..5dfe52d 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -18,7 +18,7 @@ import "package:nexus/models/relation_type.dart"; import "package:nexus/models/requests/report_request.dart"; import "package:nexus/widgets/chat_page/composer/chat_box.dart"; import "package:nexus/widgets/chat_page/emoji_picker_button.dart"; -import "package:nexus/widgets/chat_page/event_text.dart"; +import "package:nexus/widgets/chat_page/render_event.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/wrappers/event_wrapper.dart"; @@ -329,7 +329,7 @@ class RoomChat extends HookConsumerWidget { padding: EdgeInsets.symmetric(vertical: 8), child: EventWrapper( value[index], - EventText( + RenderEvent( value[index], onTapReply: () => listController.value.animateToItem( -- 2.53.0 From fee12cb94d23df67b6a3ad8354cbb2dcf02808cd Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 10:07:15 -0400 Subject: [PATCH 043/108] fix up url embeds --- lib/controllers/url_preview_controller.dart | 8 +- lib/models/open_graph_data.dart | 4 +- lib/widgets/chat_page/html/html.dart | 1 + lib/widgets/chat_page/render_event.dart | 425 ++++++++++---------- lib/widgets/chat_page/room_chat.dart | 37 +- lib/widgets/link_preview.dart | 78 ++-- pubspec.lock | 11 +- pubspec.yaml | 6 + 8 files changed, 294 insertions(+), 276 deletions(-) diff --git a/lib/controllers/url_preview_controller.dart b/lib/controllers/url_preview_controller.dart index 15eac11..ad89b55 100644 --- a/lib/controllers/url_preview_controller.dart +++ b/lib/controllers/url_preview_controller.dart @@ -26,15 +26,15 @@ class UrlPreviewController extends AsyncNotifier { ); if (response.statusCode == 200) { - final decodedValue = json.decode(response.body) as Map?; - if (decodedValue?.isNotEmpty == true) return null; + final decodedValue = json.decode(response.body); + if (decodedValue is! Map) return null; - final mxc = decodedValue!["og:image"]; + final mxc = decodedValue["og:image"]; final image = mxc == null ? null : Uri.tryParse(mxc)?.mxcToHttps(homeserver); - return OpenGraphData.fromJson({...decodedValue, "og:image": image}); + return OpenGraphData.fromJson(decodedValue).copyWith(imageUrl: image); } } } diff --git a/lib/models/open_graph_data.dart b/lib/models/open_graph_data.dart index 4076edd..d7e840d 100644 --- a/lib/models/open_graph_data.dart +++ b/lib/models/open_graph_data.dart @@ -7,11 +7,11 @@ abstract class OpenGraphData with _$OpenGraphData { const factory OpenGraphData({ @JsonKey(name: "og:title") required String? title, @JsonKey(name: "og:description") required String? description, - @JsonKey(name: "og:image") required String? imageUrl, + @JsonKey(name: "og:image") required Uri? imageUrl, @JsonKey(name: "og:image:width") required double? width, @JsonKey(name: "og:image:height") required double? height, }) = _OpenGraphData; - factory OpenGraphData.fromJson(Map json) => + factory OpenGraphData.fromJson(Map json) => _$OpenGraphDataFromJson(json); } diff --git a/lib/widgets/chat_page/html/html.dart b/lib/widgets/chat_page/html/html.dart index fb533ad..2f93264 100644 --- a/lib/widgets/chat_page/html/html.dart +++ b/lib/widgets/chat_page/html/html.dart @@ -23,6 +23,7 @@ class Html extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( html, + buildAsync: false, textStyle: textStyle, customWidgetBuilder: (element) { if (element.attributes.keys.contains("data-mx-profile-fallback")) { diff --git a/lib/widgets/chat_page/render_event.dart b/lib/widgets/chat_page/render_event.dart index 957f6d7..99c9f04 100644 --- a/lib/widgets/chat_page/render_event.dart +++ b/lib/widgets/chat_page/render_event.dart @@ -1,8 +1,10 @@ +import "package:collection/collection.dart"; import "package:cross_cache/cross_cache.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:flutter_blurhash/flutter_blurhash.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:linkify/linkify.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/cross_cache_controller.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; @@ -59,232 +61,239 @@ class RenderEvent extends ConsumerWidget { children: getEventOptions!(event).toList(), ); - return GestureDetector( - onSecondaryTapUp: contextMenuCallback, - onLongPressStart: contextMenuCallback, - child: switch (event.content) { - MessageContent() => Row( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - isGrouped || textOnly - ? SizedBox(width: 40) - : MessageAvatar(event, height: 40), - Expanded( - child: Column( - spacing: 4, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isGrouped && !textOnly) - Row( - spacing: 4, - children: [ - MessageDisplayname( + final child = switch (event.content) { + MessageContent() => Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + if (!textOnly) + if (isGrouped) + SizedBox(width: 40) + else + MessageAvatar(event, height: 40), + Expanded( + child: Column( + spacing: 4, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isGrouped && !textOnly) + Row( + spacing: 4, + children: [ + Flexible( + child: MessageDisplayname( event, style: TextStyle(fontWeight: FontWeight.bold), ), - Flexible(child: timestamp), - ], - ), - ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(8)), - child: Container( - padding: EdgeInsets.symmetric( - vertical: 8, - horizontal: 12, ), - decoration: BoxDecoration( - color: - ref.watch( - ClientStateController.provider.select( - (value) => value?.userId, - ), - ) == - event.sender - ? (event.eventId.startsWith("~") - ? colorScheme.onPrimary - : colorScheme.primaryContainer) - : colorScheme.surfaceContainer, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Quoted( // TODO: Show replies - // EventText(replyEvent textOnly: true, maxLines: 1,) - // ), - switch (event.content) { - Content(:final parseError?) => SelectableText( - "An error occurred while parsing this message:\n$parseError", - style: errorStyle, - ), - TextMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - ImageMessageContent( - :final body, - :final formattedBody, - :final format, - ) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - format == "org.matrix.custom.html" && !textOnly - ? Html( - textStyle: - event.localContent?.bigEmoji == true - ? TextStyle(fontSize: 32) - : null, - formattedBody!.replaceAllMapped( - RegExp( - "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", - caseSensitive: false, - dotAll: true, - ), - (m) { - // If it's already an tag, leave it unchanged - if (m.group(1) != null) { - return m.group(1)!; - } - - // Otherwise, wrap the bare URL - final url = m.group(2)!; - return "$url"; - }, - ), - ) - : Linkify( - text: body, - maxLines: maxLines, - options: LinkifyOptions( - humanize: false, - ), - onOpen: (link) => ref - .watch(LaunchHelper.provider) - .launchUrl(Uri.parse(link.url)), - linkStyle: TextStyle( - color: Theme.of( - context, - ).colorScheme.primary, - ), - ), - if (event.content case ImageMessageContent( - :final url, - :final info, - )) - switch (url?.mxcToHttps( - ref.watch( + Flexible(child: timestamp), + ], + ), + ClipRRect( + borderRadius: textOnly + ? BorderRadius.zero + : BorderRadius.all(Radius.circular(8)), + child: Container( + padding: textOnly + ? EdgeInsets.zero + : EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: textOnly + ? null + : BoxDecoration( + color: + ref.watch( ClientStateController.provider.select( - (value) => value!.homeserverUrl!, + (value) => value?.userId, + ), + ) == + event.sender + ? (event.eventId.startsWith("~") + ? colorScheme.onPrimary + : colorScheme.primaryContainer) + : colorScheme.surfaceContainer, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Quoted( // TODO: Show replies + // EventText(replyEvent textOnly: true, maxLines: 1,) + // ), + switch (event.content) { + Content(:final parseError?) => SelectableText( + "An error occurred while parsing this message:\n$parseError", + style: errorStyle, + ), + TextMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + NoticeMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + ImageMessageContent( + :final body, + :final formattedBody, + :final format, + ) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + format == "org.matrix.custom.html" && !textOnly + ? Html( + textStyle: + event.localContent?.bigEmoji == true + ? TextStyle(fontSize: 32) + : null, + formattedBody!.replaceAllMapped( + RegExp( + "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", + caseSensitive: false, + dotAll: true, + ), + (m) { + // If it's already an tag, leave it unchanged + if (m.group(1) != null) { + return m.group(1)!; + } + + // Otherwise, wrap the bare URL + final url = m.group(2)!; + return "$url"; + }, + ), + ) + : Linkify( + text: body, + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + options: LinkifyOptions(humanize: false), + onOpen: (link) => ref + .watch(LaunchHelper.provider) + .launchUrl(Uri.parse(link.url)), + linkStyle: TextStyle( + color: Theme.of( + context, + ).colorScheme.primary, ), ), - )) { - final url? => ExpandableImage( - url.toString(), - child: ClipRRect( - borderRadius: BorderRadius.all( - Radius.circular(8), - ), - child: Image( - image: CachedNetworkImage( - url.toString(), - ref.watch( - CrossCacheController.provider, - ), - headers: ref.headers, + if (event.content case ImageMessageContent( + :final url, + :final info, + )) + switch (url?.mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value!.homeserverUrl!, + ), + ), + )) { + final url? => ExpandableImage( + url.toString(), + child: ClipRRect( + borderRadius: BorderRadius.all( + Radius.circular(8), + ), + child: Image( + image: CachedNetworkImage( + url.toString(), + ref.watch( + CrossCacheController.provider, ), - width: info?.width, - height: info?.height, - loadingBuilder: - (_, child, loadingProgress) => - loadingProgress == null - ? child - : switch (info?.blurHash) { - final blurHash? => - SizedBox( - width: - info?.width ?? - info?.height ?? - 200, - height: - info?.height ?? - info?.width ?? - 200, - child: BlurHash( - hash: blurHash, - ), - ), - _ => Loading(), - }, - errorBuilder: - (context, error, stackTrace) => - Center( - child: Text( - "Image Failed to Load", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.error, + headers: ref.headers, + ), + width: info?.width, + height: info?.height, + loadingBuilder: + (_, child, loadingProgress) => + loadingProgress == null + ? child + : switch (info?.blurHash) { + final blurHash? => SizedBox( + width: + info?.width ?? + info?.height ?? + 200, + height: + info?.height ?? + info?.width ?? + 200, + child: BlurHash( + hash: blurHash, ), ), + _ => Loading(), + }, + errorBuilder: + (context, error, stackTrace) => + Center( + child: Text( + "Image Failed to Load", + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.error, + ), ), - ), + ), ), ), - _ => Text( - "Nexus currently cannot handle encrypted media", - style: errorStyle, - ), - }, - if (event.lastEditRowId != null) - Text( - "(edited)", - style: theme.textTheme.labelSmall, ), - if (RegExp( - r'''https?://[^\s"'<>]+''', - ).allMatches(body).firstOrNull?.group(0) - case final link?) - LinkPreview(link), - ], - ), - _ => - textOnly - ? Text( - "Unknown message type", - style: errorStyle, - ) - : SizedBox.shrink(), - }, - ], - ), + _ => Text( + "Nexus currently cannot handle encrypted media", + style: errorStyle, + ), + }, + if (event.lastEditRowId != null && !textOnly) + Text( + "(edited)", + style: theme.textTheme.labelSmall, + ), + if (linkify(body).firstWhereOrNull( + (element) => element is UrlElement, + ) + case final UrlElement link?) + LinkPreview(link.url), + ], + ), + _ => Text("Unknown message type", style: errorStyle), + }, + ], ), ), - ], - ), + ), + ], ), - ], - ), - AvatarContent() => Row( - spacing: 4, - children: [ - SizedBox(width: 4), - Icon(Icons.numbers), - MessageDisplayname( - event, - style: TextStyle( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ), + ), + ], + ), + AvatarContent() => Row( + spacing: 4, + children: [ + SizedBox(width: 4), + Icon(Icons.numbers), + MessageDisplayname( + event, + style: TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, ), - Text("changed the room avatar"), - ], - ), - _ => - textOnly - ? Text("Unknown event type", style: errorStyle) - : SizedBox.shrink(), - }, + ), + Text("changed the room avatar"), + ], + ), + _ => null, + }; + + return GestureDetector( + onSecondaryTapUp: contextMenuCallback, + onLongPressStart: contextMenuCallback, + child: child == null + ? textOnly + ? Text("Unknown event type", style: errorStyle) + : SizedBox.shrink() + : Padding(padding: EdgeInsets.symmetric(vertical: 8), child: child), ); } } diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 5dfe52d..a9383f9 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -325,28 +325,25 @@ class RoomChat extends HookConsumerWidget { SuperSliverList.builder( listController: listController.value, itemCount: value.length, - itemBuilder: (_, index) => Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: EventWrapper( + itemBuilder: (_, index) => EventWrapper( + value[index], + RenderEvent( value[index], - RenderEvent( - value[index], - onTapReply: () => - listController.value.animateToItem( - index: index, - scrollController: scrollController, - alignment: 0.5, - duration: (_) => - Duration(milliseconds: 250), - curve: (_) => Curves.easeInOut, - ), - getEventOptions: getEventOptions, - // TODO: Reimplement grouping - isGrouped: false, - ), - // TODO: Reimplement flashing - isFlashing: false, + onTapReply: () => + listController.value.animateToItem( + index: index, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => + Duration(milliseconds: 250), + curve: (_) => Curves.easeInOut, + ), + getEventOptions: getEventOptions, + // TODO: Reimplement grouping + isGrouped: false, ), + // TODO: Reimplement flashing + isFlashing: false, ), ), ], diff --git a/lib/widgets/link_preview.dart b/lib/widgets/link_preview.dart index 1186097..0089762 100644 --- a/lib/widgets/link_preview.dart +++ b/lib/widgets/link_preview.dart @@ -17,44 +17,48 @@ class LinkPreview extends ConsumerWidget { .betterWhen( data: (preview) => preview == null ? SizedBox.shrink() - : InkWell( - onTap: () => - ref.watch(LaunchHelper.provider).launchUrl(Uri.parse(link)), - child: Card( - child: Column( - children: [ - if (preview.title != null) - Text( - preview.title!, - style: Theme.of(context).textTheme.labelLarge, - ), - if (preview.description != null) - Text(preview.description!), - if (preview.imageUrl != null) - Image( - errorBuilder: (_, _, _) => SizedBox.shrink(), - width: preview.width, - height: preview.height, - image: CachedNetworkImage( - preview.imageUrl!, - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - fit: BoxFit.cover, - ), - ], + : ConstrainedBox( + constraints: BoxConstraints.loose(Size.fromWidth(400)), + child: InkWell( + onTap: () => ref + .watch(LaunchHelper.provider) + .launchUrl(Uri.parse(link)), + child: Card( + child: Padding( + padding: EdgeInsetsGeometry.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + if (preview.title != null) + Text( + preview.title!, + style: Theme.of(context).textTheme.titleLarge, + ), + if (preview.description != null) ...[ + Text(preview.description!), + SizedBox(height: 4), + ], + if (preview.imageUrl != null) + ClipRRect( + borderRadius: BorderRadius.all( + Radius.circular(8), + ), + child: Image( + errorBuilder: (_, _, _) => SizedBox.shrink(), + width: preview.width, + image: CachedNetworkImage( + preview.imageUrl.toString(), + ref.watch(CrossCacheController.provider), + headers: ref.headers, + ), + fit: BoxFit.fitWidth, + ), + ), + ], + ), + ), ), - // text: link, - // backgroundColor: isSentByMe - // ? colorScheme.inversePrimary - // : colorScheme.surfaceContainerLow, - // outsidePadding: EdgeInsets.only(top: 4), - // insidePadding: EdgeInsets.symmetric( - // vertical: 8, - // horizontal: 16, - // ), - // linkPreviewData: preview, - // onLinkPreviewDataFetched: (_) => null, ), ), ); diff --git a/pubspec.lock b/pubspec.lock index 9ccfe8d..01a7833 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -720,12 +720,13 @@ packages: source: hosted version: "3.0.2" linkify: - dependency: transitive + dependency: "direct overridden" description: - name: linkify - sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" - url: "https://pub.dev" - source: hosted + path: "." + ref: "fix/consecutive-periods-loose-url" + resolved-ref: e990021f30b8535b462d41a39f37019045ae55f4 + url: "https://github.com/appelladev/linkify" + source: git version: "5.0.0" lints: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index d511fe6..dd7f365 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,12 @@ flutter: environment: sdk: "3.11.4" +dependency_overrides: + linkify: + git: + url: https://github.com/appelladev/linkify + ref: fix/consecutive-periods-loose-url + dependencies: flutter: sdk: flutter -- 2.53.0 From df491b2ed3a55882c1866eca5613d889256c44c5 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 10:40:16 -0400 Subject: [PATCH 044/108] fix decryption --- lib/models/content/content.dart | 21 ++++++++++++++------- lib/models/content/encrypted.dart | 13 +++++++++++++ lib/models/content/reaction.dart | 7 ++++--- lib/models/event.dart | 24 ++++++++++++++---------- lib/widgets/chat_page/render_event.dart | 10 ++++++---- pubspec.lock | 2 +- pubspec.yaml | 5 +++-- 7 files changed, 55 insertions(+), 27 deletions(-) create mode 100644 lib/models/content/encrypted.dart diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index dbe3695..2896049 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -1,3 +1,4 @@ +import "package:collection/collection.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/create.dart"; @@ -9,30 +10,36 @@ import "package:nexus/models/content/name.dart"; import "package:nexus/models/content/pinned_events.dart"; import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/reaction.dart"; +import "package:nexus/models/content/encrypted.dart"; import "package:nexus/models/content/redaction.dart"; import "package:nexus/models/content/server_acl.dart"; import "package:nexus/models/content/topic.dart"; class Content { - final String? parseError; + final Error? parseError; Content({this.parseError}); factory Content.fromJson(Map json) => Content(); Map toJson() => {}; - static Content fromEventJson(Map eventJson, String type) { + static Map readValue(Map json, _) => + json["decrypted"] ?? json["content"]; + + static Content fromEventJson(Map json, String type) { try { - return EventType.values - .firstWhere((eventType) => eventType.type == type) - .contentFromJson(eventJson); + return (EventType.values + .firstWhereOrNull((eventType) => eventType.type == type) + ?.contentFromJson ?? + Content.fromJson)(json); } catch (error) { - return Content(parseError: error.toString()); + if (error is Error) return Content(parseError: error); + rethrow; } } } enum EventType { - encrypted("m.room.encrypted", Content.fromJson), + encrypted("m.room.encrypted", EncryptedContent.fromJson), redaction("m.room.redaction", RedactionContent.fromJson), encryption("m.room.encryption", EncryptionContent.fromJson), membership("m.room.member", MembershipContent.fromJson), diff --git a/lib/models/content/encrypted.dart b/lib/models/content/encrypted.dart new file mode 100644 index 0000000..b33a440 --- /dev/null +++ b/lib/models/content/encrypted.dart @@ -0,0 +1,13 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "encrypted.freezed.dart"; +part "encrypted.g.dart"; + +@freezed +abstract class EncryptedContent extends Content with _$EncryptedContent { + EncryptedContent._(); + factory EncryptedContent() = _EncryptedContent; + + factory EncryptedContent.fromJson(Map json) => + _$EncryptedContentFromJson(json); +} diff --git a/lib/models/content/reaction.dart b/lib/models/content/reaction.dart index 0361df8..7a3d714 100644 --- a/lib/models/content/reaction.dart +++ b/lib/models/content/reaction.dart @@ -3,13 +3,14 @@ import "package:nexus/models/content/content.dart"; part "reaction.freezed.dart"; part "reaction.g.dart"; -String? keyFromJson(Map json) => json["m.relates_to"]?["key"]; +String? keyFromJson(Map json) => json["key"]; @freezed abstract class ReactionContent extends Content with _$ReactionContent { ReactionContent._(); - factory ReactionContent({@JsonKey(fromJson: keyFromJson) String? key}) = - _ReactionContent; + factory ReactionContent({ + @JsonKey(fromJson: keyFromJson, name: "m.relates_to") String? key, + }) = _ReactionContent; factory ReactionContent.fromJson(Map json) => _$ReactionContentFromJson(json); diff --git a/lib/models/event.dart b/lib/models/event.dart index d77911b..3d9c0f6 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -6,24 +6,25 @@ import "package:nexus/models/profile.dart"; part "event.freezed.dart"; part "event.g.dart"; -Profile? pmpFromJson(Map? json) { - final pmp = json?["content"]?["com.beeper.per_message_profile"]; - return pmp == null ? null : Profile.fromJsonWithCatch(pmp); -} - @freezed abstract class Event with _$Event { + static Profile? pmpFromJson(Map? json) { + final pmp = json?["content"]?["com.beeper.per_message_profile"]; + return pmp == null ? null : Profile.fromJsonWithCatch(pmp); + } + + static String typeJsonFromJson(Map json, _) => + json["decrypted_type"] ?? json["type"]; + const factory Event({ @JsonKey(name: "rowid") required int rowId, @JsonKey(name: "timeline_rowid") required int timelineRowId, required String roomId, required String eventId, required String sender, - required String type, + @JsonKey(readValue: Event.typeJsonFromJson) required String type, String? stateKey, @EpochDateTimeConverter() required DateTime timestamp, - IMap? decrypted, - String? decryptedType, @Default(IMap.empty()) IMap unsigned, LocalContent? localContent, String? transactionId, @@ -35,13 +36,16 @@ abstract class Event with _$Event { @Default(IMap.empty()) IMap reactions, @JsonKey(name: "last_edit_rowid") int? lastEditRowId, @UnreadTypeConverter() UnreadType? unreadType, - @JsonKey(fromJson: pmpFromJson) Profile? pmp, + @JsonKey(fromJson: Event.pmpFromJson) Profile? pmp, @JsonKey(fromJson: Content.fromJson) required Content content, }) = _Event; factory Event.fromJson(Map json) => _$EventFromJson(json).copyWith( - content: Content.fromEventJson(json["content"], json["type"] as String), + content: Content.fromEventJson( + json["decrypted"] ?? json["content"], + json["decrypted_type"] ?? json["type"], + ), ); } diff --git a/lib/widgets/chat_page/render_event.dart b/lib/widgets/chat_page/render_event.dart index 99c9f04..5e9c78c 100644 --- a/lib/widgets/chat_page/render_event.dart +++ b/lib/widgets/chat_page/render_event.dart @@ -13,6 +13,7 @@ import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/encrypted.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/chat_page/expandable_image.dart"; @@ -62,6 +63,11 @@ class RenderEvent extends ConsumerWidget { ); final child = switch (event.content) { + Content(:final parseError?) => SelectableText( + "An error occurred while parsing this event:\n$parseError", + style: errorStyle, + ), + EncryptedContent() => Text("Unable to decrypt event"), MessageContent() => Row( crossAxisAlignment: CrossAxisAlignment.start, spacing: 8, @@ -119,10 +125,6 @@ class RenderEvent extends ConsumerWidget { // EventText(replyEvent textOnly: true, maxLines: 1,) // ), switch (event.content) { - Content(:final parseError?) => SelectableText( - "An error occurred while parsing this message:\n$parseError", - style: errorStyle, - ), TextMessageContent( :final body, :final formattedBody, diff --git a/pubspec.lock b/pubspec.lock index 01a7833..19869cc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -720,7 +720,7 @@ packages: source: hosted version: "3.0.2" linkify: - dependency: "direct overridden" + dependency: "direct main" description: path: "." ref: "fix/consecutive-periods-loose-url" diff --git a/pubspec.yaml b/pubspec.yaml index dd7f365..56412f7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,11 +54,12 @@ dependencies: timeago: 3.7.1 http: 1.6.0 flutter_linkify: 6.0.0 + linkify: 5.0.0 emoji_text_field: git: url: https://github.com/Henry-Hiles/emoji_text_field - flutter_blurhash: ^0.9.1 - super_sliver_list: ^0.4.1 + flutter_blurhash: 0.9.1 + super_sliver_list: 0.4.1 dev_dependencies: build_runner: 2.15.0 -- 2.53.0 From 6534e2d46ef4fc882e8af2cf319e93efde2a0803 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 10:42:59 -0400 Subject: [PATCH 045/108] change embed color --- lib/widgets/link_preview.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/widgets/link_preview.dart b/lib/widgets/link_preview.dart index 0089762..aa8e408 100644 --- a/lib/widgets/link_preview.dart +++ b/lib/widgets/link_preview.dart @@ -24,6 +24,9 @@ class LinkPreview extends ConsumerWidget { .watch(LaunchHelper.provider) .launchUrl(Uri.parse(link)), child: Card( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, child: Padding( padding: EdgeInsetsGeometry.all(8), child: Column( -- 2.53.0 From e7ecae4606bd18fec49eb946c8cd864579ffca87 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 10:56:05 -0400 Subject: [PATCH 046/108] don't try to render redacted events --- lib/models/content/reaction.dart | 7 ++++--- lib/widgets/chat_page/render_event.dart | 6 ++++-- lib/widgets/link_preview.dart | 1 + 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/models/content/reaction.dart b/lib/models/content/reaction.dart index 7a3d714..3115ae0 100644 --- a/lib/models/content/reaction.dart +++ b/lib/models/content/reaction.dart @@ -3,13 +3,14 @@ import "package:nexus/models/content/content.dart"; part "reaction.freezed.dart"; part "reaction.g.dart"; -String? keyFromJson(Map json) => json["key"]; - @freezed abstract class ReactionContent extends Content with _$ReactionContent { ReactionContent._(); + static String? keyJsonFromJson(Map json, String key) => + json["m.relates_to"]?["key"]; + factory ReactionContent({ - @JsonKey(fromJson: keyFromJson, name: "m.relates_to") String? key, + @JsonKey(readValue: ReactionContent.keyJsonFromJson) String? key, }) = _ReactionContent; factory ReactionContent.fromJson(Map json) => diff --git a/lib/widgets/chat_page/render_event.dart b/lib/widgets/chat_page/render_event.dart index 5e9c78c..14d5117 100644 --- a/lib/widgets/chat_page/render_event.dart +++ b/lib/widgets/chat_page/render_event.dart @@ -62,12 +62,14 @@ class RenderEvent extends ConsumerWidget { children: getEventOptions!(event).toList(), ); + if (event.redactedBy != null) return SizedBox.shrink(); + final child = switch (event.content) { Content(:final parseError?) => SelectableText( - "An error occurred while parsing this event:\n$parseError", + "An error occurred while parsing this event:\n$parseError\n${parseError.stackTrace}", style: errorStyle, ), - EncryptedContent() => Text("Unable to decrypt event"), + EncryptedContent() => Text("Unable to decrypt event", style: errorStyle), MessageContent() => Row( crossAxisAlignment: CrossAxisAlignment.start, spacing: 8, diff --git a/lib/widgets/link_preview.dart b/lib/widgets/link_preview.dart index aa8e408..843f5ac 100644 --- a/lib/widgets/link_preview.dart +++ b/lib/widgets/link_preview.dart @@ -24,6 +24,7 @@ class LinkPreview extends ConsumerWidget { .watch(LaunchHelper.provider) .launchUrl(Uri.parse(link)), child: Card( + margin: EdgeInsets.symmetric(vertical: 4), color: Theme.of( context, ).colorScheme.surfaceContainerHighest, -- 2.53.0 From b71ebe5fee0eac0d46f0d65f1837281549a6cfb9 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 11:00:59 -0400 Subject: [PATCH 047/108] fixup image rendering, prettier rendering for UTDs --- lib/widgets/chat_page/render_event.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/widgets/chat_page/render_event.dart b/lib/widgets/chat_page/render_event.dart index 14d5117..b8e8b85 100644 --- a/lib/widgets/chat_page/render_event.dart +++ b/lib/widgets/chat_page/render_event.dart @@ -69,8 +69,7 @@ class RenderEvent extends ConsumerWidget { "An error occurred while parsing this event:\n$parseError\n${parseError.stackTrace}", style: errorStyle, ), - EncryptedContent() => Text("Unable to decrypt event", style: errorStyle), - MessageContent() => Row( + MessageContent() || EncryptedContent() => Row( crossAxisAlignment: CrossAxisAlignment.start, spacing: 8, children: [ @@ -127,6 +126,10 @@ class RenderEvent extends ConsumerWidget { // EventText(replyEvent textOnly: true, maxLines: 1,) // ), switch (event.content) { + EncryptedContent() => Text( + "Unable to decrypt event", + style: errorStyle, + ), TextMessageContent( :final body, :final formattedBody, @@ -208,7 +211,6 @@ class RenderEvent extends ConsumerWidget { headers: ref.headers, ), width: info?.width, - height: info?.height, loadingBuilder: (_, child, loadingProgress) => loadingProgress == null -- 2.53.0 From b9e42ec51b973b935715a310df7be8987c1e83c3 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 11:05:40 -0400 Subject: [PATCH 048/108] constrain images to a max size --- lib/widgets/chat_page/render_event.dart | 166 ++++++++++++------------ 1 file changed, 83 insertions(+), 83 deletions(-) diff --git a/lib/widgets/chat_page/render_event.dart b/lib/widgets/chat_page/render_event.dart index b8e8b85..84b95fc 100644 --- a/lib/widgets/chat_page/render_event.dart +++ b/lib/widgets/chat_page/render_event.dart @@ -96,29 +96,21 @@ class RenderEvent extends ConsumerWidget { Flexible(child: timestamp), ], ), - ClipRRect( - borderRadius: textOnly - ? BorderRadius.zero - : BorderRadius.all(Radius.circular(8)), - child: Container( - padding: textOnly - ? EdgeInsets.zero - : EdgeInsets.symmetric(vertical: 8, horizontal: 12), - decoration: textOnly - ? null - : BoxDecoration( - color: - ref.watch( - ClientStateController.provider.select( - (value) => value?.userId, - ), - ) == - event.sender - ? (event.eventId.startsWith("~") - ? colorScheme.onPrimary - : colorScheme.primaryContainer) - : colorScheme.surfaceContainer, - ), + Card( + color: + ref.watch( + ClientStateController.provider.select( + (value) => value?.userId, + ), + ) == + event.sender + ? (event.eventId.startsWith("~") + ? colorScheme.onPrimary + : colorScheme.primaryContainer) + : colorScheme.surfaceContainer, + + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -185,72 +177,80 @@ class RenderEvent extends ConsumerWidget { ).colorScheme.primary, ), ), - if (event.content case ImageMessageContent( - :final url, - :final info, - )) - switch (url?.mxcToHttps( - ref.watch( - ClientStateController.provider.select( - (value) => value!.homeserverUrl!, - ), - ), - )) { - final url? => ExpandableImage( - url.toString(), - child: ClipRRect( - borderRadius: BorderRadius.all( - Radius.circular(8), + + if (!textOnly) + if (event.content case ImageMessageContent( + :final url, + :final info, + )) + switch (url?.mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value!.homeserverUrl!, ), - child: Image( - image: CachedNetworkImage( - url.toString(), - ref.watch( - CrossCacheController.provider, + ), + )) { + final url? => ConstrainedBox( + constraints: BoxConstraints.loose( + Size.fromWidth(500), + ), + child: ExpandableImage( + url.toString(), + child: ClipRRect( + borderRadius: BorderRadius.all( + Radius.circular(8), ), - headers: ref.headers, - ), - width: info?.width, - loadingBuilder: - (_, child, loadingProgress) => - loadingProgress == null - ? child - : switch (info?.blurHash) { - final blurHash? => SizedBox( - width: - info?.width ?? - info?.height ?? - 200, - height: - info?.height ?? - info?.width ?? - 200, - child: BlurHash( - hash: blurHash, + child: Image( + image: CachedNetworkImage( + url.toString(), + ref.watch( + CrossCacheController.provider, + ), + headers: ref.headers, + ), + width: info?.width, + loadingBuilder: + (_, child, loadingProgress) => + loadingProgress == null + ? child + : switch (info?.blurHash) { + final blurHash? => + SizedBox( + width: + info?.width ?? + info?.height ?? + 200, + height: + info?.height ?? + info?.width ?? + 200, + child: BlurHash( + hash: blurHash, + ), + ), + _ => Loading(), + }, + errorBuilder: + (context, error, stackTrace) => + Center( + child: Text( + "Image Failed to Load", + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.error, + ), ), ), - _ => Loading(), - }, - errorBuilder: - (context, error, stackTrace) => - Center( - child: Text( - "Image Failed to Load", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.error, - ), - ), - ), + ), + ), ), ), - ), - _ => Text( - "Nexus currently cannot handle encrypted media", - style: errorStyle, - ), - }, + _ => Text( + "Nexus currently cannot handle encrypted media", + style: errorStyle, + ), + }, if (event.lastEditRowId != null && !textOnly) Text( "(edited)", -- 2.53.0 From f9b1960cf809323a1653bcb54905348bbef8a477 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 11:23:38 -0400 Subject: [PATCH 049/108] support for m.emote msgtype --- lib/models/content/message.dart | 7 +++++++ lib/widgets/chat_page/render_event.dart | 26 ++++++++++++++++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index 1140809..35802ed 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -25,6 +25,13 @@ abstract class MessageContent extends Content with _$MessageContent { String? formattedBody, }) = NoticeMessageContent; + @FreezedUnionValue("m.emote") + factory MessageContent.emote({ + required String body, + String? format, + String? formattedBody, + }) = EmoteMessageContent; + @FreezedUnionValue("m.image") factory MessageContent.image({ required String body, diff --git a/lib/widgets/chat_page/render_event.dart b/lib/widgets/chat_page/render_event.dart index 84b95fc..3902c3d 100644 --- a/lib/widgets/chat_page/render_event.dart +++ b/lib/widgets/chat_page/render_event.dart @@ -62,6 +62,11 @@ class RenderEvent extends ConsumerWidget { children: getEventOptions!(event).toList(), ); + final textStyle = TextStyle( + fontSize: event.localContent?.bigEmoji == true ? 32 : null, + fontStyle: event.content is EmoteMessageContent ? FontStyle.italic : null, + ); + if (event.redactedBy != null) return SizedBox.shrink(); final child = switch (event.content) { @@ -132,6 +137,11 @@ class RenderEvent extends ConsumerWidget { :final formattedBody, :final format, ) || + EmoteMessageContent( + :final body, + :final formattedBody, + :final format, + ) || ImageMessageContent( :final body, :final formattedBody, @@ -141,10 +151,7 @@ class RenderEvent extends ConsumerWidget { children: [ format == "org.matrix.custom.html" && !textOnly ? Html( - textStyle: - event.localContent?.bigEmoji == true - ? TextStyle(fontSize: 32) - : null, + textStyle: textStyle, formattedBody!.replaceAllMapped( RegExp( "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", @@ -164,6 +171,7 @@ class RenderEvent extends ConsumerWidget { ), ) : Linkify( + style: textStyle, text: body, maxLines: maxLines, overflow: TextOverflow.ellipsis, @@ -263,7 +271,15 @@ class RenderEvent extends ConsumerWidget { LinkPreview(link.url), ], ), - _ => Text("Unknown message type", style: errorStyle), + MessageContent(:final body) => Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + children: [ + Text("Unknown message type:", style: errorStyle), + Text(body), + ], + ), + _ => throw Exception("This is impossible"), }, ], ), -- 2.53.0 From b3d1dc81b573b12b54d35a648be58b4718a22233 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 12:16:33 -0400 Subject: [PATCH 050/108] add membership rendering --- lib/models/membership_status.dart | 2 +- lib/widgets/chat_page/render_event.dart | 34 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/models/membership_status.dart b/lib/models/membership_status.dart index bc85e22..ba7a241 100644 --- a/lib/models/membership_status.dart +++ b/lib/models/membership_status.dart @@ -1,4 +1,4 @@ import "package:freezed_annotation/freezed_annotation.dart"; @JsonEnum() -enum MembershipStatus { leave, invite, ban, join } +enum MembershipStatus { leave, invite, ban, join, knock } diff --git a/lib/widgets/chat_page/render_event.dart b/lib/widgets/chat_page/render_event.dart index 3902c3d..3062068 100644 --- a/lib/widgets/chat_page/render_event.dart +++ b/lib/widgets/chat_page/render_event.dart @@ -8,14 +8,18 @@ import "package:linkify/linkify.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/cross_cache_controller.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/encrypted.dart"; +import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/models/membership_status.dart"; import "package:nexus/widgets/chat_page/expandable_image.dart"; import "package:nexus/widgets/chat_page/html/html.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; @@ -290,6 +294,36 @@ class RenderEvent extends ConsumerWidget { ), ], ), + MembershipContent content => Row( + spacing: 4, + children: [ + SizedBox(width: 4), + Icon(Icons.people), + InkWell( + onTapUp: (details) => context.showUserPopover( + content, + event.sender, + globalPosition: details.globalPosition, + ), + child: Text( + content.displayName ?? event.stateKey!.localpart, + style: TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + "${switch (content.status) { + MembershipStatus.invite => "was invited to", + MembershipStatus.join => "joined", + MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), + MembershipStatus.ban => "was banned from", + MembershipStatus.knock => "asked to join", + }} the room. ${content.reason ?? ""}", + ), + ], + ), AvatarContent() => Row( spacing: 4, children: [ -- 2.53.0 From 211c088df9956a91326549089188bc560440e40b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 13:37:02 -0400 Subject: [PATCH 051/108] fix extra memberships --- lib/models/event.dart | 9 +- lib/widgets/chat_page/render_event.dart | 544 ++++++++++-------- .../chat_page/wrappers/event_wrapper.dart | 1 - 3 files changed, 300 insertions(+), 254 deletions(-) diff --git a/lib/models/event.dart b/lib/models/event.dart index 3d9c0f6..51d8c9f 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -37,7 +37,8 @@ abstract class Event with _$Event { @JsonKey(name: "last_edit_rowid") int? lastEditRowId, @UnreadTypeConverter() UnreadType? unreadType, @JsonKey(fromJson: Event.pmpFromJson) Profile? pmp, - @JsonKey(fromJson: Content.fromJson) required Content content, + required Content content, + required Content? previousContent, }) = _Event; factory Event.fromJson(Map json) => @@ -46,6 +47,12 @@ abstract class Event with _$Event { json["decrypted"] ?? json["content"], json["decrypted_type"] ?? json["type"], ), + previousContent: json["unsigned"]?["prev_content"] == null + ? null + : Content.fromEventJson( + json["unsigned"]?["prev_content"], + json["decrypted_type"] ?? json["type"], + ), ); } diff --git a/lib/widgets/chat_page/render_event.dart b/lib/widgets/chat_page/render_event.dart index 3062068..b5439db 100644 --- a/lib/widgets/chat_page/render_event.dart +++ b/lib/widgets/chat_page/render_event.dart @@ -71,285 +71,325 @@ class RenderEvent extends ConsumerWidget { fontStyle: event.content is EmoteMessageContent ? FontStyle.italic : null, ); - if (event.redactedBy != null) return SizedBox.shrink(); - - final child = switch (event.content) { - Content(:final parseError?) => SelectableText( - "An error occurred while parsing this event:\n$parseError\n${parseError.stackTrace}", - style: errorStyle, - ), - MessageContent() || EncryptedContent() => Row( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - if (!textOnly) - if (isGrouped) - SizedBox(width: 40) - else - MessageAvatar(event, height: 40), - Expanded( - child: Column( - spacing: 4, + final child = event.redactedBy != null + ? null + : switch (event.content) { + Content(:final parseError?) => SelectableText( + "An error occurred while parsing this event:\n$parseError\n${parseError.stackTrace}", + style: errorStyle, + ), + MessageContent() || EncryptedContent() => Row( crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, children: [ - if (!isGrouped && !textOnly) - Row( + if (!textOnly) + if (isGrouped) + SizedBox(width: 40) + else + MessageAvatar(event, height: 40), + Expanded( + child: Column( spacing: 4, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Flexible( - child: MessageDisplayname( - event, - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - Flexible(child: timestamp), - ], - ), - Card( - color: - ref.watch( - ClientStateController.provider.select( - (value) => value?.userId, + if (!isGrouped && !textOnly) + Row( + spacing: 4, + children: [ + Flexible( + child: MessageDisplayname( + event, + style: TextStyle(fontWeight: FontWeight.bold), + ), ), - ) == - event.sender - ? (event.eventId.startsWith("~") - ? colorScheme.onPrimary - : colorScheme.primaryContainer) - : colorScheme.surfaceContainer, + Flexible(child: timestamp), + ], + ), + Card( + color: + ref.watch( + ClientStateController.provider.select( + (value) => value?.userId, + ), + ) == + event.sender + ? (event.eventId.startsWith("~") + ? colorScheme.onPrimary + : colorScheme.primaryContainer) + : colorScheme.surfaceContainer, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Quoted( // TODO: Show replies - // EventText(replyEvent textOnly: true, maxLines: 1,) - // ), - switch (event.content) { - EncryptedContent() => Text( - "Unable to decrypt event", - style: errorStyle, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, ), - TextMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - NoticeMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - EmoteMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - ImageMessageContent( - :final body, - :final formattedBody, - :final format, - ) => Column( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - format == "org.matrix.custom.html" && !textOnly - ? Html( - textStyle: textStyle, - formattedBody!.replaceAllMapped( - RegExp( - "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", - caseSensitive: false, - dotAll: true, - ), - (m) { - // If it's already an tag, leave it unchanged - if (m.group(1) != null) { - return m.group(1)!; - } - - // Otherwise, wrap the bare URL - final url = m.group(2)!; - return "$url"; - }, - ), - ) - : Linkify( - style: textStyle, - text: body, - maxLines: maxLines, - overflow: TextOverflow.ellipsis, - options: LinkifyOptions(humanize: false), - onOpen: (link) => ref - .watch(LaunchHelper.provider) - .launchUrl(Uri.parse(link.url)), - linkStyle: TextStyle( - color: Theme.of( - context, - ).colorScheme.primary, - ), - ), - - if (!textOnly) - if (event.content case ImageMessageContent( - :final url, - :final info, - )) - switch (url?.mxcToHttps( - ref.watch( - ClientStateController.provider.select( - (value) => value!.homeserverUrl!, - ), - ), - )) { - final url? => ConstrainedBox( - constraints: BoxConstraints.loose( - Size.fromWidth(500), - ), - child: ExpandableImage( - url.toString(), - child: ClipRRect( - borderRadius: BorderRadius.all( - Radius.circular(8), - ), - child: Image( - image: CachedNetworkImage( - url.toString(), - ref.watch( - CrossCacheController.provider, + // Quoted( // TODO: Show replies + // EventText(replyEvent textOnly: true, maxLines: 1,) + // ), + switch (event.content) { + EncryptedContent() => Text( + "Unable to decrypt event", + style: errorStyle, + ), + TextMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + NoticeMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + EmoteMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + ImageMessageContent( + :final body, + :final formattedBody, + :final format, + ) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + format == "org.matrix.custom.html" && + !textOnly + ? Html( + textStyle: textStyle, + formattedBody!.replaceAllMapped( + RegExp( + "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", + caseSensitive: false, + dotAll: true, ), - headers: ref.headers, + (m) { + // If it's already an tag, leave it unchanged + if (m.group(1) != null) { + return m.group(1)!; + } + + // Otherwise, wrap the bare URL + final url = m.group(2)!; + return "$url"; + }, ), - width: info?.width, - loadingBuilder: - (_, child, loadingProgress) => - loadingProgress == null - ? child - : switch (info?.blurHash) { - final blurHash? => - SizedBox( - width: - info?.width ?? - info?.height ?? - 200, - height: - info?.height ?? - info?.width ?? - 200, - child: BlurHash( - hash: blurHash, + ) + : Linkify( + style: textStyle, + text: body, + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + options: LinkifyOptions( + humanize: false, + ), + onOpen: (link) => ref + .watch(LaunchHelper.provider) + .launchUrl(Uri.parse(link.url)), + linkStyle: TextStyle( + color: Theme.of( + context, + ).colorScheme.primary, + ), + ), + + if (!textOnly) + if (event.content + case ImageMessageContent( + :final url, + :final info, + )) + switch (url?.mxcToHttps( + ref.watch( + ClientStateController.provider + .select( + (value) => + value!.homeserverUrl!, + ), + ), + )) { + final url? => ConstrainedBox( + constraints: BoxConstraints.loose( + Size.fromWidth(500), + ), + child: ExpandableImage( + url.toString(), + child: ClipRRect( + borderRadius: BorderRadius.all( + Radius.circular(8), + ), + child: Image( + image: CachedNetworkImage( + url.toString(), + ref.watch( + CrossCacheController + .provider, + ), + headers: ref.headers, + ), + width: info?.width, + loadingBuilder: + ( + _, + child, + loadingProgress, + ) => loadingProgress == null + ? child + : switch (info?.blurHash) { + final blurHash? => + SizedBox( + width: + info?.width ?? + info?.height ?? + 200, + height: + info?.height ?? + info?.width ?? + 200, + child: BlurHash( + hash: blurHash, + ), ), + _ => Loading(), + }, + errorBuilder: + ( + context, + error, + stackTrace, + ) => Center( + child: Text( + "Image Failed to Load", + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.error, ), - _ => Loading(), - }, - errorBuilder: - (context, error, stackTrace) => - Center( - child: Text( - "Image Failed to Load", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.error, ), ), - ), + ), + ), + ), ), - ), + _ => Text( + "Nexus currently cannot handle encrypted media", + style: errorStyle, + ), + }, + if (event.lastEditRowId != null && + !textOnly) + Text( + "(edited)", + style: theme.textTheme.labelSmall, ), - ), - _ => Text( - "Nexus currently cannot handle encrypted media", + if (linkify(body).firstWhereOrNull( + (element) => element is UrlElement, + ) + case final UrlElement link?) + LinkPreview(link.url), + ], + ), + MessageContent(:final body) => Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Unknown message type:", style: errorStyle, ), - }, - if (event.lastEditRowId != null && !textOnly) - Text( - "(edited)", - style: theme.textTheme.labelSmall, + Text(body), + ], ), - if (linkify(body).firstWhereOrNull( - (element) => element is UrlElement, - ) - case final UrlElement link?) - LinkPreview(link.url), + _ => throw Exception("This is impossible"), + }, ], ), - MessageContent(:final body) => Row( - spacing: 8, - mainAxisSize: MainAxisSize.min, - children: [ - Text("Unknown message type:", style: errorStyle), - Text(body), - ], - ), - _ => throw Exception("This is impossible"), - }, - ], - ), + ), + ), + ], ), ), ], ), - ), - ], - ), - MembershipContent content => Row( - spacing: 4, - children: [ - SizedBox(width: 4), - Icon(Icons.people), - InkWell( - onTapUp: (details) => context.showUserPopover( - content, - event.sender, - globalPosition: details.globalPosition, + MembershipContent content => + event.previousContent is MembershipContent && + (event.previousContent as MembershipContent).status == + content.status + ? null + : Row( + spacing: 4, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Icon(Icons.people), + ), + InkWell( + onTapUp: (details) => context.showUserPopover( + content, + event.sender, + globalPosition: details.globalPosition, + ), + child: Text( + content.displayName ?? event.stateKey!.localpart, + style: TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + "${switch (content.status) { + MembershipStatus.invite => "was invited to", + MembershipStatus.join => "joined", + MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), + MembershipStatus.ban => "was banned from", + MembershipStatus.knock => "asked to join", + }} the room${content.reason == null ? "" : "because ${content.reason}"}${event.sender == event.stateKey ? "" : " by "}", + ), + if (event.sender != event.stateKey) + MessageDisplayname( + event, + style: TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + AvatarContent() => Row( + spacing: 4, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Icon(Icons.numbers), + ), + MessageDisplayname( + event, + style: TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + Text("changed the room avatar"), + ], ), - child: Text( - content.displayName ?? event.stateKey!.localpart, - style: TextStyle( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - ), - Text( - "${switch (content.status) { - MembershipStatus.invite => "was invited to", - MembershipStatus.join => "joined", - MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), - MembershipStatus.ban => "was banned from", - MembershipStatus.knock => "asked to join", - }} the room. ${content.reason ?? ""}", - ), - ], - ), - AvatarContent() => Row( - spacing: 4, - children: [ - SizedBox(width: 4), - Icon(Icons.numbers), - MessageDisplayname( - event, - style: TextStyle( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - Text("changed the room avatar"), - ], - ), - _ => null, - }; + _ => null, + }; - return GestureDetector( - onSecondaryTapUp: contextMenuCallback, - onLongPressStart: contextMenuCallback, - child: child == null - ? textOnly - ? Text("Unknown event type", style: errorStyle) - : SizedBox.shrink() - : Padding(padding: EdgeInsets.symmetric(vertical: 8), child: child), - ); + return child == null + ? textOnly + ? Text("Unknown event type", style: errorStyle) + : SizedBox.shrink() + : GestureDetector( + onSecondaryTapUp: contextMenuCallback, + onLongPressStart: contextMenuCallback, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: child, + ), + ); } } diff --git a/lib/widgets/chat_page/wrappers/event_wrapper.dart b/lib/widgets/chat_page/wrappers/event_wrapper.dart index fb765da..d131e66 100644 --- a/lib/widgets/chat_page/wrappers/event_wrapper.dart +++ b/lib/widgets/chat_page/wrappers/event_wrapper.dart @@ -27,7 +27,6 @@ class EventWrapper extends StatelessWidget { duration: Duration(milliseconds: 250), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, children: [ child, if (event.sendError != null && event.sendError != "not sent") -- 2.53.0 From 1a4ef800c6f52170e6b1f0ecd6ff6b2762b415f6 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 16:05:45 -0400 Subject: [PATCH 052/108] placeholder widget for video support --- flake.lock | 12 +- lib/main.dart | 2 + lib/models/content/message.dart | 3 +- lib/widgets/chat_page/render_event.dart | 113 +++++++++------ lib/widgets/players/video.dart | 27 ++++ linux/flutter/generated_plugin_registrant.cc | 8 ++ linux/flutter/generated_plugins.cmake | 2 + linux/nix/devshell.nix | 9 +- pubspec.lock | 130 +++++++++++++++++- pubspec.yaml | 5 +- .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 12 files changed, 265 insertions(+), 54 deletions(-) create mode 100644 lib/widgets/players/video.dart diff --git a/flake.lock b/flake.lock index 0f824b0..d6167fb 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1777988971, - "narHash": "sha256-qIoWPDs+0/8JecyYgE3gpKQxW/4bLW/gp45vow9ioCQ=", + "lastModified": 1778716662, + "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "0678d8986be1661af6bb555f3489f2fdfc31f6ff", + "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", "type": "github" }, "original": { @@ -88,11 +88,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1777954456, - "narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=", + "lastModified": 1778869304, + "narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=", "owner": "nixos", "repo": "nixpkgs", - "rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1", + "rev": "d233902339c02a9c334e7e593de68855ad26c4cb", "type": "github" }, "original": { diff --git a/lib/main.dart b/lib/main.dart index 846f075..b687ebd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import "dart:io"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/foundation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:media_kit/media_kit.dart"; import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/header_controller.dart"; @@ -56,6 +57,7 @@ void showError(Object error, [StackTrace? stackTrace]) { void main() async { WidgetsFlutterBinding.ensureInitialized(); + MediaKit.ensureInitialized(); if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { await windowManager.ensureInitialized(); diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index 35802ed..c431324 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -3,6 +3,7 @@ import "package:nexus/models/info/audio.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/info/file.dart"; import "package:nexus/models/info/image.dart"; +import "package:nexus/models/info/video.dart"; part "message.freezed.dart"; part "message.g.dart"; @@ -72,7 +73,7 @@ abstract class MessageContent extends Content with _$MessageContent { String? formattedBody, // EncryptedFile? file String? filename, - AudioInfo? info, + VideoInfo? info, Uri? url, }) = VideoMessageContent; diff --git a/lib/widgets/chat_page/render_event.dart b/lib/widgets/chat_page/render_event.dart index b5439db..fa249d6 100644 --- a/lib/widgets/chat_page/render_event.dart +++ b/lib/widgets/chat_page/render_event.dart @@ -26,6 +26,7 @@ import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/link_preview.dart"; import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/players/video.dart"; import "package:timeago/timeago.dart"; import "package:flutter_linkify/flutter_linkify.dart"; @@ -153,6 +154,21 @@ class RenderEvent extends ConsumerWidget { :final body, :final formattedBody, :final format, + ) || + VideoMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + AudioMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + FileMessageContent( + :final body, + :final formattedBody, + :final format, ) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -199,9 +215,11 @@ class RenderEvent extends ConsumerWidget { if (!textOnly) if (event.content case ImageMessageContent( - :final url, - :final info, - )) + :final url, + ) || + FileMessageContent(:final url) || + VideoMessageContent(:final url) || + AudioMessageContent(:final url)) switch (url?.mxcToHttps( ref.watch( ClientStateController.provider @@ -215,32 +233,37 @@ class RenderEvent extends ConsumerWidget { constraints: BoxConstraints.loose( Size.fromWidth(500), ), - child: ExpandableImage( - url.toString(), - child: ClipRRect( - borderRadius: BorderRadius.all( - Radius.circular(8), - ), - child: Image( - image: CachedNetworkImage( - url.toString(), - ref.watch( - CrossCacheController - .provider, + child: switch (event.content) { + VideoMessageContent( + :final info, + ) => + VideoPlayer(url, info), + ImageMessageContent(:final info) => ExpandableImage( + url.toString(), + child: ClipRRect( + borderRadius: + BorderRadius.all( + Radius.circular(8), + ), + child: Image( + image: CachedNetworkImage( + url.toString(), + ref.watch( + CrossCacheController + .provider, + ), + headers: ref.headers, ), - headers: ref.headers, - ), - width: info?.width, - loadingBuilder: - ( - _, - child, - loadingProgress, - ) => loadingProgress == null - ? child - : switch (info?.blurHash) { - final blurHash? => - SizedBox( + width: info?.width, + loadingBuilder: + ( + _, + child, + loadingProgress, + ) => loadingProgress == null + ? child + : switch (info?.blurHash) { + final blurHash? => SizedBox( width: info?.width ?? info?.height ?? @@ -253,26 +276,28 @@ class RenderEvent extends ConsumerWidget { hash: blurHash, ), ), - _ => Loading(), - }, - errorBuilder: - ( - context, - error, - stackTrace, - ) => Center( - child: Text( - "Image Failed to Load", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.error, + _ => Loading(), + }, + errorBuilder: + ( + context, + error, + stackTrace, + ) => Center( + child: Text( + "Image Failed to Load", + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.error, + ), ), ), - ), + ), ), ), - ), + _ => SizedBox.shrink(), + }, ), _ => Text( "Nexus currently cannot handle encrypted media", diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart new file mode 100644 index 0000000..809ea22 --- /dev/null +++ b/lib/widgets/players/video.dart @@ -0,0 +1,27 @@ +import "package:flutter/material.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/models/info/video.dart"; +// import "package:flutter_hooks/flutter_hooks.dart"; +// import "package:media_kit/media_kit.dart"; +// import "package:media_kit_video/media_kit_video.dart"; +// import "package:nexus/helpers/extensions/get_headers.dart"; + +class VideoPlayer extends HookConsumerWidget { + final VideoInfo? info; + final Uri url; + const VideoPlayer(this.url, this.info, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // final player = useMemoized(Player.new); + // final controller = useMemoized(() => VideoController(player), [player]); + + // useEffect(() { + // player.open(Media(url.toString(), httpHeaders: ref.headers), play: false); + + // return player.dispose; + // }, []); + + return Placeholder(); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 5485b95..45c0f94 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,8 @@ #include #include +#include +#include #include #include #include @@ -19,6 +21,12 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); + media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_video_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); + media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 13ef2de..4e3b41b 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,6 +5,8 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_system_colors file_selector_linux + media_kit_libs_linux + media_kit_video screen_retriever_linux url_launcher_linux window_manager diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index 91ba95a..ae77467 100644 --- a/linux/nix/devshell.nix +++ b/linux/nix/devshell.nix @@ -22,7 +22,14 @@ pkgs.mkShell { go git jdk17 - flutter + libGL + wayland + (flutter.override { + extraPkgConfigPackages = [ + mpv-unwrapped + libass + ]; + }) android.platform-tools ]; diff --git a/pubspec.lock b/pubspec.lock index 19869cc..6d3aa22 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -760,6 +760,70 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted + version: "2.0.1" meta: dependency: transitive description: @@ -808,6 +872,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: "direct main" description: @@ -976,6 +1056,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "287ea1f667c0b93cdc127dccc707158e2d81ee59fba0459c31a0c7da4d09c755" + url: "https://pub.dev" + source: hosted + version: "2.0.3" screen_retriever: dependency: transitive description: @@ -1277,6 +1365,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" url_launcher: dependency: "direct main" description: @@ -1341,6 +1445,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_graphics: dependency: transitive description: @@ -1381,6 +1493,22 @@ packages: url: "https://pub.dev" source: hosted version: "15.2.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b + url: "https://pub.dev" + source: hosted + version: "1.5.1" watcher: dependency: transitive description: @@ -1470,5 +1598,5 @@ packages: source: hosted version: "2.2.4" sdks: - dart: "3.11.4" + dart: "3.11.5" flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 56412f7..f124f40 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,7 +9,7 @@ flutter: uses-material-design: true environment: - sdk: "3.11.4" + sdk: "3.11.5" dependency_overrides: linkify: @@ -60,6 +60,9 @@ dependencies: url: https://github.com/Henry-Hiles/emoji_text_field flutter_blurhash: 0.9.1 super_sliver_list: 0.4.1 + media_kit: 1.2.6 + media_kit_video: 2.0.1 + media_kit_libs_video: 1.0.7 dev_dependencies: build_runner: 2.15.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index bde1c28..8c54692 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,8 @@ #include #include +#include +#include #include #include #include @@ -17,6 +19,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); + MediaKitVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 7b6b425..f769d6e 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,8 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_system_colors file_selector_windows + media_kit_libs_windows_video + media_kit_video screen_retriever_windows url_launcher_windows window_manager -- 2.53.0 From 8010c3467ee53d2403d29db80a16714f3ce2decc Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 16:37:18 -0400 Subject: [PATCH 053/108] add video player, might need tweaking to get perfect --- lib/widgets/chat_page/render_event.dart | 5 +++++ lib/widgets/players/video.dart | 24 +++++++++++++----------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/lib/widgets/chat_page/render_event.dart b/lib/widgets/chat_page/render_event.dart index fa249d6..d3702fc 100644 --- a/lib/widgets/chat_page/render_event.dart +++ b/lib/widgets/chat_page/render_event.dart @@ -238,6 +238,11 @@ class RenderEvent extends ConsumerWidget { :final info, ) => VideoPlayer(url, info), + // TODO: Support audio + // FileMessageContent( + // :final info, + // ) => + // VideoPlayer(url, info), ImageMessageContent(:final info) => ExpandableImage( url.toString(), child: ClipRRect( diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart index 809ea22..5ba0e05 100644 --- a/lib/widgets/players/video.dart +++ b/lib/widgets/players/video.dart @@ -1,10 +1,10 @@ import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/models/info/video.dart"; -// import "package:flutter_hooks/flutter_hooks.dart"; -// import "package:media_kit/media_kit.dart"; -// import "package:media_kit_video/media_kit_video.dart"; -// import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:media_kit/media_kit.dart"; +import "package:media_kit_video/media_kit_video.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; class VideoPlayer extends HookConsumerWidget { final VideoInfo? info; @@ -13,15 +13,17 @@ class VideoPlayer extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // final player = useMemoized(Player.new); - // final controller = useMemoized(() => VideoController(player), [player]); + late final player = useMemoized(Player.new); + late final controller = useMemoized(() => VideoController(player), [ + player, + ]); - // useEffect(() { - // player.open(Media(url.toString(), httpHeaders: ref.headers), play: false); + useEffect(() { + player.open(Media(url.toString(), httpHeaders: ref.headers), play: false); - // return player.dispose; - // }, []); + return player.dispose; + }, []); - return Placeholder(); + return SizedBox(height: 300, child: Video(controller: controller)); } } -- 2.53.0 From 13f52a3989d8fa39dc54eebff70eafbdd5a20327 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 16:37:48 -0400 Subject: [PATCH 054/108] fix incorrect popover user for membership events --- lib/widgets/chat_page/render_event.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/chat_page/render_event.dart b/lib/widgets/chat_page/render_event.dart index d3702fc..c4a68f6 100644 --- a/lib/widgets/chat_page/render_event.dart +++ b/lib/widgets/chat_page/render_event.dart @@ -359,7 +359,7 @@ class RenderEvent extends ConsumerWidget { InkWell( onTapUp: (details) => context.showUserPopover( content, - event.sender, + event.stateKey!, globalPosition: details.globalPosition, ), child: Text( -- 2.53.0 From ce15add4e79942f13049524dc68c09974cce6d5e Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 17:04:19 -0400 Subject: [PATCH 055/108] turn up buffer size for video --- lib/widgets/players/video.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart index 5ba0e05..893505c 100644 --- a/lib/widgets/players/video.dart +++ b/lib/widgets/players/video.dart @@ -13,10 +13,12 @@ class VideoPlayer extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - late final player = useMemoized(Player.new); - late final controller = useMemoized(() => VideoController(player), [ - player, - ]); + late final player = useMemoized( + () => Player( + configuration: PlayerConfiguration(bufferSize: 128 * 1024 * 1024), + ), + ); + late final controller = useMemoized(() => VideoController(player)); useEffect(() { player.open(Media(url.toString(), httpHeaders: ref.headers), play: false); -- 2.53.0 From 5c2f8fa01489ab6e2ee40feb0fc1eac05af19aa7 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 18:47:30 -0400 Subject: [PATCH 056/108] more reliable video playback --- lib/widgets/players/video.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart index 893505c..9621e4f 100644 --- a/lib/widgets/players/video.dart +++ b/lib/widgets/players/video.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/models/info/video.dart"; @@ -13,15 +15,20 @@ class VideoPlayer extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - late final player = useMemoized( + final player = useMemoized( () => Player( configuration: PlayerConfiguration(bufferSize: 128 * 1024 * 1024), ), ); - late final controller = useMemoized(() => VideoController(player)); + final controller = useMemoized(() => VideoController(player)); useEffect(() { - player.open(Media(url.toString(), httpHeaders: ref.headers), play: false); + scheduleMicrotask( + () => player.open( + Media(url.toString(), httpHeaders: ref.headers), + play: false, + ), + ); return player.dispose; }, []); -- 2.53.0 From 551bec798222eb325e074fa58fdae9eaaa0db09e Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 19:11:23 -0400 Subject: [PATCH 057/108] add custom audio player widget --- lib/widgets/chat_page/render_event.dart | 11 +-- lib/widgets/players/audio.dart | 101 ++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 lib/widgets/players/audio.dart diff --git a/lib/widgets/chat_page/render_event.dart b/lib/widgets/chat_page/render_event.dart index c4a68f6..747c3b7 100644 --- a/lib/widgets/chat_page/render_event.dart +++ b/lib/widgets/chat_page/render_event.dart @@ -27,6 +27,7 @@ import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/link_preview.dart"; import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/players/video.dart"; +import "package:nexus/widgets/players/audio.dart"; import "package:timeago/timeago.dart"; import "package:flutter_linkify/flutter_linkify.dart"; @@ -120,10 +121,7 @@ class RenderEvent extends ConsumerWidget { : colorScheme.surfaceContainer, child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), + padding: EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -238,7 +236,10 @@ class RenderEvent extends ConsumerWidget { :final info, ) => VideoPlayer(url, info), - // TODO: Support audio + AudioMessageContent( + :final info, + ) => + AudioPlayer(url, info), // FileMessageContent( // :final info, // ) => diff --git a/lib/widgets/players/audio.dart b/lib/widgets/players/audio.dart new file mode 100644 index 0000000..a851035 --- /dev/null +++ b/lib/widgets/players/audio.dart @@ -0,0 +1,101 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:media_kit/media_kit.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/models/info/audio.dart"; + +class AudioPlayer extends HookConsumerWidget { + final Uri url; + final AudioInfo? info; + + const AudioPlayer(this.url, this.info, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = useMemoized( + () => Player( + configuration: PlayerConfiguration(bufferSize: 128 * 1024 * 1024), + ), + ); + + final playing = useState(false); + final position = useState(Duration.zero); + final duration = useState(Duration.zero); + + useEffect(() { + scheduleMicrotask(() async { + await player.open( + Media(url.toString(), httpHeaders: ref.headers), + play: false, + ); + + player.stream.playing.listen((value) { + playing.value = value; + }); + + player.stream.position.listen((value) { + position.value = value; + }); + + player.stream.duration.listen((value) { + duration.value = value; + }); + }); + + return player.dispose; + }, []); + + String format(Duration duration) { + final minutes = duration.inMinutes + .remainder(60) + .toString() + .padLeft(2, "0"); + final seconds = duration.inSeconds + .remainder(60) + .toString() + .padLeft(2, "0"); + + return "$minutes:$seconds"; + } + + return Card( + color: Theme.of(context).colorScheme.surfaceContainer, + child: Padding( + padding: EdgeInsetsGeometry.only(left: 8, right: 16), + child: Row( + children: [ + IconButton( + onPressed: player.playOrPause, + icon: Icon( + playing.value ? Icons.pause_circle : Icons.play_circle, + ), + ), + SizedBox(width: 8), + Text( + format(position.value), + style: Theme.of(context).textTheme.bodySmall, + ), + Expanded( + child: Slider( + min: 0, + max: duration.value.inMilliseconds <= 0 + ? 1 + : duration.value.inMilliseconds.toDouble(), + value: position.value.inMilliseconds.toDouble(), + onChanged: (value) => + player.seek(Duration(milliseconds: value.toInt())), + ), + ), + Text( + format(duration.value), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ); + } +} -- 2.53.0 From 32aff5b4b1e5b6e598c0d43d6cbe8deb23f9c7d6 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 19:12:59 -0400 Subject: [PATCH 058/108] increase link preview padding --- lib/widgets/link_preview.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/link_preview.dart b/lib/widgets/link_preview.dart index 843f5ac..8cb2a09 100644 --- a/lib/widgets/link_preview.dart +++ b/lib/widgets/link_preview.dart @@ -29,7 +29,7 @@ class LinkPreview extends ConsumerWidget { context, ).colorScheme.surfaceContainerHighest, child: Padding( - padding: EdgeInsetsGeometry.all(8), + padding: EdgeInsetsGeometry.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 4, -- 2.53.0 From 1cc2c87ae8bd30c5b8683ada1f8572d52ecca64b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 19:16:33 -0400 Subject: [PATCH 059/108] rename render_event to event_renderer --- lib/widgets/chat_page/composer/relation_preview.dart | 4 ++-- .../{render_event.dart => event_renderer.dart} | 10 ++++------ lib/widgets/chat_page/membership_renderer.dart | 0 lib/widgets/chat_page/room_chat.dart | 4 ++-- 4 files changed, 8 insertions(+), 10 deletions(-) rename lib/widgets/chat_page/{render_event.dart => event_renderer.dart} (98%) create mode 100644 lib/widgets/chat_page/membership_renderer.dart diff --git a/lib/widgets/chat_page/composer/relation_preview.dart b/lib/widgets/chat_page/composer/relation_preview.dart index 2df8a3d..2e59d5a 100644 --- a/lib/widgets/chat_page/composer/relation_preview.dart +++ b/lib/widgets/chat_page/composer/relation_preview.dart @@ -2,7 +2,7 @@ import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/chat_page/render_event.dart"; +import "package:nexus/widgets/chat_page/event_renderer.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; @@ -55,7 +55,7 @@ class RelationPreview extends ConsumerWidget { ), Expanded( child: IgnorePointer( - child: RenderEvent( + child: EventRenderer( relatedEvent!, textOnly: true, maxLines: 1, diff --git a/lib/widgets/chat_page/render_event.dart b/lib/widgets/chat_page/event_renderer.dart similarity index 98% rename from lib/widgets/chat_page/render_event.dart rename to lib/widgets/chat_page/event_renderer.dart index 747c3b7..b13a650 100644 --- a/lib/widgets/chat_page/render_event.dart +++ b/lib/widgets/chat_page/event_renderer.dart @@ -31,14 +31,14 @@ import "package:nexus/widgets/players/audio.dart"; import "package:timeago/timeago.dart"; import "package:flutter_linkify/flutter_linkify.dart"; -class RenderEvent extends ConsumerWidget { +class EventRenderer extends ConsumerWidget { final Event event; final bool textOnly; final bool isGrouped; final int? maxLines; final VoidCallback? onTapReply; final IList Function(Event event)? getEventOptions; - const RenderEvent( + const EventRenderer( this.event, { this.onTapReply, this.textOnly = false, @@ -240,10 +240,8 @@ class RenderEvent extends ConsumerWidget { :final info, ) => AudioPlayer(url, info), - // FileMessageContent( - // :final info, - // ) => - // VideoPlayer(url, info), + // FileMessageContent(:final info) => + // FileCard(url, info), ImageMessageContent(:final info) => ExpandableImage( url.toString(), child: ClipRRect( diff --git a/lib/widgets/chat_page/membership_renderer.dart b/lib/widgets/chat_page/membership_renderer.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index a9383f9..35490ac 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -18,7 +18,7 @@ import "package:nexus/models/relation_type.dart"; import "package:nexus/models/requests/report_request.dart"; import "package:nexus/widgets/chat_page/composer/chat_box.dart"; import "package:nexus/widgets/chat_page/emoji_picker_button.dart"; -import "package:nexus/widgets/chat_page/render_event.dart"; +import "package:nexus/widgets/chat_page/event_renderer.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/wrappers/event_wrapper.dart"; @@ -327,7 +327,7 @@ class RoomChat extends HookConsumerWidget { itemCount: value.length, itemBuilder: (_, index) => EventWrapper( value[index], - RenderEvent( + EventRenderer( value[index], onTapReply: () => listController.value.animateToItem( -- 2.53.0 From 35f5d4e8494c276458960342d4b59e16f3e189b6 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 19:21:37 -0400 Subject: [PATCH 060/108] refactor membership renderer into its own widget --- .../chat_page/composer/relation_preview.dart | 2 +- .../chat_page/membership_renderer.dart | 0 lib/widgets/chat_page/room_chat.dart | 2 +- .../event.dart} | 45 +------------ lib/widgets/renderers/membership.dart | 64 +++++++++++++++++++ 5 files changed, 68 insertions(+), 45 deletions(-) delete mode 100644 lib/widgets/chat_page/membership_renderer.dart rename lib/widgets/{chat_page/event_renderer.dart => renderers/event.dart} (89%) create mode 100644 lib/widgets/renderers/membership.dart diff --git a/lib/widgets/chat_page/composer/relation_preview.dart b/lib/widgets/chat_page/composer/relation_preview.dart index 2e59d5a..fff95b2 100644 --- a/lib/widgets/chat_page/composer/relation_preview.dart +++ b/lib/widgets/chat_page/composer/relation_preview.dart @@ -2,7 +2,7 @@ import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/chat_page/event_renderer.dart"; +import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; diff --git a/lib/widgets/chat_page/membership_renderer.dart b/lib/widgets/chat_page/membership_renderer.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 35490ac..0876982 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -18,7 +18,7 @@ import "package:nexus/models/relation_type.dart"; import "package:nexus/models/requests/report_request.dart"; import "package:nexus/widgets/chat_page/composer/chat_box.dart"; import "package:nexus/widgets/chat_page/emoji_picker_button.dart"; -import "package:nexus/widgets/chat_page/event_renderer.dart"; +import "package:nexus/widgets/renderers/event.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/wrappers/event_wrapper.dart"; diff --git a/lib/widgets/chat_page/event_renderer.dart b/lib/widgets/renderers/event.dart similarity index 89% rename from lib/widgets/chat_page/event_renderer.dart rename to lib/widgets/renderers/event.dart index b13a650..860e07f 100644 --- a/lib/widgets/chat_page/event_renderer.dart +++ b/lib/widgets/renderers/event.dart @@ -8,10 +8,8 @@ import "package:linkify/linkify.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/cross_cache_controller.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart"; -import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/content.dart"; @@ -19,7 +17,6 @@ import "package:nexus/models/content/encrypted.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/models/membership_status.dart"; import "package:nexus/widgets/chat_page/expandable_image.dart"; import "package:nexus/widgets/chat_page/html/html.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; @@ -28,6 +25,7 @@ import "package:nexus/widgets/link_preview.dart"; import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/players/video.dart"; import "package:nexus/widgets/players/audio.dart"; +import "package:nexus/widgets/renderers/membership.dart"; import "package:timeago/timeago.dart"; import "package:flutter_linkify/flutter_linkify.dart"; @@ -348,46 +346,7 @@ class EventRenderer extends ConsumerWidget { (event.previousContent as MembershipContent).status == content.status ? null - : Row( - spacing: 4, - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 4), - child: Icon(Icons.people), - ), - InkWell( - onTapUp: (details) => context.showUserPopover( - content, - event.stateKey!, - globalPosition: details.globalPosition, - ), - child: Text( - content.displayName ?? event.stateKey!.localpart, - style: TextStyle( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - ), - Text( - "${switch (content.status) { - MembershipStatus.invite => "was invited to", - MembershipStatus.join => "joined", - MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), - MembershipStatus.ban => "was banned from", - MembershipStatus.knock => "asked to join", - }} the room${content.reason == null ? "" : "because ${content.reason}"}${event.sender == event.stateKey ? "" : " by "}", - ), - if (event.sender != event.stateKey) - MessageDisplayname( - event, - style: TextStyle( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - ], - ), + : MembershipRenderer(event), AvatarContent() => Row( spacing: 4, children: [ diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart new file mode 100644 index 0000000..37c61c4 --- /dev/null +++ b/lib/widgets/renderers/membership.dart @@ -0,0 +1,64 @@ +import "package:flutter/material.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/membership_status.dart"; +import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; + +class MembershipRenderer extends StatelessWidget { + final Event event; + const MembershipRenderer(this.event, {super.key}); + + @override + Widget build(BuildContext context) { + assert( + event.content is MembershipContent, + "Make sure to only pass membership events to MembershipRenderer", + ); + + return switch (event.content) { + MembershipContent content => Row( + spacing: 4, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Icon(Icons.people), + ), + InkWell( + onTapUp: (details) => context.showUserPopover( + content, + event.stateKey!, + globalPosition: details.globalPosition, + ), + child: Text( + content.displayName ?? event.stateKey!.localpart, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + "${switch (content.status) { + MembershipStatus.invite => "was invited to", + MembershipStatus.join => "joined", + MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), + MembershipStatus.ban => "was banned from", + MembershipStatus.knock => "asked to join", + }} the room${content.reason == null ? "" : "because ${content.reason}"}${event.sender == event.stateKey ? "" : " by "}", + ), + if (event.sender != event.stateKey) + MessageDisplayname( + event, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + _ => SizedBox.shrink(), + }; + } +} -- 2.53.0 From 613e74ea33ea5a06e853f05b5a2ba4bac7937d52 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 19:25:41 -0400 Subject: [PATCH 061/108] remove chat_page directory, move relevant files --- lib/helpers/extensions/show_user_popover.dart | 2 +- lib/pages/chat_page.dart | 4 ++-- lib/widgets/{chat_page => }/composer/chat_box.dart | 6 +++--- .../{chat_page => }/composer/mention_overlay.dart | 0 .../{chat_page => }/composer/relation_preview.dart | 4 ++-- lib/widgets/{chat_page => }/emoji_picker_button.dart | 0 lib/widgets/{chat_page => }/expandable_image.dart | 0 lib/widgets/{chat_page => }/html/code_block.dart | 0 lib/widgets/{chat_page => }/html/html.dart | 10 +++++----- lib/widgets/{chat_page => }/html/mention_chip.dart | 0 lib/widgets/{chat_page => }/html/quoted.dart | 0 lib/widgets/{chat_page => }/html/spoiler_text.dart | 0 lib/widgets/{chat_page => }/join_dialog.dart | 0 .../{chat_page => }/lazy_loading/message_avatar.dart | 0 .../lazy_loading/message_displayname.dart | 0 lib/widgets/{chat_page => }/member_list.dart | 0 lib/widgets/renderers/event.dart | 10 +++++----- lib/widgets/renderers/membership.dart | 2 +- lib/widgets/{chat_page => }/room_appbar.dart | 4 ++-- lib/widgets/{chat_page => }/room_chat.dart | 10 +++++----- lib/widgets/{chat_page => }/room_menu.dart | 0 lib/widgets/{chat_page => }/sidebar.dart | 4 ++-- lib/widgets/{chat_page => }/user_popover.dart | 2 +- .../{chat_page => }/wrappers/event_wrapper.dart | 2 +- lib/widgets/{chat_page => }/wrappers/reaction_row.dart | 0 25 files changed, 30 insertions(+), 30 deletions(-) rename lib/widgets/{chat_page => }/composer/chat_box.dart (97%) rename lib/widgets/{chat_page => }/composer/mention_overlay.dart (100%) rename lib/widgets/{chat_page => }/composer/relation_preview.dart (94%) rename lib/widgets/{chat_page => }/emoji_picker_button.dart (100%) rename lib/widgets/{chat_page => }/expandable_image.dart (100%) rename lib/widgets/{chat_page => }/html/code_block.dart (100%) rename lib/widgets/{chat_page => }/html/html.dart (93%) rename lib/widgets/{chat_page => }/html/mention_chip.dart (100%) rename lib/widgets/{chat_page => }/html/quoted.dart (100%) rename lib/widgets/{chat_page => }/html/spoiler_text.dart (100%) rename lib/widgets/{chat_page => }/join_dialog.dart (100%) rename lib/widgets/{chat_page => }/lazy_loading/message_avatar.dart (100%) rename lib/widgets/{chat_page => }/lazy_loading/message_displayname.dart (100%) rename lib/widgets/{chat_page => }/member_list.dart (100%) rename lib/widgets/{chat_page => }/room_appbar.dart (95%) rename lib/widgets/{chat_page => }/room_chat.dart (97%) rename lib/widgets/{chat_page => }/room_menu.dart (100%) rename lib/widgets/{chat_page => }/sidebar.dart (98%) rename lib/widgets/{chat_page => }/user_popover.dart (99%) rename lib/widgets/{chat_page => }/wrappers/event_wrapper.dart (94%) rename lib/widgets/{chat_page => }/wrappers/reaction_row.dart (100%) diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart index 1ef68e9..1826037 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -1,7 +1,7 @@ import "package:flutter/material.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/models/content/membership.dart"; -import "package:nexus/widgets/chat_page/user_popover.dart"; +import "package:nexus/widgets/user_popover.dart"; extension ShowUserPopover on BuildContext { void showUserPopover( diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index 671891c..a8ec584 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -2,8 +2,8 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/init_complete_controller.dart"; import "package:nexus/widgets/appbar.dart"; -import "package:nexus/widgets/chat_page/sidebar.dart"; -import "package:nexus/widgets/chat_page/room_chat.dart"; +import "package:nexus/widgets/sidebar.dart"; +import "package:nexus/widgets/room_chat.dart"; import "package:nexus/widgets/loading.dart"; class ChatPage extends ConsumerWidget { diff --git a/lib/widgets/chat_page/composer/chat_box.dart b/lib/widgets/composer/chat_box.dart similarity index 97% rename from lib/widgets/chat_page/composer/chat_box.dart rename to lib/widgets/composer/chat_box.dart index e0aaca9..71033f9 100644 --- a/lib/widgets/chat_page/composer/chat_box.dart +++ b/lib/widgets/composer/chat_box.dart @@ -8,9 +8,9 @@ import "package:nexus/models/configs/power_level_config.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/chat_page/composer/mention_overlay.dart"; -import "package:nexus/widgets/chat_page/composer/relation_preview.dart"; -import "package:nexus/widgets/chat_page/emoji_picker_button.dart"; +import "package:nexus/widgets/composer/mention_overlay.dart"; +import "package:nexus/widgets/composer/relation_preview.dart"; +import "package:nexus/widgets/emoji_picker_button.dart"; class ChatBox extends HookConsumerWidget { final Event? relatedEvent; diff --git a/lib/widgets/chat_page/composer/mention_overlay.dart b/lib/widgets/composer/mention_overlay.dart similarity index 100% rename from lib/widgets/chat_page/composer/mention_overlay.dart rename to lib/widgets/composer/mention_overlay.dart diff --git a/lib/widgets/chat_page/composer/relation_preview.dart b/lib/widgets/composer/relation_preview.dart similarity index 94% rename from lib/widgets/chat_page/composer/relation_preview.dart rename to lib/widgets/composer/relation_preview.dart index fff95b2..028e412 100644 --- a/lib/widgets/chat_page/composer/relation_preview.dart +++ b/lib/widgets/composer/relation_preview.dart @@ -3,8 +3,8 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/renderers/event.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/lazy_loading/message_avatar.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; class RelationPreview extends ConsumerWidget { final Event? relatedEvent; diff --git a/lib/widgets/chat_page/emoji_picker_button.dart b/lib/widgets/emoji_picker_button.dart similarity index 100% rename from lib/widgets/chat_page/emoji_picker_button.dart rename to lib/widgets/emoji_picker_button.dart diff --git a/lib/widgets/chat_page/expandable_image.dart b/lib/widgets/expandable_image.dart similarity index 100% rename from lib/widgets/chat_page/expandable_image.dart rename to lib/widgets/expandable_image.dart diff --git a/lib/widgets/chat_page/html/code_block.dart b/lib/widgets/html/code_block.dart similarity index 100% rename from lib/widgets/chat_page/html/code_block.dart rename to lib/widgets/html/code_block.dart diff --git a/lib/widgets/chat_page/html/html.dart b/lib/widgets/html/html.dart similarity index 93% rename from lib/widgets/chat_page/html/html.dart rename to lib/widgets/html/html.dart index 2f93264..e889aff 100644 --- a/lib/widgets/chat_page/html/html.dart +++ b/lib/widgets/html/html.dart @@ -9,11 +9,11 @@ import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/link_to_mention.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/helpers/launch_helper.dart"; -import "package:nexus/widgets/chat_page/expandable_image.dart"; -import "package:nexus/widgets/chat_page/html/mention_chip.dart"; -import "package:nexus/widgets/chat_page/html/spoiler_text.dart"; -import "package:nexus/widgets/chat_page/html/code_block.dart"; -import "package:nexus/widgets/chat_page/html/quoted.dart"; +import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/html/mention_chip.dart"; +import "package:nexus/widgets/html/spoiler_text.dart"; +import "package:nexus/widgets/html/code_block.dart"; +import "package:nexus/widgets/html/quoted.dart"; class Html extends ConsumerWidget { final String html; diff --git a/lib/widgets/chat_page/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart similarity index 100% rename from lib/widgets/chat_page/html/mention_chip.dart rename to lib/widgets/html/mention_chip.dart diff --git a/lib/widgets/chat_page/html/quoted.dart b/lib/widgets/html/quoted.dart similarity index 100% rename from lib/widgets/chat_page/html/quoted.dart rename to lib/widgets/html/quoted.dart diff --git a/lib/widgets/chat_page/html/spoiler_text.dart b/lib/widgets/html/spoiler_text.dart similarity index 100% rename from lib/widgets/chat_page/html/spoiler_text.dart rename to lib/widgets/html/spoiler_text.dart diff --git a/lib/widgets/chat_page/join_dialog.dart b/lib/widgets/join_dialog.dart similarity index 100% rename from lib/widgets/chat_page/join_dialog.dart rename to lib/widgets/join_dialog.dart diff --git a/lib/widgets/chat_page/lazy_loading/message_avatar.dart b/lib/widgets/lazy_loading/message_avatar.dart similarity index 100% rename from lib/widgets/chat_page/lazy_loading/message_avatar.dart rename to lib/widgets/lazy_loading/message_avatar.dart diff --git a/lib/widgets/chat_page/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart similarity index 100% rename from lib/widgets/chat_page/lazy_loading/message_displayname.dart rename to lib/widgets/lazy_loading/message_displayname.dart diff --git a/lib/widgets/chat_page/member_list.dart b/lib/widgets/member_list.dart similarity index 100% rename from lib/widgets/chat_page/member_list.dart rename to lib/widgets/member_list.dart diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 860e07f..b2272f3 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -17,10 +17,10 @@ import "package:nexus/models/content/encrypted.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/widgets/chat_page/expandable_image.dart"; -import "package:nexus/widgets/chat_page/html/html.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/html/html.dart"; +import "package:nexus/widgets/lazy_loading/message_avatar.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/link_preview.dart"; import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/players/video.dart"; @@ -239,7 +239,7 @@ class EventRenderer extends ConsumerWidget { ) => AudioPlayer(url, info), // FileMessageContent(:final info) => - // FileCard(url, info), + // FileRenderer(url, info), ImageMessageContent(:final info) => ExpandableImage( url.toString(), child: ClipRRect( diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index 37c61c4..5330fea 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -4,7 +4,7 @@ import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/membership_status.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; class MembershipRenderer extends StatelessWidget { final Event event; diff --git a/lib/widgets/chat_page/room_appbar.dart b/lib/widgets/room_appbar.dart similarity index 95% rename from lib/widgets/chat_page/room_appbar.dart rename to lib/widgets/room_appbar.dart index 62e282d..52cf0ec 100644 --- a/lib/widgets/chat_page/room_appbar.dart +++ b/lib/widgets/room_appbar.dart @@ -4,8 +4,8 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/chat_page/expandable_image.dart"; -import "package:nexus/widgets/chat_page/room_menu.dart"; +import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/room_menu.dart"; class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { final bool isDesktop; diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/room_chat.dart similarity index 97% rename from lib/widgets/chat_page/room_chat.dart rename to lib/widgets/room_chat.dart index 0876982..5d9cf09 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -16,12 +16,12 @@ import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/models/requests/report_request.dart"; -import "package:nexus/widgets/chat_page/composer/chat_box.dart"; -import "package:nexus/widgets/chat_page/emoji_picker_button.dart"; +import "package:nexus/widgets/composer/chat_box.dart"; +import "package:nexus/widgets/emoji_picker_button.dart"; import "package:nexus/widgets/renderers/event.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/wrappers/event_wrapper.dart"; +import "package:nexus/widgets/member_list.dart"; +import "package:nexus/widgets/room_appbar.dart"; +import "package:nexus/widgets/wrappers/event_wrapper.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/main.dart"; diff --git a/lib/widgets/chat_page/room_menu.dart b/lib/widgets/room_menu.dart similarity index 100% rename from lib/widgets/chat_page/room_menu.dart rename to lib/widgets/room_menu.dart diff --git a/lib/widgets/chat_page/sidebar.dart b/lib/widgets/sidebar.dart similarity index 98% rename from lib/widgets/chat_page/sidebar.dart rename to lib/widgets/sidebar.dart index 77b8cd6..e200801 100644 --- a/lib/widgets/chat_page/sidebar.dart +++ b/lib/widgets/sidebar.dart @@ -4,8 +4,8 @@ import "package:nexus/controllers/key_controller.dart"; import "package:nexus/controllers/selected_space_controller.dart"; import "package:nexus/controllers/spaces_controller.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/chat_page/join_dialog.dart"; -import "package:nexus/widgets/chat_page/room_menu.dart"; +import "package:nexus/widgets/join_dialog.dart"; +import "package:nexus/widgets/room_menu.dart"; class Sidebar extends HookConsumerWidget { final bool isDesktop; diff --git a/lib/widgets/chat_page/user_popover.dart b/lib/widgets/user_popover.dart similarity index 99% rename from lib/widgets/chat_page/user_popover.dart rename to lib/widgets/user_popover.dart index 5d438ff..508a038 100644 --- a/lib/widgets/chat_page/user_popover.dart +++ b/lib/widgets/user_popover.dart @@ -16,7 +16,7 @@ import "package:nexus/models/requests/membership_action.dart"; import "package:nexus/models/requests/set_membership_request.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/main.dart"; -import "package:nexus/widgets/chat_page/expandable_image.dart"; +import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/form_text_input.dart"; class UserPopover extends ConsumerWidget { diff --git a/lib/widgets/chat_page/wrappers/event_wrapper.dart b/lib/widgets/wrappers/event_wrapper.dart similarity index 94% rename from lib/widgets/chat_page/wrappers/event_wrapper.dart rename to lib/widgets/wrappers/event_wrapper.dart index d131e66..c032d52 100644 --- a/lib/widgets/chat_page/wrappers/event_wrapper.dart +++ b/lib/widgets/wrappers/event_wrapper.dart @@ -1,6 +1,6 @@ import "package:flutter/material.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/widgets/chat_page/wrappers/reaction_row.dart"; +import "package:nexus/widgets/wrappers/reaction_row.dart"; class EventWrapper extends StatelessWidget { final Event event; diff --git a/lib/widgets/chat_page/wrappers/reaction_row.dart b/lib/widgets/wrappers/reaction_row.dart similarity index 100% rename from lib/widgets/chat_page/wrappers/reaction_row.dart rename to lib/widgets/wrappers/reaction_row.dart -- 2.53.0 From 2344ed887d01254367df329c668ec41648571178 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 19:45:39 -0400 Subject: [PATCH 062/108] Add file card --- lib/helpers/extensions/size_to_string.dart | 22 ++++++++++++++++ lib/widgets/file_card.dart | 29 ++++++++++++++++++++++ lib/widgets/renderers/event.dart | 12 +++++++-- 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 lib/helpers/extensions/size_to_string.dart create mode 100644 lib/widgets/file_card.dart diff --git a/lib/helpers/extensions/size_to_string.dart b/lib/helpers/extensions/size_to_string.dart new file mode 100644 index 0000000..654df9a --- /dev/null +++ b/lib/helpers/extensions/size_to_string.dart @@ -0,0 +1,22 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +extension SizeToString on int { + String get sizeAsString { + const IListConst suffixes = IListConst([ + "B", + "KB", + "MB", + "GB", + "TB", + "PB", + ]); + + var i = 0; + var size = toDouble(); + while (size > 1024 && i < suffixes.length - 1) { + size /= 1024; + i++; + } + return "${size.toStringAsFixed(2)} ${suffixes[i]}"; + } +} diff --git a/lib/widgets/file_card.dart b/lib/widgets/file_card.dart new file mode 100644 index 0000000..7e3bb6f --- /dev/null +++ b/lib/widgets/file_card.dart @@ -0,0 +1,29 @@ +import "package:flutter/material.dart"; +import "package:nexus/helpers/extensions/size_to_string.dart"; +import "package:nexus/models/info/file.dart"; + +class FileCard extends StatelessWidget { + final Uri uri; + final FileInfo? info; + final String? filename; + const FileCard(this.uri, this.info, {this.filename, super.key}); + + @override + Widget build(BuildContext context) => SizedBox( + width: 320, + child: Card( + color: Theme.of(context).colorScheme.surfaceContainer, + child: ListTile( + leading: Icon(Icons.file_copy), + title: Text( + filename ?? "file", + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: info?.size == null ? null : Text(info!.size!.sizeAsString), + // TODO: Downloading files + trailing: IconButton(onPressed: null, icon: Icon(Icons.download)), + ), + ), + ); +} diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index b2272f3..85b9e92 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -26,6 +26,7 @@ import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/players/video.dart"; import "package:nexus/widgets/players/audio.dart"; import "package:nexus/widgets/renderers/membership.dart"; +import "package:nexus/widgets/file_card.dart"; import "package:timeago/timeago.dart"; import "package:flutter_linkify/flutter_linkify.dart"; @@ -238,8 +239,15 @@ class EventRenderer extends ConsumerWidget { :final info, ) => AudioPlayer(url, info), - // FileMessageContent(:final info) => - // FileRenderer(url, info), + FileMessageContent( + :final info, + :final filename, + ) => + FileCard( + url, + info, + filename: filename, + ), ImageMessageContent(:final info) => ExpandableImage( url.toString(), child: ClipRRect( -- 2.53.0 From 94df2dc68f41228b382e3bdba907a4e6121b7e0a Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 19:53:57 -0400 Subject: [PATCH 063/108] add a WIP comment for location messages --- lib/widgets/renderers/event.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 85b9e92..6ff65bd 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -132,6 +132,8 @@ class EventRenderer extends ConsumerWidget { "Unable to decrypt event", style: errorStyle, ), + // TODO: Handle locations + // LocationMessageContent(:final body , :final geoUri) => TextMessageContent( :final body, :final formattedBody, -- 2.53.0 From bbd157a584ff34f809e5a53a9062b3b156b0a78b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 19:57:01 -0400 Subject: [PATCH 064/108] use raw string for link regex --- lib/widgets/renderers/event.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 6ff65bd..6f00988 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -177,7 +177,7 @@ class EventRenderer extends ConsumerWidget { textStyle: textStyle, formattedBody!.replaceAllMapped( RegExp( - "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", + r"(]*>.*?<\/a>)|(\\bhttps?:\/\/[^\s<]+)", caseSensitive: false, dotAll: true, ), -- 2.53.0 From 1305320a1a80545c6bc062df73c48224ff0bfa0a Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 20:17:41 -0400 Subject: [PATCH 065/108] Implement theoretical code for rendering replies Waiting on Tulir's reply on why I don't get relatesTo and relationType back on DBEvents from sync --- lib/controllers/client_controller.dart | 5 ---- lib/controllers/event_controller.dart | 14 +++++++-- lib/models/requests/get_event_request.dart | 20 ++----------- lib/widgets/renderers/event.dart | 34 ++++++++++++++++++++-- 4 files changed, 45 insertions(+), 28 deletions(-) diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index 612efa6..3f29f25 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -218,11 +218,6 @@ class ClientController extends AsyncNotifier { } Future getEvent(GetEventRequest request) async { - final event = request.room.events.firstWhereOrNull( - (event) => event.eventId == request.eventId, - ); - if (event != null) return event; - final json = await _sendCommand("get_event", request.toJson()); return json == null ? null : Event.fromJson(json); } diff --git a/lib/controllers/event_controller.dart b/lib/controllers/event_controller.dart index 4f72963..8dfb356 100644 --- a/lib/controllers/event_controller.dart +++ b/lib/controllers/event_controller.dart @@ -1,5 +1,7 @@ +import "package:collection/collection.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_event_request.dart"; @@ -9,8 +11,16 @@ class EventController extends AsyncNotifier { @override Future build() async { - final client = ref.watch(ClientController.provider.notifier); - return await client.getEvent(request).onError((_, _) => null); + final room = ref.watch(RoomsController.provider)[request.roomId]; + final event = room?.events.firstWhereOrNull( + (event) => event.eventId == request.eventId, + ); + + return event ?? + await ref + .watch(ClientController.provider.notifier) + .getEvent(request) + .onError((_, _) => null); } static final provider = AsyncNotifierProvider.family diff --git a/lib/models/requests/get_event_request.dart b/lib/models/requests/get_event_request.dart index 9374f3a..4fcf7b6 100644 --- a/lib/models/requests/get_event_request.dart +++ b/lib/models/requests/get_event_request.dart @@ -1,32 +1,16 @@ import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/room.dart"; part "get_event_request.freezed.dart"; part "get_event_request.g.dart"; -@Freezed(toJson: false) +@Freezed() abstract class GetEventRequest with _$GetEventRequest { const GetEventRequest._(); const factory GetEventRequest({ - required Room room, + required String roomId, required String eventId, @Default(false) bool unredact, }) = _GetEventRequest; - Map toJson() => { - "room_id": room.metadata?.id, - "event_id": eventId, - "unredact": unredact, - }; - - @override - bool operator ==(Object other) => - other.runtimeType == runtimeType && - other is GetEventRequest && - other.eventId == eventId; - - @override - int get hashCode => Object.hash(runtimeType, eventId); - factory GetEventRequest.fromJson(Map json) => _$GetEventRequestFromJson(json); } diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 6f00988..c4cf6e4 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -7,6 +7,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:linkify/linkify.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/cross_cache_controller.dart"; +import "package:nexus/controllers/event_controller.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart"; @@ -17,8 +18,10 @@ import "package:nexus/models/content/encrypted.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/models/requests/get_event_request.dart"; import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/html/html.dart"; +import "package:nexus/widgets/html/quoted.dart"; import "package:nexus/widgets/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/link_preview.dart"; @@ -124,9 +127,34 @@ class EventRenderer extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Quoted( // TODO: Show replies - // EventText(replyEvent textOnly: true, maxLines: 1,) - // ), + if (event.relationType == "m.in_reply_to" && + event.relatesTo != null) + Quoted( + ref + .watch( + EventController.provider( + GetEventRequest( + roomId: event.roomId, + eventId: event.relatesTo!, + ), + ), + ) + .when( + data: (replyEvent) => replyEvent == null + ? SizedBox.shrink() + : EventRenderer( + replyEvent, + textOnly: true, + maxLines: 1, + ), + error: (_, _) => Text( + "An error occurred while fetching the reply", + style: errorStyle, + ), + loading: () => + Text("Fetching event..."), + ), + ), switch (event.content) { EncryptedContent() => Text( "Unable to decrypt event", -- 2.53.0 From 734e7f57dfe72f5216fc6540cfc0c8bc038ad844 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 20:40:30 -0400 Subject: [PATCH 066/108] add a todo for showing events waiting for a response, some wip code --- lib/controllers/room_chat_controller.dart | 19 +++++++++++++++++-- lib/controllers/rooms_controller.dart | 6 +----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 10273a0..99cb32a 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -113,7 +113,6 @@ class RoomChatController extends AsyncNotifier> { ), }), const ISet.empty(), - addToNewEvents: false, ); return response.hasMore; @@ -158,7 +157,23 @@ class RoomChatController extends AsyncNotifier> { ), ); - // state = state TODO + // TODO: Add new event to timeline whilst its sending + // ref + // .watch(RoomsController.provider.notifier) + // .update( + // { + // roomId: Room( + // events: [event].toIList(), + // timeline: [ + // TimelineRowTuple( + // timelineRowId: event.timelineRowId, + // eventRowId: event.rowId, + // ), + // ].toIList(), + // ), + // }.toIMap(), + // const ISet.empty(), + // ); } Future removeReaction( diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart index 7eaf49f..69117f3 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -8,11 +8,7 @@ class RoomsController extends Notifier> { @override IMap build() => const IMap.empty(); - void update( - IMap rooms, - ISet leftRooms, { - bool addToNewEvents = true, - }) { + void update(IMap rooms, ISet leftRooms) { final merged = rooms.entries.fold(state, (acc, entry) { final roomId = entry.key; final incoming = entry.value; -- 2.53.0 From 7761ca73fd2cc6e390b3b0094daad28d53237751 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 21:18:27 -0400 Subject: [PATCH 067/108] working edits --- lib/controllers/room_chat_controller.dart | 18 ++++++++++++++---- lib/models/event.dart | 6 ++++-- lib/widgets/renderers/event.dart | 5 ++--- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 99cb32a..a13a801 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -62,11 +62,21 @@ class RoomChatController extends AsyncNotifier> { } return room.timeline.reversed - .map( - (timeline) => room.events.firstWhereOrNull( + .map((timeline) { + final foundEvent = room.events.firstWhereOrNull( (event) => event.rowId == timeline.eventRowId, - ), - ) + ); + + final editedEvent = foundEvent?.lastEditRowId == 0 + ? null + : room.events.firstWhereOrNull( + (event) => event.rowId == foundEvent?.lastEditRowId, + ); + + return foundEvent?.copyWith( + content: editedEvent?.content ?? foundEvent.content, + ); + }) .nonNulls .toIList(); } diff --git a/lib/models/event.dart b/lib/models/event.dart index 51d8c9f..6f8d362 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -34,7 +34,7 @@ abstract class Event with _$Event { String? decryptionError, String? sendError, @Default(IMap.empty()) IMap reactions, - @JsonKey(name: "last_edit_rowid") int? lastEditRowId, + @JsonKey(name: "last_edit_rowid") @Default(0) int lastEditRowId, @UnreadTypeConverter() UnreadType? unreadType, @JsonKey(fromJson: Event.pmpFromJson) Profile? pmp, required Content content, @@ -44,7 +44,9 @@ abstract class Event with _$Event { factory Event.fromJson(Map json) => _$EventFromJson(json).copyWith( content: Content.fromEventJson( - json["decrypted"] ?? json["content"], + (json["decrypted"] ?? json["content"])["m.new_content"] ?? + json["decrypted"] ?? + json["content"], json["decrypted_type"] ?? json["type"], ), previousContent: json["unsigned"]?["prev_content"] == null diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index c4cf6e4..0814887 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -75,7 +75,7 @@ class EventRenderer extends ConsumerWidget { fontStyle: event.content is EmoteMessageContent ? FontStyle.italic : null, ); - final child = event.redactedBy != null + final child = event.redactedBy != null || event.relationType == "m.replace" ? null : switch (event.content) { Content(:final parseError?) => SelectableText( @@ -344,8 +344,7 @@ class EventRenderer extends ConsumerWidget { style: errorStyle, ), }, - if (event.lastEditRowId != null && - !textOnly) + if (event.lastEditRowId != 0 && !textOnly) Text( "(edited)", style: theme.textTheme.labelSmall, -- 2.53.0 From a72d696f49ce05fcb9007b5807c2d38913e9056d Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 21:44:03 -0400 Subject: [PATCH 068/108] working reply rendering --- lib/models/event.dart | 5 +++ lib/widgets/renderers/event.dart | 77 +++++++++++++++++++++----------- lib/widgets/room_chat.dart | 34 +++++++++----- 3 files changed, 81 insertions(+), 35 deletions(-) diff --git a/lib/models/event.dart b/lib/models/event.dart index 6f8d362..f647460 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -31,6 +31,7 @@ abstract class Event with _$Event { String? redactedBy, String? relatesTo, String? relationType, + String? replyTo, String? decryptionError, String? sendError, @Default(IMap.empty()) IMap reactions, @@ -43,6 +44,10 @@ abstract class Event with _$Event { factory Event.fromJson(Map json) => _$EventFromJson(json).copyWith( + replyTo: + ((json["decrypted"] ?? json["content"])["m.new_content"] ?? + json["decrypted"] ?? + json["content"])?["m.relates_to"]?["m.in_reply_to"]?["event_id"], content: Content.fromEventJson( (json["decrypted"] ?? json["content"])["m.new_content"] ?? json["decrypted"] ?? diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 0814887..e053e90 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -21,7 +21,6 @@ import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_event_request.dart"; import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/html/html.dart"; -import "package:nexus/widgets/html/quoted.dart"; import "package:nexus/widgets/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/link_preview.dart"; @@ -110,50 +109,76 @@ class EventRenderer extends ConsumerWidget { ], ), Card( - color: - ref.watch( - ClientStateController.provider.select( - (value) => value?.userId, - ), - ) == - event.sender + color: textOnly + ? Colors.transparent + : ref.watch( + ClientStateController.provider.select( + (value) => value?.userId, + ), + ) == + event.sender ? (event.eventId.startsWith("~") ? colorScheme.onPrimary : colorScheme.primaryContainer) : colorScheme.surfaceContainer, + elevation: textOnly ? 0 : null, child: Padding( - padding: EdgeInsets.all(12), + padding: textOnly + ? EdgeInsets.zero + : EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (event.relationType == "m.in_reply_to" && - event.relatesTo != null) - Quoted( - ref - .watch( + if (event.replyTo != null) + Card( + margin: EdgeInsets.only(bottom: 8), + color: theme.colorScheme.surfaceContainer, + child: InkWell( + onTap: onTapReply, + child: Padding( + padding: EdgeInsetsGeometry.symmetric( + vertical: 8, + horizontal: 12, + ), + child: switch (ref.watch( EventController.provider( GetEventRequest( roomId: event.roomId, - eventId: event.relatesTo!, + eventId: event.replyTo!, ), ), - ) - .when( - data: (replyEvent) => replyEvent == null - ? SizedBox.shrink() - : EventRenderer( - replyEvent, + )) { + AsyncData(:final value?) || + AsyncLoading(:final value?) => Row( + spacing: 8, + children: [ + MessageAvatar(event, height: 24), + MessageDisplayname( + event, + style: TextStyle( + color: + theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + Expanded( + child: EventRenderer( + value, textOnly: true, maxLines: 1, ), - error: (_, _) => Text( + ), + ], + ), + AsyncError _ => Text( "An error occurred while fetching the reply", style: errorStyle, ), - loading: () => - Text("Fetching event..."), - ), + _ => Text("Fetching event..."), + }, + ), + ), ), switch (event.content) { EncryptedContent() => Text( @@ -408,6 +433,8 @@ class EventRenderer extends ConsumerWidget { ? textOnly ? Text("Unknown event type", style: errorStyle) : SizedBox.shrink() + : textOnly + ? child : GestureDetector( onSecondaryTapUp: contextMenuCallback, onLongPressStart: contextMenuCallback, diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 5d9cf09..a531cdb 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -41,6 +41,7 @@ class RoomChat extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final relatedEvent = useState(null); final relationType = useState(RelationType.reply); + final flashingEvent = useState(null); final memberListOpened = useState(showMembersByDefault); @@ -329,21 +330,34 @@ class RoomChat extends HookConsumerWidget { value[index], EventRenderer( value[index], - onTapReply: () => - listController.value.animateToItem( - index: index, - scrollController: scrollController, - alignment: 0.5, - duration: (_) => - Duration(milliseconds: 250), - curve: (_) => Curves.easeInOut, + onTapReply: () async { + final replyId = value[index].replyTo; + listController.value.animateToItem( + index: value.indexWhere( + (element) => element.eventId == replyId, ), + scrollController: scrollController, + alignment: 0.5, + duration: (_) => + Duration(milliseconds: 250), + curve: (_) => Curves.easeInOut, + ); + flashingEvent.value = replyId; + await Future.delayed( + Duration(seconds: 1), + () { + if (flashingEvent.value == replyId) { + flashingEvent.value = null; + } + }, + ); + }, getEventOptions: getEventOptions, // TODO: Reimplement grouping isGrouped: false, ), - // TODO: Reimplement flashing - isFlashing: false, + isFlashing: + flashingEvent.value == value[index].eventId, ), ), ], -- 2.53.0 From 150de1a66923b433d785f828f24f0582311245f3 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 21:45:03 -0400 Subject: [PATCH 069/108] Change scroll animation length --- lib/widgets/room_chat.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index a531cdb..3dcea9b 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -339,7 +339,7 @@ class RoomChat extends HookConsumerWidget { scrollController: scrollController, alignment: 0.5, duration: (_) => - Duration(milliseconds: 250), + Duration(milliseconds: 700), curve: (_) => Curves.easeInOut, ); flashingEvent.value = replyId; -- 2.53.0 From 200ce2285cfccf6c84902d53190c4a1d984dc967 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 21:45:51 -0400 Subject: [PATCH 070/108] limit size of loading indicator for link previews --- lib/widgets/link_preview.dart | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/widgets/link_preview.dart b/lib/widgets/link_preview.dart index 8cb2a09..e20e955 100644 --- a/lib/widgets/link_preview.dart +++ b/lib/widgets/link_preview.dart @@ -12,14 +12,14 @@ class LinkPreview extends ConsumerWidget { const LinkPreview(this.link, {super.key}); @override - Widget build(BuildContext context, WidgetRef ref) => ref - .watch(UrlPreviewController.provider(link)) - .betterWhen( - data: (preview) => preview == null - ? SizedBox.shrink() - : ConstrainedBox( - constraints: BoxConstraints.loose(Size.fromWidth(400)), - child: InkWell( + Widget build(BuildContext context, WidgetRef ref) => ConstrainedBox( + constraints: BoxConstraints.loose(Size.fromWidth(400)), + child: ref + .watch(UrlPreviewController.provider(link)) + .betterWhen( + data: (preview) => preview == null + ? SizedBox.shrink() + : InkWell( onTap: () => ref .watch(LaunchHelper.provider) .launchUrl(Uri.parse(link)), @@ -64,6 +64,6 @@ class LinkPreview extends ConsumerWidget { ), ), ), - ), - ); + ), + ); } -- 2.53.0 From c4255f340a2c028aede31bb4cb353772a94b71d3 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 21:54:21 -0400 Subject: [PATCH 071/108] Support for loading history and marking read --- lib/widgets/room_chat.dart | 43 ++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 3dcea9b..7d28838 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -7,6 +7,7 @@ import "package:nexus/controllers/account_data_controller.dart"; import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/power_level_controller.dart"; +import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/controllers/via_controller.dart"; @@ -45,13 +46,6 @@ class RoomChat extends HookConsumerWidget { final memberListOpened = useState(showMembersByDefault); - final listController = useRef(ListController()); - final scrollController = useScrollController( - onAttach: (position) => position.addListener(() { - // TODO: Do things on scroll to top or bottom - }), - ); - final userId = ref.watch(ClientStateController.provider)?.userId; final roomId = ref.watch( SelectedRoomController.provider.select((value) => value?.metadata?.id), @@ -78,6 +72,21 @@ class RoomChat extends HookConsumerWidget { final controllerProvider = RoomChatController.provider(roomId); final notifier = ref.watch(controllerProvider.notifier); + final client = ref.watch(ClientController.provider.notifier); + + final listController = useRef(ListController()); + final scrollController = useScrollController(); + scrollController.addListener(() async { + if (!scrollController.position.atEdge) return; + + if (scrollController.position.pixels == 0) { + final room = ref.watch(RoomsController.provider)[roomId]; + if (room != null) client.markRead(room); + } else { + await notifier.loadOlder(); + } + }); + final composerNode = useFocusNode( onKeyEvent: (_, event) { if (event is KeyDownEvent && @@ -269,17 +278,15 @@ class RoomChat extends HookConsumerWidget { ), TextButton( onPressed: () { - ref - .watch(ClientController.provider.notifier) - .reportEvent( - ReportRequest( - roomId: roomId, - eventId: event.eventId, - reason: reasonController.text.isEmpty - ? null - : reasonController.text, - ), - ); + client.reportEvent( + ReportRequest( + roomId: roomId, + eventId: event.eventId, + reason: reasonController.text.isEmpty + ? null + : reasonController.text, + ), + ); Navigator.of(context).pop(); }, child: Text("Report"), -- 2.53.0 From 0da5e8beac8fdb964989685c38c65d2f6e32e5e7 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 21:57:55 -0400 Subject: [PATCH 072/108] fix overflow of reply preview --- lib/widgets/renderers/event.dart | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index e053e90..3127755 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -154,12 +154,14 @@ class EventRenderer extends ConsumerWidget { spacing: 8, children: [ MessageAvatar(event, height: 24), - MessageDisplayname( - event, - style: TextStyle( - color: - theme.colorScheme.primary, - fontWeight: FontWeight.bold, + Flexible( + child: MessageDisplayname( + event, + style: TextStyle( + color: + theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), ), ), Expanded( -- 2.53.0 From dbef2d709bac056d1e817f83b8826e05075c318a Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 22:37:17 -0400 Subject: [PATCH 073/108] fix double reply preview --- lib/widgets/renderers/event.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 3127755..cfc865f 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -130,7 +130,7 @@ class EventRenderer extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (event.replyTo != null) + if (!textOnly && event.replyTo != null) Card( margin: EdgeInsets.only(bottom: 8), color: theme.colorScheme.surfaceContainer, -- 2.53.0 From e7bcf956e3a964504f7e9405e556bc1f59f43d3e Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 22:46:36 -0400 Subject: [PATCH 074/108] improve legibility of content parsing --- lib/models/event.dart | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/models/event.dart b/lib/models/event.dart index f647460..c74e2f3 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -16,6 +16,12 @@ abstract class Event with _$Event { static String typeJsonFromJson(Map json, _) => json["decrypted_type"] ?? json["type"]; + static Map getContentFromJson(Map json) { + final content = json["decrypted"] ?? json["content"]; + + return content["m.new_content"] ?? content; + } + const factory Event({ @JsonKey(name: "rowid") required int rowId, @JsonKey(name: "timeline_rowid") required int timelineRowId, @@ -44,14 +50,11 @@ abstract class Event with _$Event { factory Event.fromJson(Map json) => _$EventFromJson(json).copyWith( - replyTo: - ((json["decrypted"] ?? json["content"])["m.new_content"] ?? - json["decrypted"] ?? - json["content"])?["m.relates_to"]?["m.in_reply_to"]?["event_id"], + replyTo: getContentFromJson( + json, + )["m.relates_to"]?["m.in_reply_to"]?["event_id"], content: Content.fromEventJson( - (json["decrypted"] ?? json["content"])["m.new_content"] ?? - json["decrypted"] ?? - json["content"], + getContentFromJson(json), json["decrypted_type"] ?? json["type"], ), previousContent: json["unsigned"]?["prev_content"] == null -- 2.53.0 From 5c6cc1d584e89359e261872ee7cf0c07c00a70db Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 22:55:29 -0400 Subject: [PATCH 075/108] fix PMP rendering, add grouping --- lib/models/event.dart | 12 +++--- lib/widgets/renderers/event.dart | 4 +- lib/widgets/room_chat.dart | 71 +++++++++++++++++--------------- 3 files changed, 47 insertions(+), 40 deletions(-) diff --git a/lib/models/event.dart b/lib/models/event.dart index c74e2f3..c54dbc5 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -8,11 +8,6 @@ part "event.g.dart"; @freezed abstract class Event with _$Event { - static Profile? pmpFromJson(Map? json) { - final pmp = json?["content"]?["com.beeper.per_message_profile"]; - return pmp == null ? null : Profile.fromJsonWithCatch(pmp); - } - static String typeJsonFromJson(Map json, _) => json["decrypted_type"] ?? json["type"]; @@ -43,7 +38,7 @@ abstract class Event with _$Event { @Default(IMap.empty()) IMap reactions, @JsonKey(name: "last_edit_rowid") @Default(0) int lastEditRowId, @UnreadTypeConverter() UnreadType? unreadType, - @JsonKey(fromJson: Event.pmpFromJson) Profile? pmp, + Profile? pmp, required Content content, required Content? previousContent, }) = _Event; @@ -53,6 +48,11 @@ abstract class Event with _$Event { replyTo: getContentFromJson( json, )["m.relates_to"]?["m.in_reply_to"]?["event_id"], + pmp: json["content"]?["com.beeper.per_message_profile"] == null + ? null + : Profile.fromJsonWithCatch( + json["content"]?["com.beeper.per_message_profile"], + ), content: Content.fromEventJson( getContentFromJson(json), json["decrypted_type"] ?? json["type"], diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index cfc865f..4097759 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -441,7 +441,9 @@ class EventRenderer extends ConsumerWidget { onSecondaryTapUp: contextMenuCallback, onLongPressStart: contextMenuCallback, child: Padding( - padding: EdgeInsets.symmetric(vertical: 8), + padding: isGrouped + ? EdgeInsets.zero + : EdgeInsets.symmetric(vertical: 8), child: child, ), ); diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 7d28838..696642e 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -333,39 +333,44 @@ class RoomChat extends HookConsumerWidget { SuperSliverList.builder( listController: listController.value, itemCount: value.length, - itemBuilder: (_, index) => EventWrapper( - value[index], - EventRenderer( - value[index], - onTapReply: () async { - final replyId = value[index].replyTo; - listController.value.animateToItem( - index: value.indexWhere( - (element) => element.eventId == replyId, - ), - scrollController: scrollController, - alignment: 0.5, - duration: (_) => - Duration(milliseconds: 700), - curve: (_) => Curves.easeInOut, - ); - flashingEvent.value = replyId; - await Future.delayed( - Duration(seconds: 1), - () { - if (flashingEvent.value == replyId) { - flashingEvent.value = null; - } - }, - ); - }, - getEventOptions: getEventOptions, - // TODO: Reimplement grouping - isGrouped: false, - ), - isFlashing: - flashingEvent.value == value[index].eventId, - ), + itemBuilder: (_, index) { + final event = value[index]; + final previousEvent = value.getOrNull(index - 1); + return EventWrapper( + event, + EventRenderer( + event, + onTapReply: () async { + final replyId = event.replyTo; + listController.value.animateToItem( + index: value.indexWhere( + (element) => element.eventId == replyId, + ), + scrollController: scrollController, + alignment: 0.5, + duration: (_) => + Duration(milliseconds: 700), + curve: (_) => Curves.easeInOut, + ); + flashingEvent.value = replyId; + await Future.delayed( + Duration(seconds: 1), + () { + if (flashingEvent.value == replyId) { + flashingEvent.value = null; + } + }, + ); + }, + getEventOptions: getEventOptions, + isGrouped: + "${event.sender}${event.pmp?.id}" == + "${previousEvent?.sender}${previousEvent?.pmp?.id}", + ), + isFlashing: + flashingEvent.value == event.eventId, + ); + }, ), ], ), -- 2.53.0 From 922c624d4e1f1e9a1967ab39b7e0bf4609b4726e Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 22:57:04 -0400 Subject: [PATCH 076/108] some indentation fixes in event renderer --- lib/widgets/renderers/event.dart | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 4097759..8d39581 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -435,17 +435,17 @@ class EventRenderer extends ConsumerWidget { ? textOnly ? Text("Unknown event type", style: errorStyle) : SizedBox.shrink() - : textOnly - ? child - : GestureDetector( - onSecondaryTapUp: contextMenuCallback, - onLongPressStart: contextMenuCallback, - child: Padding( - padding: isGrouped - ? EdgeInsets.zero - : EdgeInsets.symmetric(vertical: 8), - child: child, - ), - ); + : (textOnly + ? child + : GestureDetector( + onSecondaryTapUp: contextMenuCallback, + onLongPressStart: contextMenuCallback, + child: Padding( + padding: isGrouped + ? EdgeInsets.zero + : EdgeInsets.symmetric(vertical: 8), + child: child, + ), + )); } } -- 2.53.0 From 81aead26cc0cb7ba0d92382643a2ed6d595673df Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 23:02:40 -0400 Subject: [PATCH 077/108] fix grouping logic --- lib/widgets/room_chat.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 696642e..191cfcf 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -335,7 +335,7 @@ class RoomChat extends HookConsumerWidget { itemCount: value.length, itemBuilder: (_, index) { final event = value[index]; - final previousEvent = value.getOrNull(index - 1); + final previousEvent = value.getOrNull(index + 1); return EventWrapper( event, EventRenderer( @@ -364,8 +364,12 @@ class RoomChat extends HookConsumerWidget { }, getEventOptions: getEventOptions, isGrouped: + previousEvent?.content + is MessageContent && + event.redactedBy == null && + event.relationType != "m.replace" && "${event.sender}${event.pmp?.id}" == - "${previousEvent?.sender}${previousEvent?.pmp?.id}", + "${previousEvent?.sender}${previousEvent?.pmp?.id}", ), isFlashing: flashingEvent.value == event.eventId, -- 2.53.0 From 3aec4c308035f1ef5a3e7a71305a8c9266df2ad8 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 23:03:34 -0400 Subject: [PATCH 078/108] lower padding for groups --- lib/widgets/renderers/event.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 8d39581..63ef885 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -443,7 +443,7 @@ class EventRenderer extends ConsumerWidget { child: Padding( padding: isGrouped ? EdgeInsets.zero - : EdgeInsets.symmetric(vertical: 8), + : EdgeInsets.only(top: 8), child: child, ), )); -- 2.53.0 From d746f4077825debe9e346003855a62180f05c509 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 23:44:02 -0400 Subject: [PATCH 079/108] fix nix build --- linux/nix/pkg/default.nix | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index 2d92a08..717f1c1 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -1,6 +1,8 @@ { lib, callPackage, + mpv-unwrapped, + libass, libclang, flutter, src, @@ -17,6 +19,11 @@ flutter.buildFlutterApplication { packageRun build_runner build ''; + buildInputs = [ + mpv-unwrapped + libass + ]; + env.LIBCLANG_PATH = lib.makeLibraryPath [ libclang ]; autoPubspecLock = src + "/pubspec.lock"; @@ -24,8 +31,8 @@ flutter.buildFlutterApplication { gitHashes = { window_size = "sha256-XelNtp7tpZ91QCEcvewVphNUtgQX7xrp5QP0oFo6DgM="; dynamic_system_colors = "sha256-GInPqU7r4Kj7+CNBQnf95u0BiagOUI6EtcW0A18pfd0="; - flutter_link_previewer = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk="; emoji_text_field = "sha256-3TOys09EP2GRo6pUBGPXaqBlE39O2Cmwt42Hs1cTDKo="; + linkify = "sha256-mxV/XHLxF9cn7sUPr2SUNjVmDr5lbxkuGCbNdyiZi2c="; }; postInstall = '' -- 2.53.0 From f085d04f67a50afdbc27e755ae78e8cdd14d613c Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 20 May 2026 10:16:52 -0400 Subject: [PATCH 080/108] Fix showing link previews in reply preview --- lib/widgets/renderers/event.dart | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 63ef885..fa4c774 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -266,7 +266,7 @@ class EventRenderer extends ConsumerWidget { ), ), - if (!textOnly) + if (!textOnly) ...[ if (event.content case ImageMessageContent( :final url, @@ -371,16 +371,17 @@ class EventRenderer extends ConsumerWidget { style: errorStyle, ), }, - if (event.lastEditRowId != 0 && !textOnly) - Text( - "(edited)", - style: theme.textTheme.labelSmall, - ), - if (linkify(body).firstWhereOrNull( - (element) => element is UrlElement, - ) - case final UrlElement link?) - LinkPreview(link.url), + if (event.lastEditRowId != 0) + Text( + "(edited)", + style: theme.textTheme.labelSmall, + ), + if (linkify(body).firstWhereOrNull( + (element) => element is UrlElement, + ) + case final UrlElement link?) + LinkPreview(link.url), + ], ], ), MessageContent(:final body) => Row( -- 2.53.0 From 8d3b94bc40d99fff425be55ebdf80f9c355c4ebc Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 20 May 2026 10:17:49 -0400 Subject: [PATCH 081/108] fix showing the wrong user in reply preview --- lib/widgets/renderers/event.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index fa4c774..23bd025 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -153,10 +153,10 @@ class EventRenderer extends ConsumerWidget { AsyncLoading(:final value?) => Row( spacing: 8, children: [ - MessageAvatar(event, height: 24), + MessageAvatar(value, height: 24), Flexible( child: MessageDisplayname( - event, + value, style: TextStyle( color: theme.colorScheme.primary, -- 2.53.0 From ffdcc89de0e4c35b9a47b439b20f113d8c534626 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 20 May 2026 10:20:17 -0400 Subject: [PATCH 082/108] fix incorrectly ellipsised messages --- lib/widgets/renderers/event.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 23bd025..50fdcd6 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -252,7 +252,9 @@ class EventRenderer extends ConsumerWidget { style: textStyle, text: body, maxLines: maxLines, - overflow: TextOverflow.ellipsis, + overflow: maxLines == null + ? null + : TextOverflow.ellipsis, options: LinkifyOptions( humanize: false, ), -- 2.53.0 From 5a9e29be34e95fbea17aa46b8f6c722f81ca19ee Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 20 May 2026 10:22:09 -0400 Subject: [PATCH 083/108] ignore pointer for reply preview --- lib/widgets/renderers/event.dart | 43 ++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 50fdcd6..bb46f10 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -150,28 +150,33 @@ class EventRenderer extends ConsumerWidget { ), )) { AsyncData(:final value?) || - AsyncLoading(:final value?) => Row( - spacing: 8, - children: [ - MessageAvatar(value, height: 24), - Flexible( - child: MessageDisplayname( - value, - style: TextStyle( - color: - theme.colorScheme.primary, - fontWeight: FontWeight.bold, + AsyncLoading( + :final value?, + ) => IgnorePointer( + child: Row( + spacing: 8, + children: [ + MessageAvatar(value, height: 24), + Flexible( + child: MessageDisplayname( + value, + style: TextStyle( + color: theme + .colorScheme + .primary, + fontWeight: FontWeight.bold, + ), ), ), - ), - Expanded( - child: EventRenderer( - value, - textOnly: true, - maxLines: 1, + Expanded( + child: EventRenderer( + value, + textOnly: true, + maxLines: 1, + ), ), - ), - ], + ], + ), ), AsyncError _ => Text( "An error occurred while fetching the reply", -- 2.53.0 From 740ab2fb9f76c84d50f2180d6481fe094707fbd1 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 20 May 2026 10:26:33 -0400 Subject: [PATCH 084/108] fix passing an mxc to expandableimage --- lib/widgets/room_appbar.dart | 12 +++++++++++- lib/widgets/user_popover.dart | 11 ++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/widgets/room_appbar.dart b/lib/widgets/room_appbar.dart index 52cf0ec..3930686 100644 --- a/lib/widgets/room_appbar.dart +++ b/lib/widgets/room_appbar.dart @@ -1,7 +1,9 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/selected_room_controller.dart"; +import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/expandable_image.dart"; @@ -29,7 +31,15 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { ? room == null ? null : ExpandableImage( - room.metadata?.avatar?.toString(), + room.metadata?.avatar + ?.mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value!.homeserverUrl!, + ), + ), + ) + .toString(), child: AvatarOrHash( room.metadata?.avatar, room.metadata?.name ?? "Unnamed Rooms", diff --git a/lib/widgets/user_popover.dart b/lib/widgets/user_popover.dart index 508a038..31bd814 100644 --- a/lib/widgets/user_popover.dart +++ b/lib/widgets/user_popover.dart @@ -9,6 +9,7 @@ import "package:nexus/controllers/profile_controller.dart"; import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/models/configs/power_level_config.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; @@ -91,7 +92,15 @@ class UserPopover extends ConsumerWidget { runSpacing: 8, children: [ ExpandableImage( - member.avatarUrl?.toString(), + member.avatarUrl + ?.mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value!.homeserverUrl!, + ), + ), + ) + .toString(), child: AvatarOrHash( member.avatarUrl, member.displayName ?? userId.localpart, -- 2.53.0 From 17a1af0b730ec8d4e6a0c0391e1c4f1544cadd14 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 20 May 2026 10:35:13 -0400 Subject: [PATCH 085/108] change reply card color to not match message card --- lib/widgets/renderers/event.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index bb46f10..33eb66d 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -133,7 +133,7 @@ class EventRenderer extends ConsumerWidget { if (!textOnly && event.replyTo != null) Card( margin: EdgeInsets.only(bottom: 8), - color: theme.colorScheme.surfaceContainer, + color: theme.colorScheme.surfaceContainerHigh, child: InkWell( onTap: onTapReply, child: Padding( -- 2.53.0 From fc6ca5b4549965230e8d023abe011307457c4d4b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 20 May 2026 10:38:10 -0400 Subject: [PATCH 086/108] Make message format an enum --- lib/models/content/message.dart | 20 +++++++++++++------- lib/widgets/renderers/event.dart | 3 +-- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index c431324..b5e308c 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -15,28 +15,28 @@ abstract class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.text") factory MessageContent.text({ required String body, - String? format, + MessageFormat? format, String? formattedBody, }) = TextMessageContent; @FreezedUnionValue("m.notice") factory MessageContent.notice({ required String body, - String? format, + MessageFormat? format, String? formattedBody, }) = NoticeMessageContent; @FreezedUnionValue("m.emote") factory MessageContent.emote({ required String body, - String? format, + MessageFormat? format, String? formattedBody, }) = EmoteMessageContent; @FreezedUnionValue("m.image") factory MessageContent.image({ required String body, - String? format, + MessageFormat? format, String? formattedBody, // EncryptedFile? file String? filename, @@ -47,7 +47,7 @@ abstract class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.file") factory MessageContent.file({ required String body, - String? format, + MessageFormat? format, String? formattedBody, // EncryptedFile? file String? filename, @@ -58,7 +58,7 @@ abstract class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.audio") factory MessageContent.audio({ required String body, - String? format, + MessageFormat? format, String? formattedBody, // EncryptedFile? file String? filename, @@ -69,7 +69,7 @@ abstract class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.video") factory MessageContent.video({ required String body, - String? format, + MessageFormat? format, String? formattedBody, // EncryptedFile? file String? filename, @@ -84,3 +84,9 @@ abstract class MessageContent extends Content with _$MessageContent { factory MessageContent.fromJson(Map json) => _$MessageContentFromJson(json); } + +@JsonEnum() +enum MessageFormat { + @JsonValue("org.matrix.custom.html") + html, +} diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 33eb66d..bfccb2b 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -231,8 +231,7 @@ class EventRenderer extends ConsumerWidget { ) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - format == "org.matrix.custom.html" && - !textOnly + format == MessageFormat.html && !textOnly ? Html( textStyle: textStyle, formattedBody!.replaceAllMapped( -- 2.53.0 From e59505bd6ef2dbda5579b83eee07eee23043d238 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 20 May 2026 10:41:18 -0400 Subject: [PATCH 087/108] Make room type into an enum --- lib/models/content/create.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/models/content/create.dart b/lib/models/content/create.dart index f20a713..6921c04 100644 --- a/lib/models/content/create.dart +++ b/lib/models/content/create.dart @@ -19,13 +19,19 @@ abstract class CreateContent extends Content with _$CreateContent { @JsonKey(name: "m.federate") @Default(true) bool federated, @Default("1") String roomVersion, - String? type, + @JsonKey(unknownEnumValue: RoomType.room) RoomType? type, }) = _CreateContent; factory CreateContent.fromJson(Map json) => _$CreateContentFromJson(json); } +enum RoomType { + room, + @JsonValue("m.space") + space, +} + @freezed abstract class PreviousRoom with _$PreviousRoom { const factory PreviousRoom({required String roomId}) = _PreviousRoom; -- 2.53.0 From 0653961f9cba1088bba52e18470431b02fb7dd37 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 20 May 2026 11:07:49 -0400 Subject: [PATCH 088/108] reactive members controller, better caching, fixes #6, fixes #7 --- lib/controllers/event_controller.dart | 4 +- lib/controllers/members_controller.dart | 49 ++++++++++++++++------- lib/controllers/room_chat_controller.dart | 38 +++++------------- lib/controllers/rooms_controller.dart | 30 ++++++++++++++ lib/models/room.dart | 2 + lib/widgets/room_chat.dart | 4 +- 6 files changed, 82 insertions(+), 45 deletions(-) diff --git a/lib/controllers/event_controller.dart b/lib/controllers/event_controller.dart index 8dfb356..e195f4d 100644 --- a/lib/controllers/event_controller.dart +++ b/lib/controllers/event_controller.dart @@ -11,7 +11,9 @@ class EventController extends AsyncNotifier { @override Future build() async { - final room = ref.watch(RoomsController.provider)[request.roomId]; + final room = ref.watch( + RoomsController.provider.select((value) => value[request.roomId]), + ); final event = room?.events.firstWhereOrNull( (event) => event.eventId == request.eventId, ); diff --git a/lib/controllers/members_controller.dart b/lib/controllers/members_controller.dart index 91da138..0386776 100644 --- a/lib/controllers/members_controller.dart +++ b/lib/controllers/members_controller.dart @@ -1,6 +1,8 @@ +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_controller.dart"; +import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/event.dart"; @@ -13,28 +15,47 @@ class MembersController extends AsyncNotifier> { SelectedRoomController.provider.select( (value) => value?.metadata == null ? null - : (value!.metadata!.id, value.metadata!.hasMemberList), + : ( + value!.metadata!.id, + value.metadata!.hasMemberList, + value.hasFetchedMembers, + ), ), ); if (data == null) return const IList.empty(); - final state = await ref - .watch(ClientController.provider.notifier) - .getRoomState( - GetRoomStateRequest( - roomId: data.$1, - fetchMembers: data.$2 == false, - includeMembers: true, - ), - ); + if (!data.$3) { + final fetchedState = await ref + .watch(ClientController.provider.notifier) + .getRoomState( + GetRoomStateRequest( + roomId: data.$1, + fetchMembers: data.$2 == false, + includeMembers: true, + ), + ); - return state - .where((state) => state.type == EventType.membership.type) - .toIList(); + ref + .read(RoomsController.provider.notifier) + .addState(data.$1, fetchedState, isMembers: true); + } + + final room = ref.watch( + RoomsController.provider.select((value) => value[data.$1]), + ); + + return room?.state[EventType.membership.type]?.values + .map( + (rowId) => + room.events.firstWhereOrNull((event) => event.rowId == rowId), + ) + .nonNulls + .toIList() ?? + const IList.empty(); } static final provider = - AsyncNotifierProvider>( + AsyncNotifierProvider.autoDispose>( MembersController.new, ); } diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index a13a801..64fbfd7 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -23,39 +23,19 @@ class RoomChatController extends AsyncNotifier> { @override Future> build() async { final client = ref.watch(ClientController.provider.notifier); - - final state = await client.getRoomState( - GetRoomStateRequest(roomId: roomId), - ); - - ref - .read(RoomsController.provider.notifier) - .update( - { - roomId: Room( - events: state, - state: state.fold( - const IMap.empty(), - (previousValue, stateEvent) => previousValue.add( - stateEvent.type, - (previousValue[stateEvent.type] ?? const IMap.empty()).addAll( - IMap({ - if (stateEvent.stateKey != null) - stateEvent.stateKey!: stateEvent.rowId, - }), - ), - ), - ), - ), - }.toIMap(), - const ISet.empty(), - ); - final room = ref.watch( RoomsController.provider.select((rooms) => rooms[roomId]), ); if (room == null) return const IList.empty(); + if (!room.hasFetchedState) { + final state = await client.getRoomState( + GetRoomStateRequest(roomId: roomId), + ); + + ref.read(RoomsController.provider.notifier).addState(roomId, state); + } + // While there are under 30 messages, try up to load more messages until there's no more or we have 20 messages. if (room.hasMore && room.timeline.length < 30) { loadOlder(); @@ -98,7 +78,7 @@ class RoomChatController extends AsyncNotifier> { PaginateRequest( roomId: roomId, maxTimelineId: ref - .read(RoomsController.provider)[roomId] + .read(RoomsController.provider.select((value) => value[roomId])) ?.timeline .firstOrNull ?.timelineRowId, diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart index 69117f3..40f5627 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -1,6 +1,7 @@ import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/models/read_receipt.dart"; import "package:nexus/models/room.dart"; @@ -8,6 +9,30 @@ class RoomsController extends Notifier> { @override IMap build() => const IMap.empty(); + void addState(String roomId, IList state, {bool isMembers = false}) => + update( + { + roomId: Room( + events: state, + hasFetchedState: true, + hasFetchedMembers: isMembers, + state: state.fold( + const IMap.empty(), + (previousValue, stateEvent) => previousValue.add( + stateEvent.type, + (previousValue[stateEvent.type] ?? const IMap.empty()).addAll( + IMap({ + if (stateEvent.stateKey != null) + stateEvent.stateKey!: stateEvent.rowId, + }), + ), + ), + ), + ), + }.toIMap(), + const ISet.empty(), + ); + void update(IMap rooms, ISet leftRooms) { final merged = rooms.entries.fold(state, (acc, entry) { final roomId = entry.key; @@ -34,6 +59,11 @@ class RoomsController extends Notifier> { ), ), ), + reset: false, + hasFetchedMembers: + incoming.hasFetchedMembers || existing.hasFetchedMembers, + hasFetchedState: + incoming.hasFetchedState || existing.hasFetchedState, timeline: (incoming.reset ? incoming.timeline diff --git a/lib/models/room.dart b/lib/models/room.dart index 3c3eec0..98dd6da 100644 --- a/lib/models/room.dart +++ b/lib/models/room.dart @@ -12,6 +12,8 @@ abstract class Room with _$Room { @JsonKey(name: "meta") RoomMetadata? metadata, @Default(IList.empty()) IList timeline, @Default(false) bool reset, + @Default(false) bool hasFetchedState, + @Default(false) bool hasFetchedMembers, @Default(IMap.empty()) IMap> state, // required IMap accountData, @Default(IList.empty()) IList events, diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 191cfcf..b8e4349 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -80,7 +80,9 @@ class RoomChat extends HookConsumerWidget { if (!scrollController.position.atEdge) return; if (scrollController.position.pixels == 0) { - final room = ref.watch(RoomsController.provider)[roomId]; + final room = ref.watch( + RoomsController.provider.select((value) => value[roomId]), + ); if (room != null) client.markRead(room); } else { await notifier.loadOlder(); -- 2.53.0 From df5040e06c1148ffa1b8e7758c04bdaee4d19042 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 20 May 2026 12:33:14 -0400 Subject: [PATCH 089/108] remove selected room/space controllers --- lib/controllers/key_controller.dart | 8 +-- ...dart => members_by_status_controller.dart} | 18 +++--- lib/controllers/members_controller.dart | 39 +++++-------- lib/controllers/power_level_controller.dart | 7 ++- lib/controllers/profile_controller.dart | 6 +- lib/controllers/selected_room_controller.dart | 24 -------- .../selected_space_controller.dart | 22 ------- lib/controllers/user_controller.dart | 12 ---- .../configs/members_by_status_config.dart | 15 +++++ lib/models/configs/power_level_config.dart | 19 ++++-- lib/pages/chat_page.dart | 25 ++++---- lib/widgets/composer/chat_box.dart | 10 +++- lib/widgets/composer/mention_overlay.dart | 14 ++++- lib/widgets/member_list.dart | 10 +++- lib/widgets/room_appbar.dart | 8 ++- lib/widgets/room_chat.dart | 58 ++++++++++++------- lib/widgets/sidebar.dart | 6 +- lib/widgets/user_popover.dart | 9 ++- lib/widgets/wrappers/reaction_row.dart | 1 - 19 files changed, 153 insertions(+), 158 deletions(-) rename lib/controllers/{members_by_type_controller.dart => members_by_status_controller.dart} (57%) delete mode 100644 lib/controllers/selected_room_controller.dart delete mode 100644 lib/controllers/selected_space_controller.dart create mode 100644 lib/models/configs/members_by_status_config.dart diff --git a/lib/controllers/key_controller.dart b/lib/controllers/key_controller.dart index 946892e..59d49ca 100644 --- a/lib/controllers/key_controller.dart +++ b/lib/controllers/key_controller.dart @@ -12,14 +12,14 @@ class KeyController extends Notifier { String? build() => ref.watch(SharedPrefsController.provider).requireValue.getString(key); - Future set(String? id) async { + Future set(String? value) async { final prefs = ref.watch(SharedPrefsController.provider).requireValue; - state = id; + state = value; - if (id == null) { + if (value == null) { prefs.remove(key); } else { - prefs.setString(key, id); + prefs.setString(key, value); } } diff --git a/lib/controllers/members_by_type_controller.dart b/lib/controllers/members_by_status_controller.dart similarity index 57% rename from lib/controllers/members_by_type_controller.dart rename to lib/controllers/members_by_status_controller.dart index c96dc27..44b9c54 100644 --- a/lib/controllers/members_by_type_controller.dart +++ b/lib/controllers/members_by_status_controller.dart @@ -1,21 +1,21 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/members_controller.dart"; +import "package:nexus/models/configs/members_by_status_config.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/models/membership_status.dart"; -class MembersByTypeController extends AsyncNotifier> { - final MembershipStatus filterStatus; - MembersByTypeController(this.filterStatus); +class MembersByStatusController extends AsyncNotifier> { + final MembersByStatusConfig config; + MembersByStatusController(this.config); @override Future> build() => ref.watch( - MembersController.provider.selectAsync( + MembersController.provider(config.roomId).selectAsync( (members) => members .where( (membership) => switch (membership.content) { - MembershipContent(:final status) => filterStatus == status, + MembershipContent(:final status) => config.status == status, _ => false, }, ) @@ -25,8 +25,8 @@ class MembersByTypeController extends AsyncNotifier> { static final provider = AsyncNotifierProvider.family< - MembersByTypeController, + MembersByStatusController, IList, - MembershipStatus - >(MembersByTypeController.new); + MembersByStatusConfig + >(MembersByStatusController.new); } diff --git a/lib/controllers/members_controller.dart b/lib/controllers/members_controller.dart index 0386776..757968f 100644 --- a/lib/controllers/members_controller.dart +++ b/lib/controllers/members_controller.dart @@ -3,48 +3,39 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_room_state_request.dart"; class MembersController extends AsyncNotifier> { + final String roomId; + MembersController(this.roomId); + @override Future> build() async { - final data = ref.watch( - SelectedRoomController.provider.select( - (value) => value?.metadata == null - ? null - : ( - value!.metadata!.id, - value.metadata!.hasMemberList, - value.hasFetchedMembers, - ), - ), + final room = ref.watch( + RoomsController.provider.select((value) => value[roomId]), ); - if (data == null) return const IList.empty(); - if (!data.$3) { + if (room == null) return const IList.empty(); + + if (!room.hasFetchedMembers) { final fetchedState = await ref .watch(ClientController.provider.notifier) .getRoomState( GetRoomStateRequest( - roomId: data.$1, - fetchMembers: data.$2 == false, + roomId: roomId, + fetchMembers: room.metadata?.hasMemberList ?? true, includeMembers: true, ), ); ref .read(RoomsController.provider.notifier) - .addState(data.$1, fetchedState, isMembers: true); + .addState(roomId, fetchedState, isMembers: true); } - final room = ref.watch( - RoomsController.provider.select((value) => value[data.$1]), - ); - - return room?.state[EventType.membership.type]?.values + return room.state[EventType.membership.type]?.values .map( (rowId) => room.events.firstWhereOrNull((event) => event.rowId == rowId), @@ -54,8 +45,6 @@ class MembersController extends AsyncNotifier> { const IList.empty(); } - static final provider = - AsyncNotifierProvider.autoDispose>( - MembersController.new, - ); + static final provider = AsyncNotifierProvider.autoDispose + .family, String>(MembersController.new); } diff --git a/lib/controllers/power_level_controller.dart b/lib/controllers/power_level_controller.dart index d751378..9ebfcc2 100644 --- a/lib/controllers/power_level_controller.dart +++ b/lib/controllers/power_level_controller.dart @@ -1,7 +1,7 @@ import "package:collection/collection.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; +import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/models/configs/power_level_config.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/power_levels.dart"; @@ -20,7 +20,10 @@ class PowerLevelController extends Notifier { ); } - final room = ref.watch(SelectedRoomController.provider); + final room = ref.watch( + RoomsController.provider.select((value) => value[config.roomId]), + ); + final event = room?.events.firstWhereOrNull( (event) => event.rowId == room.state[EventType.powerLevels.type]?[""], ); diff --git a/lib/controllers/profile_controller.dart b/lib/controllers/profile_controller.dart index 120d4e4..6131034 100644 --- a/lib/controllers/profile_controller.dart +++ b/lib/controllers/profile_controller.dart @@ -12,6 +12,8 @@ class ProfileController extends AsyncNotifier { return client.getProfile(userId); } - static final provider = AsyncNotifierProvider.autoDispose - .family(ProfileController.new); + static final provider = + AsyncNotifierProvider.family( + ProfileController.new, + ); } diff --git a/lib/controllers/selected_room_controller.dart b/lib/controllers/selected_room_controller.dart deleted file mode 100644 index ffba78c..0000000 --- a/lib/controllers/selected_room_controller.dart +++ /dev/null @@ -1,24 +0,0 @@ -import "package:collection/collection.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/selected_space_controller.dart"; -import "package:nexus/models/room.dart"; - -class SelectedRoomController extends Notifier { - @override - Room? build() { - final space = ref.watch(SelectedSpaceController.provider); - final selectedRoomId = ref.watch( - KeyController.provider(KeyController.roomKey), - ); - - return space.children.firstWhereOrNull( - (room) => room.metadata?.id == selectedRoomId, - ) ?? - space.children.firstOrNull; - } - - static final provider = NotifierProvider( - SelectedRoomController.new, - ); -} diff --git a/lib/controllers/selected_space_controller.dart b/lib/controllers/selected_space_controller.dart deleted file mode 100644 index dbeb71f..0000000 --- a/lib/controllers/selected_space_controller.dart +++ /dev/null @@ -1,22 +0,0 @@ -import "package:collection/collection.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/spaces_controller.dart"; -import "package:nexus/models/space.dart"; - -class SelectedSpaceController extends Notifier { - @override - Space build() { - final spaces = ref.watch(SpacesController.provider); - final selectedSpaceId = ref.watch( - KeyController.provider(KeyController.spaceKey), - ); - - return spaces.firstWhereOrNull((space) => space.id == selectedSpaceId) ?? - spaces.first; - } - - static final provider = NotifierProvider( - SelectedSpaceController.new, - ); -} diff --git a/lib/controllers/user_controller.dart b/lib/controllers/user_controller.dart index e7f5fe0..16bfea7 100644 --- a/lib/controllers/user_controller.dart +++ b/lib/controllers/user_controller.dart @@ -13,18 +13,6 @@ class UserController extends AsyncNotifier { @override Future build() async { - final member = await ref.watch( - MembersController.provider.selectAsync( - (value) => value.firstWhereOrNull( - (membership) => membership.stateKey == userId, - ), - ), - ); - - if (member?.content case final MembershipContent content) { - return content; - } - final profile = await ref.watch(ProfileController.provider(userId).future); return MembershipContent( status: MembershipStatus.leave, diff --git a/lib/models/configs/members_by_status_config.dart b/lib/models/configs/members_by_status_config.dart new file mode 100644 index 0000000..8aef586 --- /dev/null +++ b/lib/models/configs/members_by_status_config.dart @@ -0,0 +1,15 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/membership_status.dart"; +part "members_by_status_config.freezed.dart"; +part "members_by_status_config.g.dart"; + +@freezed +abstract class MembersByStatusConfig with _$MembersByStatusConfig { + const factory MembersByStatusConfig({ + required String roomId, + required MembershipStatus status, + }) = _MembersByStatusConfig; + + factory MembersByStatusConfig.fromJson(Map json) => + _$MembersByStatusConfigFromJson(json); +} diff --git a/lib/models/configs/power_level_config.dart b/lib/models/configs/power_level_config.dart index 2ae7804..197e171 100644 --- a/lib/models/configs/power_level_config.dart +++ b/lib/models/configs/power_level_config.dart @@ -5,17 +5,24 @@ part "power_level_config.freezed.dart"; @freezed sealed class PowerLevelConfig with _$PowerLevelConfig { - const factory PowerLevelConfig({required EventType eventType}) = - EventPowerLevelConfig; + const factory PowerLevelConfig({ + required EventType eventType, + required String roomId, + }) = EventPowerLevelConfig; const factory PowerLevelConfig.membershipAction({ required MembershipAction action, required String targetUser, + required String roomId, }) = MembershipActionPowerLevelConfig; - const factory PowerLevelConfig.state({required EventType eventType}) = - StatePowerLevelConfig; + const factory PowerLevelConfig.state({ + required EventType eventType, + required String roomId, + }) = StatePowerLevelConfig; - const factory PowerLevelConfig.redaction({required String targetUser}) = - RedactionPowerLevelConfig; + const factory PowerLevelConfig.redaction({ + required String targetUser, + required String roomId, + }) = RedactionPowerLevelConfig; } diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index a8ec584..3d6a35f 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -1,6 +1,7 @@ import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/init_complete_controller.dart"; +import "package:nexus/controllers/key_controller.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/sidebar.dart"; import "package:nexus/widgets/room_chat.dart"; @@ -15,22 +16,22 @@ class ChatPage extends ConsumerWidget { final isDesktop = constraints.maxWidth > 650; final showMembersByDefault = constraints.maxWidth > 1000; final initComplete = ref.watch(InitCompleteController.provider); + final roomId = ref.watch(KeyController.provider(KeyController.roomKey)); return Scaffold( appBar: initComplete ? null : Appbar(), body: initComplete - ? Builder( - builder: (context) => Row( - children: [ - if (isDesktop) Sidebar(isDesktop: isDesktop), - Expanded( - child: RoomChat( - isDesktop: isDesktop, - showMembersByDefault: showMembersByDefault, - ), + ? Row( + children: [ + if (isDesktop) Sidebar(isDesktop: isDesktop), + Expanded( + child: RoomChat( + roomId: roomId, + isDesktop: isDesktop, + showMembersByDefault: showMembersByDefault, ), - ], - ), + ), + ], ) : Center( child: Column( diff --git a/lib/widgets/composer/chat_box.dart b/lib/widgets/composer/chat_box.dart index 71033f9..4cb3835 100644 --- a/lib/widgets/composer/chat_box.dart +++ b/lib/widgets/composer/chat_box.dart @@ -13,6 +13,7 @@ import "package:nexus/widgets/composer/relation_preview.dart"; import "package:nexus/widgets/emoji_picker_button.dart"; class ChatBox extends HookConsumerWidget { + final String roomId; final Event? relatedEvent; final RelationType relationType; final VoidCallback onDismiss; @@ -23,7 +24,8 @@ class ChatBox extends HookConsumerWidget { required IList tags, }) onSend; - const ChatBox({ + const ChatBox( + this.roomId, { required this.relatedEvent, required this.relationType, required this.onDismiss, @@ -88,7 +90,10 @@ class ChatBox extends HookConsumerWidget { children: ref.watch( PowerLevelController.provider( - PowerLevelConfig(eventType: EventType.message), + PowerLevelConfig( + eventType: EventType.message, + roomId: roomId, + ), ), ) ? [ @@ -125,6 +130,7 @@ class ChatBox extends HookConsumerWidget { child: FlutterTagger( triggerStrategy: TriggerStrategy.eager, overlay: MentionOverlay( + roomId, query: query.value, triggerCharacter: triggerCharacter.value, addTag: ({required id, required name}) { diff --git a/lib/widgets/composer/mention_overlay.dart b/lib/widgets/composer/mention_overlay.dart index b303ea1..b4af97a 100644 --- a/lib/widgets/composer/mention_overlay.dart +++ b/lib/widgets/composer/mention_overlay.dart @@ -1,10 +1,11 @@ import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/members_by_type_controller.dart"; +import "package:nexus/controllers/members_by_status_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/via_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/models/configs/members_by_status_config.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; @@ -13,8 +14,10 @@ import "package:nexus/widgets/loading.dart"; class MentionOverlay extends ConsumerWidget { final String? triggerCharacter; final String query; + final String roomId; final void Function({required String id, required String name}) addTag; - const MentionOverlay({ + const MentionOverlay( + this.roomId, { required this.query, required this.addTag, required this.triggerCharacter, @@ -36,7 +39,12 @@ class MentionOverlay extends ConsumerWidget { "@" => ref .watch( - MembersByTypeController.provider(MembershipStatus.join), + MembersByStatusController.provider( + MembersByStatusConfig( + roomId: roomId, + status: MembershipStatus.join, + ), + ), ) .betterWhen( data: (members) => ListView( diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart index a59146c..6ee322e 100644 --- a/lib/widgets/member_list.dart +++ b/lib/widgets/member_list.dart @@ -1,22 +1,26 @@ import "package:flutter/material.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/members_by_type_controller.dart"; +import "package:nexus/controllers/members_by_status_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/configs/members_by_status_config.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; class MemberList extends HookConsumerWidget { - const MemberList({super.key}); + final String roomId; + const MemberList(this.roomId, {super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final status = useState(MembershipStatus.join); final membersProvider = ref.watch( - MembersByTypeController.provider(status.value), + MembersByStatusController.provider( + MembersByStatusConfig(roomId: roomId, status: status.value), + ), ); return Drawer( diff --git a/lib/widgets/room_appbar.dart b/lib/widgets/room_appbar.dart index 3930686..a77e101 100644 --- a/lib/widgets/room_appbar.dart +++ b/lib/widgets/room_appbar.dart @@ -2,7 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; +import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; @@ -13,7 +13,9 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { final bool isDesktop; final void Function(BuildContext context)? onOpenMemberList; final void Function(BuildContext context) onOpenDrawer; + final String? roomId; const RoomAppbar({ + required this.roomId, required this.isDesktop, required this.onOpenDrawer, this.onOpenMemberList, @@ -25,7 +27,9 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final room = ref.watch(SelectedRoomController.provider); + final room = roomId == null + ? null + : ref.watch(RoomsController.provider.select((value) => value[roomId!])); return Appbar( leading: isDesktop ? room == null diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index b8e4349..550bd60 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -8,7 +8,6 @@ import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/power_level_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/controllers/via_controller.dart"; import "package:nexus/models/configs/power_level_config.dart"; @@ -32,7 +31,9 @@ import "package:super_sliver_list/super_sliver_list.dart"; class RoomChat extends HookConsumerWidget { final bool isDesktop; final bool showMembersByDefault; + final String? roomId; const RoomChat({ + required this.roomId, required this.isDesktop, required this.showMembersByDefault, super.key, @@ -47,15 +48,12 @@ class RoomChat extends HookConsumerWidget { final memberListOpened = useState(showMembersByDefault); final userId = ref.watch(ClientStateController.provider)?.userId; - final roomId = ref.watch( - SelectedRoomController.provider.select((value) => value?.metadata?.id), - ); - final theme = Theme.of(context); - if (roomId == null || userId == null) { + if (userId == null || this.roomId == null) { return Scaffold( appBar: RoomAppbar( + roomId: this.roomId, isDesktop: isDesktop, onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), onOpenMemberList: null, @@ -69,6 +67,8 @@ class RoomChat extends HookConsumerWidget { ); } + final roomId = this.roomId!; + final controllerProvider = RoomChatController.provider(roomId); final notifier = ref.watch(controllerProvider.notifier); @@ -76,18 +76,25 @@ class RoomChat extends HookConsumerWidget { final listController = useRef(ListController()); final scrollController = useScrollController(); - scrollController.addListener(() async { - if (!scrollController.position.atEdge) return; - if (scrollController.position.pixels == 0) { - final room = ref.watch( - RoomsController.provider.select((value) => value[roomId]), - ); - if (room != null) client.markRead(room); - } else { - await notifier.loadOlder(); + useEffect(() { + Future listener() async { + if (!scrollController.position.atEdge) return; + + if (scrollController.position.pixels == 0) { + context.mounted; + final room = ref.watch( + RoomsController.provider.select((value) => value[roomId]), + ); + if (room != null) client.markRead(room); + } else { + await notifier.loadOlder(); + } } - }); + + scrollController.addListener(listener); + return () => scrollController.removeListener(listener); + }, [roomId]); final composerNode = useFocusNode( onKeyEvent: (_, event) { @@ -107,7 +114,7 @@ class RoomChat extends HookConsumerWidget { return [ if (ref.watch( PowerLevelController.provider( - PowerLevelConfig(eventType: EventType.reaction), + PowerLevelConfig(eventType: EventType.reaction, roomId: roomId), ), )) PopupMenuItem( @@ -156,7 +163,7 @@ class RoomChat extends HookConsumerWidget { ), if (ref.watch( PowerLevelController.provider( - PowerLevelConfig(eventType: EventType.message), + PowerLevelConfig(eventType: EventType.message, roomId: roomId), ), )) PopupMenuItem( @@ -178,7 +185,9 @@ class RoomChat extends HookConsumerWidget { ), PopupMenuItem( onTap: () async { - final room = ref.watch(SelectedRoomController.provider); + final room = ref.watch( + RoomsController.provider.select((value) => value[roomId]), + ); if (room == null) return; final vias = ref.watch(ViaController.provider(room)); @@ -194,7 +203,10 @@ class RoomChat extends HookConsumerWidget { ), if (ref.watch( PowerLevelController.provider( - PowerLevelConfig.redaction(targetUser: event.sender), + PowerLevelConfig.redaction( + targetUser: event.sender, + roomId: roomId, + ), ), )) PopupMenuItem( @@ -308,6 +320,7 @@ class RoomChat extends HookConsumerWidget { return Scaffold( appBar: RoomAppbar( + roomId: roomId, isDesktop: isDesktop, onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), onOpenMemberList: (thisContext) { @@ -387,6 +400,7 @@ class RoomChat extends HookConsumerWidget { ), ), ChatBox( + roomId, node: composerNode, onSend: (text, {required shouldMention, required tags}) => notifier @@ -407,11 +421,11 @@ class RoomChat extends HookConsumerWidget { ), if (memberListOpened.value == true && showMembersByDefault) - MemberList(), + MemberList(roomId), ], ), - endDrawer: showMembersByDefault ? null : MemberList(), + endDrawer: showMembersByDefault ? null : MemberList(roomId), ); } } diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart index e200801..8afe3c5 100644 --- a/lib/widgets/sidebar.dart +++ b/lib/widgets/sidebar.dart @@ -1,7 +1,7 @@ +import "package:collection/collection.dart"; import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/selected_space_controller.dart"; import "package:nexus/controllers/spaces_controller.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/join_dialog.dart"; @@ -31,7 +31,9 @@ class Sidebar extends HookConsumerWidget { ); final selectedIndex = indexOfSelected == -1 ? 0 : indexOfSelected; - final selectedSpace = ref.watch(SelectedSpaceController.provider); + final selectedSpace = + spaces.firstWhereOrNull((space) => space.id == selectedSpaceId) ?? + spaces.first; final indexOfSelectedRoom = selectedSpace.children.indexWhere( (room) => room.metadata?.id == selectedRoomId, diff --git a/lib/widgets/user_popover.dart b/lib/widgets/user_popover.dart index 31bd814..207d723 100644 --- a/lib/widgets/user_popover.dart +++ b/lib/widgets/user_popover.dart @@ -6,7 +6,6 @@ import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/power_level_controller.dart"; import "package:nexus/controllers/profile_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; @@ -23,16 +22,14 @@ import "package:nexus/widgets/form_text_input.dart"; class UserPopover extends ConsumerWidget { final MembershipContent member; final String userId; - const UserPopover(this.member, this.userId, {super.key}); + final String? roomId; + const UserPopover(this.member, this.userId, {this.roomId, super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final textTheme = theme.textTheme; final client = ref.watch(ClientController.provider.notifier); - final roomId = ref.watch( - SelectedRoomController.provider.select((room) => room?.metadata?.id), - ); void showMembershipDialog(MembershipAction action) => showDialog( context: context, @@ -164,6 +161,7 @@ class UserPopover extends ConsumerWidget { PowerLevelController.provider( PowerLevelConfig.membershipAction( action: MembershipAction.kick, + roomId: roomId!, targetUser: userId, ), ), @@ -185,6 +183,7 @@ class UserPopover extends ConsumerWidget { if (ref.watch( PowerLevelController.provider( PowerLevelConfig.membershipAction( + roomId: roomId!, action: MembershipAction.ban, targetUser: userId, ), diff --git a/lib/widgets/wrappers/reaction_row.dart b/lib/widgets/wrappers/reaction_row.dart index 5786134..30ebf1c 100644 --- a/lib/widgets/wrappers/reaction_row.dart +++ b/lib/widgets/wrappers/reaction_row.dart @@ -5,7 +5,6 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/cross_cache_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/main.dart"; -- 2.53.0 From 6e0dd8c33d541d9dce394245f98729bcb41e885e Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 20 May 2026 13:27:00 -0400 Subject: [PATCH 090/108] update readme --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2c44fc8..3fa433a 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,6 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. ## Progress -- [x] New logo -- [x] Move from the Dart SDK to the Gomuks Backend with Dart bindings: https://git.federated.nexus/Nexus/nexus/pulls/2 - - [ ] Allow using remote Gomuks over websocket - [ ] Platform Support - [x] Linux - [ ] Windows (WIP) @@ -42,7 +39,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [x] `matrix:` Uri - [x] Matrix.to link - [ ] From space - - [ ] Exploring + - [ ] From directory - [x] Leaving - [x] Subspaces - [x] Messages @@ -116,6 +113,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [ ] Settings - [ ] Matrix: URIs vs Matrix.to links - [ ] Light/Dark mode + - [ ] Remote Gomuks instance - [ ] SSD or CSD - [ ] Align your message bubbles to left or right - [ ] Show media by default -- 2.53.0 From ccd8513cdee086e63e27c82c19264b385796a8f4 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 20 May 2026 13:28:42 -0400 Subject: [PATCH 091/108] reorganize files --- lib/widgets/{wrappers => }/event_wrapper.dart | 2 +- lib/widgets/{wrappers => }/reaction_row.dart | 0 lib/widgets/room_chat.dart | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename lib/widgets/{wrappers => }/event_wrapper.dart (95%) rename lib/widgets/{wrappers => }/reaction_row.dart (100%) diff --git a/lib/widgets/wrappers/event_wrapper.dart b/lib/widgets/event_wrapper.dart similarity index 95% rename from lib/widgets/wrappers/event_wrapper.dart rename to lib/widgets/event_wrapper.dart index c032d52..ba26e6a 100644 --- a/lib/widgets/wrappers/event_wrapper.dart +++ b/lib/widgets/event_wrapper.dart @@ -1,6 +1,6 @@ import "package:flutter/material.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/widgets/wrappers/reaction_row.dart"; +import "package:nexus/widgets/reaction_row.dart"; class EventWrapper extends StatelessWidget { final Event event; diff --git a/lib/widgets/wrappers/reaction_row.dart b/lib/widgets/reaction_row.dart similarity index 100% rename from lib/widgets/wrappers/reaction_row.dart rename to lib/widgets/reaction_row.dart diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 550bd60..98135c1 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -21,7 +21,7 @@ import "package:nexus/widgets/emoji_picker_button.dart"; import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/room_appbar.dart"; -import "package:nexus/widgets/wrappers/event_wrapper.dart"; +import "package:nexus/widgets/event_wrapper.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/main.dart"; -- 2.53.0 From cff580dee2fd35b4393b69c7a5e1bb3f06e8a757 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 20 May 2026 16:06:17 -0400 Subject: [PATCH 092/108] general fixups, plus adding colors for names --- lib/controllers/author_controller.dart | 5 +- lib/controllers/user_controller.dart | 33 +++++++-- lib/main.dart | 4 +- lib/models/configs/user_config.dart | 12 ++++ lib/widgets/composer/relation_preview.dart | 32 ++------- lib/widgets/event_preview.dart | 36 ++++++++++ lib/widgets/html/html.dart | 9 ++- lib/widgets/html/mention_chip.dart | 10 ++- lib/widgets/lazy_loading/message_avatar.dart | 2 +- .../lazy_loading/message_displayname.dart | 13 +++- lib/widgets/member_list.dart | 25 +++++-- lib/widgets/renderers/event.dart | 58 ++++----------- lib/widgets/renderers/membership.dart | 71 +++++++++++-------- pubspec.lock | 8 +++ 14 files changed, 196 insertions(+), 122 deletions(-) create mode 100644 lib/models/configs/user_config.dart create mode 100644 lib/widgets/event_preview.dart diff --git a/lib/controllers/author_controller.dart b/lib/controllers/author_controller.dart index 8499775..70070e1 100644 --- a/lib/controllers/author_controller.dart +++ b/lib/controllers/author_controller.dart @@ -1,6 +1,7 @@ import "dart:async"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/user_controller.dart"; +import "package:nexus/models/configs/user_config.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; @@ -11,7 +12,9 @@ class AuthorController extends AsyncNotifier { @override Future build() async { final member = await ref.watch( - UserController.provider(event.sender).future, + UserController.provider( + UserConfig(roomId: event.roomId, userId: event.sender), + ).future, ); return MembershipContent( diff --git a/lib/controllers/user_controller.dart b/lib/controllers/user_controller.dart index 16bfea7..5a47ba6 100644 --- a/lib/controllers/user_controller.dart +++ b/lib/controllers/user_controller.dart @@ -4,25 +4,44 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/members_controller.dart"; import "package:nexus/controllers/profile_controller.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/models/configs/user_config.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; class UserController extends AsyncNotifier { - final String userId; - UserController(this.userId); + final UserConfig config; + UserController(this.config); @override Future build() async { - final profile = await ref.watch(ProfileController.provider(userId).future); + final member = config.roomId == null + ? null + : await ref.watch( + MembersController.provider(config.roomId!).selectAsync( + (value) => value.firstWhereOrNull( + (membership) => membership.stateKey == config.userId, + ), + ), + ); + + if (member?.content case final MembershipContent content) { + return content; + } + + final profile = await ref.watch( + ProfileController.provider(config.userId).future, + ); return MembershipContent( status: MembershipStatus.leave, avatarUrl: profile.avatarUrl, - displayName: profile.displayName ?? userId.localpart, + displayName: profile.displayName ?? config.userId.localpart, ); } static final provider = - AsyncNotifierProvider.family( - UserController.new, - ); + AsyncNotifierProvider.family< + UserController, + MembershipContent, + UserConfig + >(UserController.new); } diff --git a/lib/main.dart b/lib/main.dart index b687ebd..834aeef 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -67,8 +67,8 @@ void main() async { await windowManager.setMinimumSize(Size.square(500)); } - FlutterError.onError = (FlutterErrorDetails details) => - showError(details.exception.toString(), details.stack); + // FlutterError.onError = (FlutterErrorDetails details) => + // showError(details.exception.toString(), details.stack); runApp( ProviderScope( diff --git a/lib/models/configs/user_config.dart b/lib/models/configs/user_config.dart new file mode 100644 index 0000000..4f3f8ff --- /dev/null +++ b/lib/models/configs/user_config.dart @@ -0,0 +1,12 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +part "user_config.freezed.dart"; +part "user_config.g.dart"; + +@freezed +abstract class UserConfig with _$UserConfig { + const factory UserConfig({required String? roomId, required String userId}) = + _UserConfig; + + factory UserConfig.fromJson(Map json) => + _$UserConfigFromJson(json); +} diff --git a/lib/widgets/composer/relation_preview.dart b/lib/widgets/composer/relation_preview.dart index 028e412..f2bcaf6 100644 --- a/lib/widgets/composer/relation_preview.dart +++ b/lib/widgets/composer/relation_preview.dart @@ -2,9 +2,7 @@ import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/renderers/event.dart"; -import "package:nexus/widgets/lazy_loading/message_avatar.dart"; -import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/event_preview.dart"; class RelationPreview extends ConsumerWidget { final Event? relatedEvent; @@ -29,7 +27,7 @@ class RelationPreview extends ConsumerWidget { return Container( color: theme.colorScheme.surfaceContainerHigh, - padding: EdgeInsets.symmetric(horizontal: 8), + padding: EdgeInsets.symmetric(horizontal: 12), child: Row( spacing: 8, children: [ @@ -39,30 +37,10 @@ class RelationPreview extends ConsumerWidget { style: TextStyle(fontWeight: FontWeight.bold), ), - MessageAvatar(relatedEvent!), - Expanded( - child: Row( - spacing: 8, - children: [ - Flexible( - child: MessageDisplayname( - relatedEvent!, - style: theme.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - Expanded( - child: IgnorePointer( - child: EventRenderer( - relatedEvent!, - textOnly: true, - maxLines: 1, - ), - ), - ), - ], + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: EventPreview(relatedEvent!), ), ), diff --git a/lib/widgets/event_preview.dart b/lib/widgets/event_preview.dart new file mode 100644 index 0000000..df289bb --- /dev/null +++ b/lib/widgets/event_preview.dart @@ -0,0 +1,36 @@ +import "package:flutter/material.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/lazy_loading/message_avatar.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/renderers/event.dart"; + +class EventPreview extends StatelessWidget { + final Event event; + const EventPreview(this.event, {super.key}); + + @override + Widget build(BuildContext context) => IgnorePointer( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 4), + child: Row( + spacing: 12, + children: [ + if (event.content is MessageContent) MessageAvatar(event), + + Expanded( + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 4, + runSpacing: 2, + children: [ + if (event.content is MessageContent) MessageDisplayname(event), + EventRenderer(event, textOnly: true, maxLines: 1), + ], + ), + ), + ], + ), + ), + ); +} diff --git a/lib/widgets/html/html.dart b/lib/widgets/html/html.dart index e889aff..449f400 100644 --- a/lib/widgets/html/html.dart +++ b/lib/widgets/html/html.dart @@ -17,8 +17,9 @@ import "package:nexus/widgets/html/quoted.dart"; class Html extends ConsumerWidget { final String html; + final String? roomId; final TextStyle? textStyle; - const Html(this.html, {this.textStyle, super.key}); + const Html(this.html, {this.roomId, this.textStyle, super.key}); @override Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( @@ -59,13 +60,15 @@ class Html extends ConsumerWidget { ) : null, - "blockquote" => Quoted(Html(element.innerHtml)), + "blockquote" => Quoted( + Html(element.innerHtml, textStyle: textStyle, roomId: roomId), + ), "a" => element.attributes["href"]?.mention == null ? null : InlineCustomWidget( - child: MentionChip(element.attributes["href"]!), + child: MentionChip(element.attributes["href"]!, roomId), ), "img" => diff --git a/lib/widgets/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart index 059b997..4791ed8 100644 --- a/lib/widgets/html/mention_chip.dart +++ b/lib/widgets/html/mention_chip.dart @@ -3,17 +3,23 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/user_controller.dart"; import "package:nexus/helpers/extensions/link_to_mention.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/configs/user_config.dart"; class MentionChip extends ConsumerWidget { + final String? roomId; final String content; - const MentionChip(this.content, {super.key}); + const MentionChip(this.content, this.roomId, {super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final mention = content.mention; final membership = mention?.startsWith("@") == true ? ref - .watch(UserController.provider(mention!)) + .watch( + UserController.provider( + UserConfig(roomId: roomId, userId: mention!), + ), + ) .whenOrNull(data: (data) => data) : null; diff --git a/lib/widgets/lazy_loading/message_avatar.dart b/lib/widgets/lazy_loading/message_avatar.dart index 529edaa..215bb35 100644 --- a/lib/widgets/lazy_loading/message_avatar.dart +++ b/lib/widgets/lazy_loading/message_avatar.dart @@ -10,7 +10,7 @@ import "package:nexus/widgets/avatar_or_hash.dart"; class MessageAvatar extends ConsumerWidget { final Event event; final double height; - const MessageAvatar(this.event, {this.height = 16, super.key}); + const MessageAvatar(this.event, {this.height = 24, super.key}); @override Widget build(BuildContext context, WidgetRef ref) => ref diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart index 9c6abd1..e388fe7 100644 --- a/lib/widgets/lazy_loading/message_displayname.dart +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -1,3 +1,4 @@ +import "package:color_hash/color_hash.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/author_controller.dart"; @@ -31,7 +32,17 @@ class MessageDisplayname extends ConsumerWidget { : null, child: Text( "${membership.displayName ?? event.sender.localpart}${event.pmp == null ? "" : " (via ${event.sender})"}", - style: style, + style: + style ?? + TextStyle( + color: ColorHash( + event.sender, + lightness: .7, + saturation: .7, + ).color, + fontWeight: FontWeight.bold, + ), + maxLines: 1, overflow: TextOverflow.ellipsis, ), ), diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart index 6ee322e..d943f8a 100644 --- a/lib/widgets/member_list.dart +++ b/lib/widgets/member_list.dart @@ -1,14 +1,16 @@ +import "package:color_hash/color_hash.dart"; import "package:flutter/material.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/members_by_status_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/models/configs/members_by_status_config.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/loading.dart"; class MemberList extends HookConsumerWidget { final String roomId; @@ -63,10 +65,14 @@ class MemberList extends HookConsumerWidget { ), ], ), - membersProvider.betterWhen( - data: (members) => Expanded( + switch (membersProvider) { + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + AsyncData(:final value) || AsyncLoading(:final value?) => Expanded( child: ListView( - children: members + children: value .map( (member) => switch (member.content) { MembershipContent( @@ -87,6 +93,14 @@ class MemberList extends HookConsumerWidget { title: Text( displayName ?? member.stateKey!.localpart, overflow: TextOverflow.ellipsis, + style: TextStyle( + color: ColorHash( + member.stateKey!, + lightness: .7, + saturation: .8, + ).color, + fontWeight: FontWeight.bold, + ), ), subtitle: Text( member.stateKey!, @@ -100,7 +114,8 @@ class MemberList extends HookConsumerWidget { .toList(), ), ), - ), + AsyncLoading _ => Loading(), + }, ], ), ); diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index bfccb2b..0a9cf73 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -19,6 +19,7 @@ import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_event_request.dart"; +import "package:nexus/widgets/event_preview.dart"; import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/html/html.dart"; import "package:nexus/widgets/lazy_loading/message_avatar.dart"; @@ -59,6 +60,8 @@ class EventRenderer extends ConsumerWidget { message: event.timestamp.toString(), child: Text( format(event.timestamp), + maxLines: 1, + overflow: TextOverflow.ellipsis, style: theme.textTheme.labelSmall?.copyWith(color: Colors.grey), ), ); @@ -83,6 +86,7 @@ class EventRenderer extends ConsumerWidget { ), MessageContent() || EncryptedContent() => Row( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, spacing: 8, children: [ if (!textOnly) @@ -90,7 +94,7 @@ class EventRenderer extends ConsumerWidget { SizedBox(width: 40) else MessageAvatar(event, height: 40), - Expanded( + Flexible( child: Column( spacing: 4, crossAxisAlignment: CrossAxisAlignment.start, @@ -99,16 +103,14 @@ class EventRenderer extends ConsumerWidget { Row( spacing: 4, children: [ - Flexible( - child: MessageDisplayname( - event, - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - Flexible(child: timestamp), + Flexible(child: MessageDisplayname(event)), + Flexible(flex: 0, child: timestamp), ], ), Card( + margin: textOnly + ? EdgeInsets.zero + : EdgeInsets.only(bottom: 4), color: textOnly ? Colors.transparent : ref.watch( @@ -152,32 +154,7 @@ class EventRenderer extends ConsumerWidget { AsyncData(:final value?) || AsyncLoading( :final value?, - ) => IgnorePointer( - child: Row( - spacing: 8, - children: [ - MessageAvatar(value, height: 24), - Flexible( - child: MessageDisplayname( - value, - style: TextStyle( - color: theme - .colorScheme - .primary, - fontWeight: FontWeight.bold, - ), - ), - ), - Expanded( - child: EventRenderer( - value, - textOnly: true, - maxLines: 1, - ), - ), - ], - ), - ), + ) => EventPreview(value), AsyncError _ => Text( "An error occurred while fetching the reply", style: errorStyle, @@ -233,6 +210,7 @@ class EventRenderer extends ConsumerWidget { children: [ format == MessageFormat.html && !textOnly ? Html( + roomId: event.roomId, textStyle: textStyle, formattedBody!.replaceAllMapped( RegExp( @@ -291,7 +269,7 @@ class EventRenderer extends ConsumerWidget { )) { final url? => ConstrainedBox( constraints: BoxConstraints.loose( - Size.fromWidth(500), + Size.square(500), ), child: switch (event.content) { VideoMessageContent( @@ -425,14 +403,8 @@ class EventRenderer extends ConsumerWidget { padding: EdgeInsets.symmetric(horizontal: 4), child: Icon(Icons.numbers), ), - MessageDisplayname( - event, - style: TextStyle( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - Text("changed the room avatar"), + Flexible(child: MessageDisplayname(event)), + Expanded(child: Text("changed the room avatar")), ], ), _ => null, diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index 5330fea..61dc582 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -19,43 +19,54 @@ class MembershipRenderer extends StatelessWidget { return switch (event.content) { MembershipContent content => Row( - spacing: 4, + spacing: 8, children: [ Padding( padding: EdgeInsets.symmetric(horizontal: 4), child: Icon(Icons.people), ), - InkWell( - onTapUp: (details) => context.showUserPopover( - content, - event.stateKey!, - globalPosition: details.globalPosition, - ), - child: Text( - content.displayName ?? event.stateKey!.localpart, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), + Expanded( + child: Wrap( + spacing: 4, + children: [ + InkWell( + onTapUp: (details) => context.showUserPopover( + content, + event.stateKey!, + globalPosition: details.globalPosition, + ), + child: Text( + overflow: TextOverflow.ellipsis, + content.displayName ?? event.stateKey!.localpart, + maxLines: 1, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + overflow: TextOverflow.ellipsis, + maxLines: 1, + "${switch (content.status) { + MembershipStatus.invite => "was invited to", + MembershipStatus.join => "joined", + MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), + MembershipStatus.ban => "was banned from", + MembershipStatus.knock => "asked to join", + }} the room${content.reason == null ? "" : "because ${content.reason}"}${event.sender == event.stateKey ? "" : " by "}", + ), + if (event.sender != event.stateKey) + MessageDisplayname( + event, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], ), ), - Text( - "${switch (content.status) { - MembershipStatus.invite => "was invited to", - MembershipStatus.join => "joined", - MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), - MembershipStatus.ban => "was banned from", - MembershipStatus.knock => "asked to join", - }} the room${content.reason == null ? "" : "because ${content.reason}"}${event.sender == event.stateKey ? "" : " by "}", - ), - if (event.sender != event.stateKey) - MessageDisplayname( - event, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), ], ), _ => SizedBox.shrink(), diff --git a/pubspec.lock b/pubspec.lock index 6d3aa22..df05714 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + boxy: + dependency: "direct main" + description: + name: boxy + sha256: "42ccafe13b2893878042acc5b7e2446025328e11a3197b0bb78db42ff76aa3f0" + url: "https://pub.dev" + source: hosted + version: "2.3.0" build: dependency: transitive description: -- 2.53.0 From d010faea4a1679f51a1d0e718ef489d281daaf19 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 20 May 2026 16:09:49 -0400 Subject: [PATCH 093/108] grammar fixes for membership rendering --- lib/widgets/renderers/membership.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index 61dc582..de8c191 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -54,7 +54,7 @@ class MembershipRenderer extends StatelessWidget { MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), MembershipStatus.ban => "was banned from", MembershipStatus.knock => "asked to join", - }} the room${content.reason == null ? "" : "because ${content.reason}"}${event.sender == event.stateKey ? "" : " by "}", + }} the room${event.sender == event.stateKey ? "" : " by "}", ), if (event.sender != event.stateKey) MessageDisplayname( @@ -64,6 +64,7 @@ class MembershipRenderer extends StatelessWidget { fontWeight: FontWeight.bold, ), ), + if (content.reason != null) Text("for \"${content.reason}\""), ], ), ), -- 2.53.0 From 8356719f8fb2d8c000f8075ef41b881b41de261e Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 20 May 2026 16:10:47 -0400 Subject: [PATCH 094/108] fix wrong colors on membership rendering --- lib/widgets/renderers/membership.dart | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index de8c191..aa0b5d1 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -1,3 +1,4 @@ +import "package:color_hash/color_hash.dart"; import "package:flutter/material.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; @@ -40,7 +41,11 @@ class MembershipRenderer extends StatelessWidget { content.displayName ?? event.stateKey!.localpart, maxLines: 1, style: TextStyle( - color: Theme.of(context).colorScheme.primary, + color: ColorHash( + event.sender, + lightness: .7, + saturation: .7, + ).color, fontWeight: FontWeight.bold, ), ), @@ -56,14 +61,7 @@ class MembershipRenderer extends StatelessWidget { MembershipStatus.knock => "asked to join", }} the room${event.sender == event.stateKey ? "" : " by "}", ), - if (event.sender != event.stateKey) - MessageDisplayname( - event, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), + if (event.sender != event.stateKey) MessageDisplayname(event), if (content.reason != null) Text("for \"${content.reason}\""), ], ), -- 2.53.0 From e76c0aac16871db065eb6261734da3a5ea998f9d Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Wed, 20 May 2026 16:30:26 -0400 Subject: [PATCH 095/108] Slightly up padding for event preview --- lib/widgets/event_preview.dart | 2 +- pubspec.lock | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/lib/widgets/event_preview.dart b/lib/widgets/event_preview.dart index df289bb..f092fd2 100644 --- a/lib/widgets/event_preview.dart +++ b/lib/widgets/event_preview.dart @@ -21,7 +21,7 @@ class EventPreview extends StatelessWidget { Expanded( child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, - spacing: 4, + spacing: 8, runSpacing: 2, children: [ if (event.content is MessageContent) MessageDisplayname(event), diff --git a/pubspec.lock b/pubspec.lock index df05714..6d3aa22 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,14 +73,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" - boxy: - dependency: "direct main" - description: - name: boxy - sha256: "42ccafe13b2893878042acc5b7e2446025328e11a3197b0bb78db42ff76aa3f0" - url: "https://pub.dev" - source: hosted - version: "2.3.0" build: dependency: transitive description: -- 2.53.0 From e00cd12bb925ab7a0294ee8b0a4289788c7184f1 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 11:24:18 -0400 Subject: [PATCH 096/108] some performance improvements --- lib/controllers/client_controller.dart | 7 +- lib/controllers/event_controller.dart | 2 +- .../members_by_status_controller.dart | 8 +-- lib/controllers/members_controller.dart | 20 +++--- lib/controllers/power_level_controller.dart | 7 +- lib/controllers/room_chat_controller.dart | 60 ++++++++-------- lib/controllers/rooms_controller.dart | 71 +++++++++---------- lib/controllers/via_controller.dart | 14 ++-- lib/models/room.dart | 45 ++++++++---- lib/widgets/room_chat.dart | 13 ++-- 10 files changed, 130 insertions(+), 117 deletions(-) diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index 3f29f25..5ccdc27 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -1,7 +1,7 @@ import "dart:ffi"; import "dart:io"; import "dart:isolate"; -import "package:collection/collection.dart"; +import "dart:math"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:ffi/ffi.dart"; import "package:flutter/foundation.dart"; @@ -237,9 +237,8 @@ class ClientController extends AsyncNotifier { _sendCommand("set_membership", request.toJson()); Future markRead(Room room) async { - final event = room.events.firstWhereOrNull( - (event) => event.rowId == room.timeline.last.eventRowId, - ); + final eventRowId = room.timeline[room.timeline.keys.reduce(max)]; + final event = eventRowId == null ? null : room.events[eventRowId]; if (event == null || room.metadata == null) return; await _sendCommand("mark_read", { diff --git a/lib/controllers/event_controller.dart b/lib/controllers/event_controller.dart index e195f4d..94992ca 100644 --- a/lib/controllers/event_controller.dart +++ b/lib/controllers/event_controller.dart @@ -14,7 +14,7 @@ class EventController extends AsyncNotifier { final room = ref.watch( RoomsController.provider.select((value) => value[request.roomId]), ); - final event = room?.events.firstWhereOrNull( + final event = room?.events.values.firstWhereOrNull( (event) => event.eventId == request.eventId, ); diff --git a/lib/controllers/members_by_status_controller.dart b/lib/controllers/members_by_status_controller.dart index 44b9c54..2b49903 100644 --- a/lib/controllers/members_by_status_controller.dart +++ b/lib/controllers/members_by_status_controller.dart @@ -5,12 +5,12 @@ import "package:nexus/models/configs/members_by_status_config.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; -class MembersByStatusController extends AsyncNotifier> { +class MembersByStatusController extends AsyncNotifier> { final MembersByStatusConfig config; MembersByStatusController(this.config); @override - Future> build() => ref.watch( + Future> build() => ref.watch( MembersController.provider(config.roomId).selectAsync( (members) => members .where( @@ -19,14 +19,14 @@ class MembersByStatusController extends AsyncNotifier> { _ => false, }, ) - .toIList(), + .toISet(), ), ); static final provider = AsyncNotifierProvider.family< MembersByStatusController, - IList, + ISet, MembersByStatusConfig >(MembersByStatusController.new); } diff --git a/lib/controllers/members_controller.dart b/lib/controllers/members_controller.dart index 757968f..64e1ef7 100644 --- a/lib/controllers/members_controller.dart +++ b/lib/controllers/members_controller.dart @@ -1,4 +1,3 @@ -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_controller.dart"; @@ -7,17 +6,17 @@ import "package:nexus/models/content/content.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_room_state_request.dart"; -class MembersController extends AsyncNotifier> { +class MembersController extends AsyncNotifier> { final String roomId; MembersController(this.roomId); @override - Future> build() async { + Future> build() async { final room = ref.watch( RoomsController.provider.select((value) => value[roomId]), ); - if (room == null) return const IList.empty(); + if (room == null) return const ISet.empty(); if (!room.hasFetchedMembers) { final fetchedState = await ref @@ -30,21 +29,18 @@ class MembersController extends AsyncNotifier> { ), ); - ref + await ref .read(RoomsController.provider.notifier) .addState(roomId, fetchedState, isMembers: true); } return room.state[EventType.membership.type]?.values - .map( - (rowId) => - room.events.firstWhereOrNull((event) => event.rowId == rowId), - ) + .map((rowId) => room.events[rowId]) .nonNulls - .toIList() ?? - const IList.empty(); + .toISet() ?? + const ISet.empty(); } static final provider = AsyncNotifierProvider.autoDispose - .family, String>(MembersController.new); + .family, String>(MembersController.new); } diff --git a/lib/controllers/power_level_controller.dart b/lib/controllers/power_level_controller.dart index 9ebfcc2..2539778 100644 --- a/lib/controllers/power_level_controller.dart +++ b/lib/controllers/power_level_controller.dart @@ -1,4 +1,3 @@ -import "package:collection/collection.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; @@ -24,9 +23,9 @@ class PowerLevelController extends Notifier { RoomsController.provider.select((value) => value[config.roomId]), ); - final event = room?.events.firstWhereOrNull( - (event) => event.rowId == room.state[EventType.powerLevels.type]?[""], - ); + final eventRowId = room?.state[EventType.powerLevels.type]?[""]; + + final event = eventRowId == null ? null : room?.events[eventRowId]; final content = event?.content is PowerLevelsContent ? event!.content : PowerLevelsContent(); diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 64fbfd7..4412b2d 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -1,4 +1,5 @@ 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"; @@ -33,7 +34,7 @@ class RoomChatController extends AsyncNotifier> { GetRoomStateRequest(roomId: roomId), ); - ref.read(RoomsController.provider.notifier).addState(roomId, state); + await ref.read(RoomsController.provider.notifier).addState(roomId, state); } // While there are under 30 messages, try up to load more messages until there's no more or we have 20 messages. @@ -41,21 +42,21 @@ class RoomChatController extends AsyncNotifier> { loadOlder(); } - return room.timeline.reversed - .map((timeline) { - final foundEvent = room.events.firstWhereOrNull( - (event) => event.rowId == timeline.eventRowId, - ); + return room.timeline + .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) + .map((entry) { + if (entry.value == null) return null; - final editedEvent = foundEvent?.lastEditRowId == 0 + final foundEvent = room.events[entry.value!]; + + final editedEvent = + foundEvent == null || foundEvent.lastEditRowId == 0 ? null - : room.events.firstWhereOrNull( - (event) => event.rowId == foundEvent?.lastEditRowId, - ); + : room.events[foundEvent.lastEditRowId]; - return foundEvent?.copyWith( - content: editedEvent?.content ?? foundEvent.content, - ); + return editedEvent == null + ? foundEvent + : foundEvent?.copyWith(content: editedEvent.content); }) .nonNulls .toIList(); @@ -72,34 +73,37 @@ class RoomChatController extends AsyncNotifier> { ); Future loadOlder() async { + final timelineKeys = ref + .read(RoomsController.provider.select((value) => value[roomId])) + ?.timeline + .keys; final response = await ref .watch(ClientController.provider.notifier) .paginate( PaginateRequest( roomId: roomId, - maxTimelineId: ref - .read(RoomsController.provider.select((value) => value[roomId])) - ?.timeline - .firstOrNull - ?.timelineRowId, + maxTimelineId: timelineKeys?.isNotEmpty == true + ? timelineKeys?.reduce(min) + : null, ), ); - ref + await ref .watch(RoomsController.provider.notifier) .update( IMap({ roomId: Room( - events: response.events.addAll(response.relatedEvents), + events: IMap.fromIterable( + response.events.addAll(response.relatedEvents), + keyMapper: (event) => event.rowId, + valueMapper: (event) => event, + ), hasMore: response.hasMore, - timeline: response.events - .map( - (event) => TimelineRowTuple( - timelineRowId: event.timelineRowId, - eventRowId: event.rowId, - ), - ) - .toIList(), + timeline: IMap.fromIterable( + response.events, + keyMapper: (event) => event.timelineRowId, + valueMapper: (event) => event.rowId, + ), ), }), const ISet.empty(), diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart index 40f5627..55a0d2b 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -1,4 +1,3 @@ -import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/models/event.dart"; @@ -9,47 +8,49 @@ class RoomsController extends Notifier> { @override IMap build() => const IMap.empty(); - void addState(String roomId, IList state, {bool isMembers = false}) => - update( - { - roomId: Room( - events: state, - hasFetchedState: true, - hasFetchedMembers: isMembers, - state: state.fold( - const IMap.empty(), - (previousValue, stateEvent) => previousValue.add( - stateEvent.type, - (previousValue[stateEvent.type] ?? const IMap.empty()).addAll( - IMap({ - if (stateEvent.stateKey != null) - stateEvent.stateKey!: stateEvent.rowId, - }), - ), - ), + Future addState( + String roomId, + IList state, { + bool isMembers = false, + }) => update( + { + roomId: Room( + events: IMap.fromEntries( + state.map((event) => MapEntry(event.rowId, event)), + ), + hasFetchedState: true, + hasFetchedMembers: isMembers, + state: state.fold( + const IMap.empty(), + (previousValue, stateEvent) => previousValue.add( + stateEvent.type, + (previousValue[stateEvent.type] ?? const IMap.empty()).addAll( + IMap({ + if (stateEvent.stateKey != null) + stateEvent.stateKey!: stateEvent.rowId, + }), ), ), - }.toIMap(), - const ISet.empty(), - ); + ), + ), + }.toIMap(), + const ISet.empty(), + ); - void update(IMap rooms, ISet leftRooms) { + Future update(IMap rooms, ISet leftRooms) async { final merged = rooms.entries.fold(state, (acc, entry) { final roomId = entry.key; final incoming = entry.value; final existing = acc[roomId]; - final events = existing?.events.updateById( - incoming.events, - (item) => item.eventId, - ); - return acc.add( roomId, existing?.copyWith( hasMore: incoming.hasMore, metadata: incoming.metadata ?? existing.metadata, - events: events!, + events: incoming.events.isEmpty + ? existing.events + : existing.events.addAll(incoming.events), state: incoming.state.entries.fold( existing.state, (previousValue, event) => previousValue.add( @@ -64,15 +65,9 @@ class RoomsController extends Notifier> { incoming.hasFetchedMembers || existing.hasFetchedMembers, hasFetchedState: incoming.hasFetchedState || existing.hasFetchedState, - timeline: - (incoming.reset - ? incoming.timeline - : existing.timeline.updateById( - incoming.timeline, - (item) => item.timelineRowId, - )) - .sortedBy((element) => element.timelineRowId) - .toIList(), + timeline: (incoming.reset + ? incoming.timeline + : existing.timeline.addAll(incoming.timeline)), receipts: incoming.receipts.entries.fold( existing.receipts, (receiptAcc, event) => receiptAcc.add( diff --git a/lib/controllers/via_controller.dart b/lib/controllers/via_controller.dart index 0c24487..69e9f4a 100644 --- a/lib/controllers/via_controller.dart +++ b/lib/controllers/via_controller.dart @@ -25,9 +25,10 @@ class ViaController extends Notifier { addUserId(ref.watch(ClientStateController.provider)?.userId); - final powerLevels = room.events.firstWhereOrNull( - (event) => event.rowId == room.state[EventType.powerLevels.type]?[""], - ); + final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""]; + final powerLevels = powerLevelsEventId == null + ? null + : room.events[powerLevelsEventId]; if (powerLevels?.content case PowerLevelsContent(:final users)) { for (final userId in users.keys) { @@ -38,9 +39,10 @@ class ViaController extends Notifier { final members = room.state[EventType.membership.type]?.values.toIList(); for (var i = 0; servers.length < 5; i++) { - final member = room.events.firstWhereOrNull( - (event) => event.rowId == members?.getOrNull(i), - ); + final membershipEventId = members?.getOrNull(i); + final member = membershipEventId == null + ? null + : room.events[membershipEventId]; if (member?.content case MembershipContent(:final status)) { if (status == MembershipStatus.join) { diff --git a/lib/models/room.dart b/lib/models/room.dart index 98dd6da..8aaadbe 100644 --- a/lib/models/room.dart +++ b/lib/models/room.dart @@ -8,31 +8,48 @@ part "room.g.dart"; @freezed abstract class Room with _$Room { + static IMap timelineTupleJsonToIMap(List json) => + IMap.fromEntries( + json.map( + (timelineTuple) => MapEntry( + timelineTuple["timeline_rowid"], + timelineTuple["event_rowid"], + ), + ), + ); + + static IMap eventsJsonToIMap(List json) => + IMap.fromEntries( + json.map((eventJson) { + final event = Event.fromJson(eventJson); + return MapEntry(event.rowId, event); + }), + ); + + /// [timeline] is an IMap of timelineRowId to eventRowId + /// [events] is an IMap of eventRowId to event const factory Room({ @JsonKey(name: "meta") RoomMetadata? metadata, - @Default(IList.empty()) IList timeline, + @Default(IMap.empty()) + @JsonKey(fromJson: Room.timelineTupleJsonToIMap) + IMap timeline, + + @Default(IMap.empty()) + @JsonKey(fromJson: Room.eventsJsonToIMap) + IMap events, + @Default(false) bool reset, @Default(false) bool hasFetchedState, @Default(false) bool hasFetchedMembers, @Default(IMap.empty()) IMap> state, - // required IMap accountData, - @Default(IList.empty()) IList events, + @Default(IMap.empty()) IMap> receipts, @Default(false) bool dismissNotifications, @Default(true) bool hasMore, + + // required IMap accountData, // required IList notifications, }) = _Room; factory Room.fromJson(Map json) => _$RoomFromJson(json); } - -@freezed -abstract class TimelineRowTuple with _$TimelineRowTuple { - const factory TimelineRowTuple({ - @JsonKey(name: "timeline_rowid") required int timelineRowId, - @JsonKey(name: "event_rowid") int? eventRowId, - }) = _TimelineRowTuple; - - factory TimelineRowTuple.fromJson(Map json) => - _$TimelineRowTupleFromJson(json); -} diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 98135c1..1bcb1ac 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -81,14 +81,15 @@ class RoomChat extends HookConsumerWidget { Future listener() async { if (!scrollController.position.atEdge) return; + final room = ref.watch( + RoomsController.provider.select((value) => value[roomId]), + ); + if (room == null) return; + if (scrollController.position.pixels == 0) { - context.mounted; - final room = ref.watch( - RoomsController.provider.select((value) => value[roomId]), - ); - if (room != null) client.markRead(room); + await client.markRead(room); } else { - await notifier.loadOlder(); + if (room.hasMore) await notifier.loadOlder(); } } -- 2.53.0 From 34e6c07d8d33a472ffe3c8b6a230a1cda310013e Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 12:16:01 -0400 Subject: [PATCH 097/108] temp isolate --- lib/controllers/rooms_controller.dart | 28 +++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart index 55a0d2b..bcee62d 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -1,3 +1,5 @@ +import "dart:isolate"; + import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/models/event.dart"; @@ -38,7 +40,11 @@ class RoomsController extends Notifier> { ); Future update(IMap rooms, ISet leftRooms) async { - final merged = rooms.entries.fold(state, (acc, entry) { + final merged = await rooms.entries.fold(Future.sync(() => state), ( + accF, + entry, + ) async { + final acc = await accF; final roomId = entry.key; final incoming = entry.value; final existing = acc[roomId]; @@ -51,15 +57,18 @@ class RoomsController extends Notifier> { events: incoming.events.isEmpty ? existing.events : existing.events.addAll(incoming.events), - state: incoming.state.entries.fold( - existing.state, - (previousValue, event) => previousValue.add( - event.key, - (previousValue[event.key] ?? const IMap.empty()).addAll( - event.value, + state: await Isolate.run(() { + final state = incoming.state.entries.fold( + existing.state, + (previousValue, event) => previousValue.add( + event.key, + (previousValue[event.key] ?? const IMap.empty()).addAll( + event.value, + ), ), - ), - ), + ); + return state; + }), reset: false, hasFetchedMembers: incoming.hasFetchedMembers || existing.hasFetchedMembers, @@ -86,7 +95,6 @@ class RoomsController extends Notifier> { merged, (acc, roomId) => acc.remove(roomId), ); - state = prunedList; } -- 2.53.0 From 57cfad9f45b60c5f7bbe3d3c286a3694d0580793 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 12:16:07 -0400 Subject: [PATCH 098/108] Revert "temp isolate" This reverts commit 34e6c07d8d33a472ffe3c8b6a230a1cda310013e. --- lib/controllers/rooms_controller.dart | 28 ++++++++++----------------- 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart index bcee62d..55a0d2b 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -1,5 +1,3 @@ -import "dart:isolate"; - import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/models/event.dart"; @@ -40,11 +38,7 @@ class RoomsController extends Notifier> { ); Future update(IMap rooms, ISet leftRooms) async { - final merged = await rooms.entries.fold(Future.sync(() => state), ( - accF, - entry, - ) async { - final acc = await accF; + final merged = rooms.entries.fold(state, (acc, entry) { final roomId = entry.key; final incoming = entry.value; final existing = acc[roomId]; @@ -57,18 +51,15 @@ class RoomsController extends Notifier> { events: incoming.events.isEmpty ? existing.events : existing.events.addAll(incoming.events), - state: await Isolate.run(() { - final state = incoming.state.entries.fold( - existing.state, - (previousValue, event) => previousValue.add( - event.key, - (previousValue[event.key] ?? const IMap.empty()).addAll( - event.value, - ), + state: incoming.state.entries.fold( + existing.state, + (previousValue, event) => previousValue.add( + event.key, + (previousValue[event.key] ?? const IMap.empty()).addAll( + event.value, ), - ); - return state; - }), + ), + ), reset: false, hasFetchedMembers: incoming.hasFetchedMembers || existing.hasFetchedMembers, @@ -95,6 +86,7 @@ class RoomsController extends Notifier> { merged, (acc, roomId) => acc.remove(roomId), ); + state = prunedList; } -- 2.53.0 From 13c2a4062b8be2ec802ac5e485128cc8f3478b36 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 12:26:41 -0400 Subject: [PATCH 099/108] make it a little more efficient --- lib/controllers/room_chat_controller.dart | 2 +- lib/controllers/rooms_controller.dart | 29 +++++++++++++---------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 4412b2d..1f0fe2c 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -88,7 +88,7 @@ class RoomChatController extends AsyncNotifier> { ), ); - await ref + ref .watch(RoomsController.provider.notifier) .update( IMap({ diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart index 55a0d2b..382fac4 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -1,3 +1,5 @@ +import "dart:isolate"; + import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/models/event.dart"; @@ -12,7 +14,7 @@ class RoomsController extends Notifier> { String roomId, IList state, { bool isMembers = false, - }) => update( + }) async => update( { roomId: Room( events: IMap.fromEntries( @@ -20,24 +22,25 @@ class RoomsController extends Notifier> { ), hasFetchedState: true, hasFetchedMembers: isMembers, - state: state.fold( - const IMap.empty(), - (previousValue, stateEvent) => previousValue.add( - stateEvent.type, - (previousValue[stateEvent.type] ?? const IMap.empty()).addAll( - IMap({ - if (stateEvent.stateKey != null) - stateEvent.stateKey!: stateEvent.rowId, - }), + state: await Isolate.run(() { + final newState = state.fold( + const IMap>.empty(), + (previousValue, stateEvent) => previousValue.add( + stateEvent.type, + (previousValue[stateEvent.type] ?? const IMap.empty()).add( + stateEvent.stateKey!, + stateEvent.rowId, + ), ), - ), - ), + ); + return newState; + }), ), }.toIMap(), const ISet.empty(), ); - Future update(IMap rooms, ISet leftRooms) async { + void update(IMap rooms, ISet leftRooms) { final merged = rooms.entries.fold(state, (acc, entry) { final roomId = entry.key; final incoming = entry.value; -- 2.53.0 From 1834ae2c5b0ddbd126361e41f6543efb64c8627f Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 12:35:31 -0400 Subject: [PATCH 100/108] fix timeline sorting --- lib/controllers/room_chat_controller.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 1f0fe2c..2b6810b 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -43,7 +43,7 @@ class RoomChatController extends AsyncNotifier> { } return room.timeline - .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) + .toEntryIList(compare: (a, b) => (b?.key ?? 0).compareTo(a?.key ?? 0)) .map((entry) { if (entry.value == null) return null; -- 2.53.0 From fd5eaa27258ce4edeb40b92ad048798d1113bc0b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 12:40:26 -0400 Subject: [PATCH 101/108] fix audio player size --- lib/widgets/players/audio.dart | 65 ++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/lib/widgets/players/audio.dart b/lib/widgets/players/audio.dart index a851035..f75afe5 100644 --- a/lib/widgets/players/audio.dart +++ b/lib/widgets/players/audio.dart @@ -61,39 +61,42 @@ class AudioPlayer extends HookConsumerWidget { return "$minutes:$seconds"; } - return Card( - color: Theme.of(context).colorScheme.surfaceContainer, - child: Padding( - padding: EdgeInsetsGeometry.only(left: 8, right: 16), - child: Row( - children: [ - IconButton( - onPressed: player.playOrPause, - icon: Icon( - playing.value ? Icons.pause_circle : Icons.play_circle, + return SizedBox( + height: 60, + child: Card( + color: Theme.of(context).colorScheme.surfaceContainer, + child: Padding( + padding: EdgeInsetsGeometry.only(left: 8, right: 16), + child: Row( + children: [ + IconButton( + onPressed: player.playOrPause, + icon: Icon( + playing.value ? Icons.pause_circle : Icons.play_circle, + ), ), - ), - SizedBox(width: 8), - Text( - format(position.value), - style: Theme.of(context).textTheme.bodySmall, - ), - Expanded( - child: Slider( - min: 0, - max: duration.value.inMilliseconds <= 0 - ? 1 - : duration.value.inMilliseconds.toDouble(), - value: position.value.inMilliseconds.toDouble(), - onChanged: (value) => - player.seek(Duration(milliseconds: value.toInt())), + SizedBox(width: 8), + Text( + format(position.value), + style: Theme.of(context).textTheme.bodySmall, ), - ), - Text( - format(duration.value), - style: Theme.of(context).textTheme.bodySmall, - ), - ], + Expanded( + child: Slider( + min: 0, + max: duration.value.inMilliseconds <= 0 + ? 1 + : duration.value.inMilliseconds.toDouble(), + value: position.value.inMilliseconds.toDouble(), + onChanged: (value) => + player.seek(Duration(milliseconds: value.toInt())), + ), + ), + Text( + format(duration.value), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), ), ), ); -- 2.53.0 From 7850117cb6113e4ec9671db3aed258c14106eb1f Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 12:49:01 -0400 Subject: [PATCH 102/108] abstract ColorHash into its own extension --- lib/helpers/extensions/string_to_color.dart | 6 ++++++ lib/widgets/lazy_loading/message_displayname.dart | 8 ++------ lib/widgets/member_list.dart | 8 ++------ lib/widgets/renderers/membership.dart | 8 ++------ 4 files changed, 12 insertions(+), 18 deletions(-) create mode 100644 lib/helpers/extensions/string_to_color.dart diff --git a/lib/helpers/extensions/string_to_color.dart b/lib/helpers/extensions/string_to_color.dart new file mode 100644 index 0000000..8d30e76 --- /dev/null +++ b/lib/helpers/extensions/string_to_color.dart @@ -0,0 +1,6 @@ +import "package:color_hash/color_hash.dart"; +import "package:flutter/material.dart"; + +extension ToColor on String { + Color get colorHash => ColorHash(this, lightness: .7, saturation: .7).color; +} diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart index e388fe7..b1c1460 100644 --- a/lib/widgets/lazy_loading/message_displayname.dart +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -1,10 +1,10 @@ -import "package:color_hash/color_hash.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/author_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/helpers/extensions/string_to_color.dart"; import "package:nexus/models/event.dart"; class MessageDisplayname extends ConsumerWidget { @@ -35,11 +35,7 @@ class MessageDisplayname extends ConsumerWidget { style: style ?? TextStyle( - color: ColorHash( - event.sender, - lightness: .7, - saturation: .7, - ).color, + color: event.sender.colorHash, fontWeight: FontWeight.bold, ), maxLines: 1, diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart index d943f8a..e5d41d7 100644 --- a/lib/widgets/member_list.dart +++ b/lib/widgets/member_list.dart @@ -1,10 +1,10 @@ -import "package:color_hash/color_hash.dart"; import "package:flutter/material.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/members_by_status_controller.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/helpers/extensions/string_to_color.dart"; import "package:nexus/models/configs/members_by_status_config.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; @@ -94,11 +94,7 @@ class MemberList extends HookConsumerWidget { displayName ?? member.stateKey!.localpart, overflow: TextOverflow.ellipsis, style: TextStyle( - color: ColorHash( - member.stateKey!, - lightness: .7, - saturation: .8, - ).color, + color: member.stateKey!.colorHash, fontWeight: FontWeight.bold, ), ), diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index aa0b5d1..8e9e22e 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -1,7 +1,7 @@ -import "package:color_hash/color_hash.dart"; import "package:flutter/material.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/helpers/extensions/string_to_color.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/membership_status.dart"; @@ -41,11 +41,7 @@ class MembershipRenderer extends StatelessWidget { content.displayName ?? event.stateKey!.localpart, maxLines: 1, style: TextStyle( - color: ColorHash( - event.sender, - lightness: .7, - saturation: .7, - ).color, + color: event.sender.colorHash, fontWeight: FontWeight.bold, ), ), -- 2.53.0 From 49d480d1e6ef7f5ebe24bee54f568d559fb842cb Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 13:11:04 -0400 Subject: [PATCH 103/108] remove extra backslash that was breaking link regex --- lib/widgets/renderers/event.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 0a9cf73..d2dea9a 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -214,7 +214,7 @@ class EventRenderer extends ConsumerWidget { textStyle: textStyle, formattedBody!.replaceAllMapped( RegExp( - r"(]*>.*?<\/a>)|(\\bhttps?:\/\/[^\s<]+)", + r"(]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)", caseSensitive: false, dotAll: true, ), -- 2.53.0 From e4f091cb0f482b1a9b10f5d1add37e0e5da1a1e9 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 14:07:21 -0400 Subject: [PATCH 104/108] Add GenericEventRenderer --- lib/widgets/renderers/event.dart | 20 +++---- lib/widgets/renderers/generic_event.dart | 22 ++++++++ lib/widgets/renderers/membership.dart | 71 ++++++++++-------------- 3 files changed, 60 insertions(+), 53 deletions(-) create mode 100644 lib/widgets/renderers/generic_event.dart diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index d2dea9a..855168e 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -29,6 +29,7 @@ import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/players/video.dart"; import "package:nexus/widgets/players/audio.dart"; import "package:nexus/widgets/renderers/membership.dart"; +import "package:nexus/widgets/renderers/generic_event.dart"; import "package:nexus/widgets/file_card.dart"; import "package:timeago/timeago.dart"; import "package:flutter_linkify/flutter_linkify.dart"; @@ -396,17 +397,14 @@ class EventRenderer extends ConsumerWidget { content.status ? null : MembershipRenderer(event), - AvatarContent() => Row( - spacing: 4, - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 4), - child: Icon(Icons.numbers), - ), - Flexible(child: MessageDisplayname(event)), - Expanded(child: Text("changed the room avatar")), - ], - ), + AvatarContent() => GenericEventRenderer(Icons.numbers, [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Icon(Icons.numbers), + ), + Flexible(child: MessageDisplayname(event)), + Expanded(child: Text("changed the room avatar")), + ]), _ => null, }; diff --git a/lib/widgets/renderers/generic_event.dart b/lib/widgets/renderers/generic_event.dart new file mode 100644 index 0000000..0046e33 --- /dev/null +++ b/lib/widgets/renderers/generic_event.dart @@ -0,0 +1,22 @@ +import "package:flutter/material.dart"; + +class GenericEventRenderer extends StatelessWidget { + final IconData icon; + final List children; + const GenericEventRenderer(this.icon, this.children, {super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: EdgeInsets.only(bottom: 8), + child: Row( + spacing: 8, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Icon(Icons.people), + ), + Expanded(child: Wrap(spacing: 4, children: children)), + ], + ), + ); +} diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index 8e9e22e..9012ba2 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -6,6 +6,7 @@ import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/membership_status.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/renderers/generic_event.dart"; class MembershipRenderer extends StatelessWidget { final Event event; @@ -19,51 +20,37 @@ class MembershipRenderer extends StatelessWidget { ); return switch (event.content) { - MembershipContent content => Row( - spacing: 8, - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 4), - child: Icon(Icons.people), + MembershipContent content => GenericEventRenderer(Icons.people, [ + InkWell( + onTapUp: (details) => context.showUserPopover( + content, + event.stateKey!, + globalPosition: details.globalPosition, ), - Expanded( - child: Wrap( - spacing: 4, - children: [ - InkWell( - onTapUp: (details) => context.showUserPopover( - content, - event.stateKey!, - globalPosition: details.globalPosition, - ), - child: Text( - overflow: TextOverflow.ellipsis, - content.displayName ?? event.stateKey!.localpart, - maxLines: 1, - style: TextStyle( - color: event.sender.colorHash, - fontWeight: FontWeight.bold, - ), - ), - ), - Text( - overflow: TextOverflow.ellipsis, - maxLines: 1, - "${switch (content.status) { - MembershipStatus.invite => "was invited to", - MembershipStatus.join => "joined", - MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), - MembershipStatus.ban => "was banned from", - MembershipStatus.knock => "asked to join", - }} the room${event.sender == event.stateKey ? "" : " by "}", - ), - if (event.sender != event.stateKey) MessageDisplayname(event), - if (content.reason != null) Text("for \"${content.reason}\""), - ], + child: Text( + overflow: TextOverflow.ellipsis, + content.displayName ?? event.stateKey!.localpart, + maxLines: 1, + style: TextStyle( + color: event.sender.colorHash, + fontWeight: FontWeight.bold, ), ), - ], - ), + ), + Text( + overflow: TextOverflow.ellipsis, + maxLines: 1, + "${switch (content.status) { + MembershipStatus.invite => "was invited to", + MembershipStatus.join => "joined", + MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), + MembershipStatus.ban => "was banned from", + MembershipStatus.knock => "asked to join", + }} the room${event.sender == event.stateKey ? "" : " by "}", + ), + if (event.sender != event.stateKey) MessageDisplayname(event), + if (content.reason != null) Text("for \"${content.reason}\""), + ]), _ => SizedBox.shrink(), }; } -- 2.53.0 From 7016cc4205797166e9cfd6f164becd2c09cadb07 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 14:17:01 -0400 Subject: [PATCH 105/108] change wording on verify page message -> event --- lib/pages/verify_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pages/verify_page.dart b/lib/pages/verify_page.dart index 962701c..387c640 100644 --- a/lib/pages/verify_page.dart +++ b/lib/pages/verify_page.dart @@ -21,7 +21,7 @@ class VerifyPage extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "Enter your recovery key or passphrase below to unlock encrypted messages.\nYour passphrase is usually not the same as your password.", + "Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.", ), SizedBox(height: 12), FormTextInput( -- 2.53.0 From a28592d11e74d9841cb72330ab43c6f359844782 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 14:19:51 -0400 Subject: [PATCH 106/108] change algorithm for deciding when to load more messages --- lib/controllers/room_chat_controller.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 2b6810b..a750e53 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -6,6 +6,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:fluttertagger/fluttertagger.dart"; import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; +import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_related_events_request.dart"; @@ -37,8 +38,14 @@ class RoomChatController extends AsyncNotifier> { await ref.read(RoomsController.provider.notifier).addState(roomId, state); } - // While there are under 30 messages, try up to load more messages until there's no more or we have 20 messages. - if (room.hasMore && room.timeline.length < 30) { + // While there are under 5 messages or under 20 events, try to load + // more messages until there's no more or the conditions are met. + if (room.hasMore && + (room.events.values + .where((event) => event.content is MessageContent) + .length < + 5 || + room.timeline.length < 20)) { loadOlder(); } -- 2.53.0 From 2451555479123d0ed7017ff7c3792256e291a4c8 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 16:17:21 -0400 Subject: [PATCH 107/108] add reaction support --- lib/controllers/reactions_controller.dart | 56 ++++++ lib/main.dart | 4 +- lib/models/configs/reactions_config.dart | 14 ++ lib/widgets/event_wrapper.dart | 45 ----- lib/widgets/flash_wrapper.dart | 20 ++ lib/widgets/reaction_row.dart | 189 +++++++++--------- lib/widgets/renderers/event.dart | 59 ++++-- lib/widgets/room_chat.dart | 5 +- .../{link_preview.dart => url_preview.dart} | 4 +- 9 files changed, 233 insertions(+), 163 deletions(-) create mode 100644 lib/controllers/reactions_controller.dart create mode 100644 lib/models/configs/reactions_config.dart delete mode 100644 lib/widgets/event_wrapper.dart create mode 100644 lib/widgets/flash_wrapper.dart rename lib/widgets/{link_preview.dart => url_preview.dart} (96%) diff --git a/lib/controllers/reactions_controller.dart b/lib/controllers/reactions_controller.dart new file mode 100644 index 0000000..8c199a9 --- /dev/null +++ b/lib/controllers/reactions_controller.dart @@ -0,0 +1,56 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/controllers/rooms_controller.dart"; +import "package:nexus/models/configs/reactions_config.dart"; +import "package:nexus/models/content/reaction.dart"; +import "package:nexus/models/requests/get_related_events_request.dart"; + +class ReactionsController extends AsyncNotifier>> { + final ReactionsConfig config; + ReactionsController(this.config); + + @override + Future>> build() async { + final eventInfo = ref.watch( + RoomsController.provider.select((value) { + final event = value[config.roomId]?.events[config.eventRowId]; + return event == null ? null : (event.eventId, event.reactions); + }), + ); + + final reactionEvents = eventInfo?.$2.isNotEmpty == true + ? await ref + .watch(ClientController.provider.notifier) + .getRelatedEvents( + GetRelatedEventsRequest( + roomId: config.roomId, + eventId: eventInfo!.$1, + relationType: "m.annotation", + ), + ) + : null; + + return reactionEvents + ?.where((event) => event.redactedBy == null) + .fold>>(IMap(), (acc, event) { + if (event.content case ReactionContent(:final key?)) { + return acc.update( + key, + (list) => list.add(event.sender), + ifAbsent: () => IList([event.sender]), + ); + } + + return acc; + }) ?? + const IMap.empty(); + } + + static final provider = + AsyncNotifierProvider.family< + ReactionsController, + IMap>, + ReactionsConfig + >(ReactionsController.new); +} diff --git a/lib/main.dart b/lib/main.dart index 834aeef..b687ebd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -67,8 +67,8 @@ void main() async { await windowManager.setMinimumSize(Size.square(500)); } - // FlutterError.onError = (FlutterErrorDetails details) => - // showError(details.exception.toString(), details.stack); + FlutterError.onError = (FlutterErrorDetails details) => + showError(details.exception.toString(), details.stack); runApp( ProviderScope( diff --git a/lib/models/configs/reactions_config.dart b/lib/models/configs/reactions_config.dart new file mode 100644 index 0000000..5cae859 --- /dev/null +++ b/lib/models/configs/reactions_config.dart @@ -0,0 +1,14 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +part "reactions_config.freezed.dart"; +part "reactions_config.g.dart"; + +@freezed +abstract class ReactionsConfig with _$ReactionsConfig { + const factory ReactionsConfig({ + required String roomId, + required int eventRowId, + }) = _ReactionsConfig; + + factory ReactionsConfig.fromJson(Map json) => + _$ReactionsConfigFromJson(json); +} diff --git a/lib/widgets/event_wrapper.dart b/lib/widgets/event_wrapper.dart deleted file mode 100644 index ba26e6a..0000000 --- a/lib/widgets/event_wrapper.dart +++ /dev/null @@ -1,45 +0,0 @@ -import "package:flutter/material.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/widgets/reaction_row.dart"; - -class EventWrapper extends StatelessWidget { - final Event event; - final Widget child; - final bool isFlashing; - const EventWrapper( - this.event, - this.child, { - this.isFlashing = false, - super.key, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), - child: AnimatedContainer( - padding: isFlashing ? EdgeInsets.all(8) : EdgeInsets.all(0), - color: isFlashing - ? Theme.of(context).colorScheme.onSurface.withAlpha(50) - : Colors.transparent, - duration: Duration(milliseconds: 250), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - child, - if (event.sendError != null && event.sendError != "not sent") - Text( - event.sendError!, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ReactionRow(event), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/flash_wrapper.dart b/lib/widgets/flash_wrapper.dart new file mode 100644 index 0000000..f52ea25 --- /dev/null +++ b/lib/widgets/flash_wrapper.dart @@ -0,0 +1,20 @@ +import "package:flutter/material.dart"; + +class FlashWrapper extends StatelessWidget { + final Widget child; + final bool isFlashing; + const FlashWrapper(this.child, {this.isFlashing = false, super.key}); + + @override + Widget build(BuildContext context) => ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(12)), + child: AnimatedContainer( + padding: isFlashing ? EdgeInsets.all(8) : EdgeInsets.all(0), + color: isFlashing + ? Theme.of(context).colorScheme.onSurface.withAlpha(50) + : Colors.transparent, + duration: Duration(milliseconds: 250), + child: child, + ), + ); +} diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reaction_row.dart index 30ebf1c..4935ed7 100644 --- a/lib/widgets/reaction_row.dart +++ b/lib/widgets/reaction_row.dart @@ -4,11 +4,15 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/cross_cache_controller.dart"; +import "package:nexus/controllers/reactions_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/main.dart"; +import "package:nexus/models/configs/reactions_config.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/main.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; class ReactionRow extends ConsumerWidget { final Event event; @@ -18,100 +22,99 @@ class ReactionRow extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final clientState = ref.watch(ClientStateController.provider); - return SizedBox.shrink(); + return switch (ref.watch( + ReactionsController.provider( + ReactionsConfig(roomId: event.roomId, eventRowId: event.rowId), + ), + )) { + AsyncData(value: final IMap>? reactors) || + AsyncLoading(value: final reactors) => Wrap( + spacing: 4, + runSpacing: 4, + children: event.reactions + .where((_, value) => value != 0) + .mapTo( + (reaction, count) => HookBuilder( + builder: (context) { + final enabled = useState(true); - // TODO: IMPL - // return Wrap( - // spacing: 4, - // runSpacing: 4, - // children: clientState?.homeserverUrl == null - // ? [] - // : event.reactions - // .mapTo( - // (reaction, reactors) => HookBuilder( - // builder: (context) { - // final enabled = useState(true); - // final selected = reactors.contains(clientState!.userId); - // return Tooltip( - // message: reactors.join(", "), - // child: ChoiceChip( - // showCheckmark: false, - // selected: selected, - // label: Row( - // mainAxisSize: MainAxisSize.min, - // spacing: 8, - // children: [ - // Flexible( - // child: reaction.startsWith("mxc://") - // ? Image( - // height: 20, - // image: CachedNetworkImage( - // headers: ref.headers, - // Uri.parse(reaction) - // .mxcToHttps( - // clientState.homeserverUrl!, - // ) - // .toString(), - // ref.watch( - // CrossCacheController.provider, - // ), - // ), - // ) - // : Text( - // reaction, - // overflow: TextOverflow.ellipsis, - // ), - // ), - // Text( - // reactors.length.toString(), - // overflow: TextOverflow.ellipsis, - // ), - // ], - // ), - // onSelected: enabled.value - // ? (value) async { - // enabled.value = false; - // try { - // final roomId = ref.watch( - // SelectedRoomController.provider.select( - // (value) => value?.metadata?.id, - // ), - // ); - // if (roomId == null || - // clientState.userId == null) { - // return; - // } + final selected = + reactors?[reaction]?.contains(clientState!.userId) ?? + false; + return Tooltip( + message: reactors?[reaction]?.join(", ") ?? "", + child: ChoiceChip( + showCheckmark: false, + selected: selected, + label: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + Flexible( + child: reaction.startsWith("mxc://") + ? Image( + height: 20, + image: CachedNetworkImage( + headers: ref.headers, + Uri.parse(reaction) + .mxcToHttps( + clientState!.homeserverUrl!, + ) + .toString(), + ref.watch(CrossCacheController.provider), + ), + ) + : Text( + reaction, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + count.toString(), + overflow: TextOverflow.ellipsis, + ), + ], + ), + onSelected: enabled.value + ? (value) async { + enabled.value = false; + try { + final controller = ref.watch( + RoomChatController.provider( + event.roomId, + ).notifier, + ); - // final controller = ref.watch( - // RoomChatController.provider( - // roomId, - // ).notifier, - // ); + if (selected) { + await controller + .removeReaction( + reaction, + event, + clientState!.userId!, + ) + .onError(showError); + } else { + await controller + .sendReaction(reaction, event) + .onError(showError); + } + } finally { + enabled.value = true; + } + } + : null, + ), + ); + }, + ), + ) + .toList(), + ), - // if (selected) { - // await controller - // .removeReaction( - // reaction, - // event, - // clientState.userId!, - // ) - // .onError(showError); - // } else { - // await controller - // .sendReaction(reaction, event) - // .onError(showError); - // } - // } finally { - // enabled.value = true; - // } - // } - // : null, - // ), - // ); - // }, - // ), - // ) - // .toList(), - // ); + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + }; } } diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 855168e..611f7ee 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -24,10 +24,11 @@ import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/html/html.dart"; import "package:nexus/widgets/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart"; -import "package:nexus/widgets/link_preview.dart"; +import "package:nexus/widgets/url_preview.dart"; import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/players/video.dart"; import "package:nexus/widgets/players/audio.dart"; +import "package:nexus/widgets/reaction_row.dart"; import "package:nexus/widgets/renderers/membership.dart"; import "package:nexus/widgets/renderers/generic_event.dart"; import "package:nexus/widgets/file_card.dart"; @@ -356,16 +357,21 @@ class EventRenderer extends ConsumerWidget { style: errorStyle, ), }, + if (event.lastEditRowId != 0) Text( "(edited)", style: theme.textTheme.labelSmall, ), + if (linkify(body).firstWhereOrNull( (element) => element is UrlElement, ) case final UrlElement link?) - LinkPreview(link.url), + UrlPreview(link.url), + + SizedBox(height: 4), + ReactionRow(event), ], ], ), @@ -408,21 +414,38 @@ class EventRenderer extends ConsumerWidget { _ => null, }; - return child == null - ? textOnly - ? Text("Unknown event type", style: errorStyle) - : SizedBox.shrink() - : (textOnly - ? child - : GestureDetector( - onSecondaryTapUp: contextMenuCallback, - onLongPressStart: contextMenuCallback, - child: Padding( - padding: isGrouped - ? EdgeInsets.zero - : EdgeInsets.only(top: 8), - child: child, - ), - )); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (child != null) ...[ + if (textOnly) + child + else + GestureDetector( + onSecondaryTapUp: contextMenuCallback, + onLongPressStart: contextMenuCallback, + child: Padding( + padding: isGrouped ? EdgeInsets.zero : EdgeInsets.only(top: 8), + child: child, + ), + ), + + if (event.content is! MessageContent) + Padding( + padding: EdgeInsetsGeometry.only(left: 12), + child: ReactionRow(event), + ), + + if (event.sendError != null && event.sendError != "not sent") + Text( + event.sendError!, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ] else if (textOnly) + Text("Unknown event type", style: errorStyle), + ], + ); } } diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 1bcb1ac..1bff9fa 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -21,7 +21,7 @@ import "package:nexus/widgets/emoji_picker_button.dart"; import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/room_appbar.dart"; -import "package:nexus/widgets/event_wrapper.dart"; +import "package:nexus/widgets/flash_wrapper.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/main.dart"; @@ -352,8 +352,7 @@ class RoomChat extends HookConsumerWidget { itemBuilder: (_, index) { final event = value[index]; final previousEvent = value.getOrNull(index + 1); - return EventWrapper( - event, + return FlashWrapper( EventRenderer( event, onTapReply: () async { diff --git a/lib/widgets/link_preview.dart b/lib/widgets/url_preview.dart similarity index 96% rename from lib/widgets/link_preview.dart rename to lib/widgets/url_preview.dart index e20e955..83c6604 100644 --- a/lib/widgets/link_preview.dart +++ b/lib/widgets/url_preview.dart @@ -7,9 +7,9 @@ import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/launch_helper.dart"; -class LinkPreview extends ConsumerWidget { +class UrlPreview extends ConsumerWidget { final String link; - const LinkPreview(this.link, {super.key}); + const UrlPreview(this.link, {super.key}); @override Widget build(BuildContext context, WidgetRef ref) => ConstrainedBox( -- 2.53.0 From 228ff1051f54ab34793ee2765fbf1497bee8066f Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 16:28:47 -0400 Subject: [PATCH 108/108] Add load more button --- lib/controllers/room_chat_controller.dart | 12 +++--------- lib/widgets/room_chat.dart | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index a750e53..5a6741e 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -6,7 +6,6 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:fluttertagger/fluttertagger.dart"; import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_related_events_request.dart"; @@ -38,14 +37,9 @@ class RoomChatController extends AsyncNotifier> { await ref.read(RoomsController.provider.notifier).addState(roomId, state); } - // While there are under 5 messages or under 20 events, try to load - // more messages until there's no more or the conditions are met. - if (room.hasMore && - (room.events.values - .where((event) => event.content is MessageContent) - .length < - 5 || - room.timeline.length < 20)) { + // While there are under 20 events, try to load more + // until there's no more or the conditions are met. + if (room.hasMore && room.timeline.length < 20) { loadOlder(); } diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 1bff9fa..249f2d2 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -319,6 +319,8 @@ class RoomChat extends HookConsumerWidget { ].toIList(); } + final controllerData = ref.watch(controllerProvider); + return Scaffold( appBar: RoomAppbar( roomId: roomId, @@ -337,7 +339,7 @@ class RoomChat extends HookConsumerWidget { Positioned.fill( child: Padding( padding: EdgeInsets.symmetric(horizontal: 12), - child: switch (ref.watch(controllerProvider)) { + child: switch (controllerData) { AsyncData(:final value) || AsyncLoading(:final value?) => CustomScrollView( reverse: true, @@ -346,6 +348,7 @@ class RoomChat extends HookConsumerWidget { SliverPadding( padding: EdgeInsetsGeometry.only(bottom: 64), ), + SuperSliverList.builder( listController: listController.value, itemCount: value.length, @@ -391,6 +394,20 @@ class RoomChat extends HookConsumerWidget { ); }, ), + + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.only(bottom: 36), + child: Center( + child: controllerData is AsyncLoading + ? Loading() + : ElevatedButton( + onPressed: notifier.loadOlder, + child: Text("Load More"), + ), + ), + ), + ), ], ), AsyncLoading() => Loading(), -- 2.53.0