From 16cf126df4c82e4989ac2e2ff89ef013d5ad6d8c Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 21 May 2026 16:58:22 -0400 Subject: [PATCH] Remove flutter chat (#26) Had to squash merge manually as Forgejo was erroring --- .vscode/settings.json | 4 +- README.md | 6 +- flake.lock | 12 +- lib/controllers/author_controller.dart | 46 +- lib/controllers/client_controller.dart | 38 +- lib/controllers/event_controller.dart | 16 +- lib/controllers/key_controller.dart | 8 +- .../members_by_status_controller.dart | 32 ++ .../members_by_type_controller.dart | 25 - lib/controllers/members_controller.dart | 74 ++- lib/controllers/message_controller.dart | 214 -------- lib/controllers/messages_controller.dart | 27 - lib/controllers/new_events_controller.dart | 18 - lib/controllers/power_level_controller.dart | 85 +-- lib/controllers/profile_controller.dart | 6 +- lib/controllers/reactions_controller.dart | 56 ++ lib/controllers/room_chat_controller.dart | 353 ++++--------- lib/controllers/rooms_controller.dart | 106 ++-- lib/controllers/selected_room_controller.dart | 24 - .../selected_space_controller.dart | 22 - lib/controllers/url_preview_controller.dart | 31 +- lib/controllers/user_controller.dart | 53 +- lib/controllers/via_controller.dart | 34 +- lib/helpers/extensions/get_localpart.dart | 2 +- lib/helpers/extensions/show_user_popover.dart | 32 +- lib/helpers/extensions/size_to_string.dart | 22 + lib/helpers/extensions/string_to_color.dart | 6 + lib/main.dart | 2 + .../configs/members_by_status_config.dart | 15 + lib/models/configs/message_config.dart | 28 - lib/models/configs/messages_config.dart | 17 - lib/models/configs/power_level_config.dart | 29 +- lib/models/configs/reactions_config.dart | 14 + lib/models/configs/user_config.dart | 12 + lib/models/content/avatar.dart | 14 + lib/models/content/canonical_alias.dart | 15 + lib/models/content/content.dart | 61 +++ lib/models/content/create.dart | 41 ++ lib/models/content/encrypted.dart | 13 + lib/models/content/encryption.dart | 23 + lib/models/content/join_rules.dart | 34 ++ lib/models/content/membership.dart | 19 + lib/models/content/message.dart | 92 ++++ lib/models/content/name.dart | 13 + lib/models/content/pinned_events.dart | 15 + lib/models/content/power_levels.dart | 36 ++ lib/models/content/reaction.dart | 18 + lib/models/content/redaction.dart | 14 + lib/models/content/server_acl.dart | 18 + lib/models/content/topic.dart | 40 ++ lib/models/event.dart | 46 +- lib/models/info/audio.dart | 17 + lib/models/info/file.dart | 15 + lib/models/info/image.dart | 18 + lib/models/info/video.dart | 19 + lib/models/join_rule.dart | 4 + lib/models/membership.dart | 32 -- lib/models/membership_status.dart | 2 +- lib/models/ms_duration.dart | 11 + lib/models/open_graph_data.dart | 17 + lib/models/profile.dart | 18 +- lib/models/requests/get_event_request.dart | 20 +- lib/models/room.dart | 47 +- lib/pages/chat_page.dart | 29 +- lib/pages/verify_page.dart | 2 +- lib/widgets/avatar_or_hash.dart | 14 +- .../chat_page/expandable_image_message.dart | 35 -- lib/widgets/chat_page/html/mention_chip.dart | 44 -- lib/widgets/chat_page/member_list.dart | 94 ---- lib/widgets/chat_page/reply_widget.dart | 101 ---- lib/widgets/chat_page/room_chat.dart | 492 ------------------ .../chat_page/wrappers/message_wrapper.dart | 83 --- .../chat_page/wrappers/reaction_row.dart | 116 ----- .../wrappers/text_message_wrapper.dart | 147 ------ .../{chat_page => }/composer/chat_box.dart | 31 +- .../composer/mention_overlay.dart | 76 ++- .../composer/relation_preview.dart | 41 +- .../{chat_page => }/emoji_picker_button.dart | 0 lib/widgets/event_preview.dart | 36 ++ .../{chat_page => }/expandable_image.dart | 0 lib/widgets/file_card.dart | 29 ++ lib/widgets/flash_wrapper.dart | 20 + .../{chat_page => }/html/code_block.dart | 0 lib/widgets/{chat_page => }/html/html.dart | 20 +- lib/widgets/html/mention_chip.dart | 53 ++ lib/widgets/{chat_page => }/html/quoted.dart | 0 .../{chat_page => }/html/spoiler_text.dart | 0 lib/widgets/{chat_page => }/join_dialog.dart | 0 .../lazy_loading/message_avatar.dart | 24 +- .../lazy_loading/message_displayname.dart | 21 +- lib/widgets/member_list.dart | 119 +++++ lib/widgets/players/audio.dart | 104 ++++ lib/widgets/players/video.dart | 38 ++ lib/widgets/reaction_row.dart | 120 +++++ lib/widgets/renderers/event.dart | 451 ++++++++++++++++ lib/widgets/renderers/generic_event.dart | 22 + lib/widgets/renderers/membership.dart | 57 ++ lib/widgets/{chat_page => }/room_appbar.dart | 24 +- lib/widgets/room_chat.dart | 448 ++++++++++++++++ lib/widgets/{chat_page => }/room_menu.dart | 0 lib/widgets/{chat_page => }/sidebar.dart | 10 +- lib/widgets/url_preview.dart | 69 +++ lib/widgets/{chat_page => }/user_popover.dart | 61 +-- linux/flutter/generated_plugin_registrant.cc | 8 + linux/flutter/generated_plugins.cmake | 2 + linux/nix/devshell.nix | 9 +- linux/nix/pkg/default.nix | 10 +- pubspec.lock | 261 ++++++---- pubspec.yaml | 20 +- .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 111 files changed, 3173 insertions(+), 2377 deletions(-) create mode 100644 lib/controllers/members_by_status_controller.dart delete mode 100644 lib/controllers/members_by_type_controller.dart delete mode 100644 lib/controllers/message_controller.dart delete mode 100644 lib/controllers/messages_controller.dart delete mode 100644 lib/controllers/new_events_controller.dart create mode 100644 lib/controllers/reactions_controller.dart delete mode 100644 lib/controllers/selected_room_controller.dart delete mode 100644 lib/controllers/selected_space_controller.dart create mode 100644 lib/helpers/extensions/size_to_string.dart create mode 100644 lib/helpers/extensions/string_to_color.dart create mode 100644 lib/models/configs/members_by_status_config.dart delete mode 100644 lib/models/configs/message_config.dart delete mode 100644 lib/models/configs/messages_config.dart create mode 100644 lib/models/configs/reactions_config.dart create mode 100644 lib/models/configs/user_config.dart create mode 100644 lib/models/content/avatar.dart create mode 100644 lib/models/content/canonical_alias.dart create mode 100644 lib/models/content/content.dart create mode 100644 lib/models/content/create.dart create mode 100644 lib/models/content/encrypted.dart create mode 100644 lib/models/content/encryption.dart create mode 100644 lib/models/content/join_rules.dart create mode 100644 lib/models/content/membership.dart create mode 100644 lib/models/content/message.dart create mode 100644 lib/models/content/name.dart create mode 100644 lib/models/content/pinned_events.dart create mode 100644 lib/models/content/power_levels.dart create mode 100644 lib/models/content/reaction.dart create mode 100644 lib/models/content/redaction.dart create mode 100644 lib/models/content/server_acl.dart create mode 100644 lib/models/content/topic.dart 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/join_rule.dart delete mode 100644 lib/models/membership.dart create mode 100644 lib/models/ms_duration.dart create mode 100644 lib/models/open_graph_data.dart delete mode 100644 lib/widgets/chat_page/expandable_image_message.dart delete mode 100644 lib/widgets/chat_page/html/mention_chip.dart delete mode 100644 lib/widgets/chat_page/member_list.dart delete mode 100644 lib/widgets/chat_page/reply_widget.dart delete mode 100644 lib/widgets/chat_page/room_chat.dart delete mode 100644 lib/widgets/chat_page/wrappers/message_wrapper.dart delete mode 100644 lib/widgets/chat_page/wrappers/reaction_row.dart delete mode 100644 lib/widgets/chat_page/wrappers/text_message_wrapper.dart rename lib/widgets/{chat_page => }/composer/chat_box.dart (89%) rename lib/widgets/{chat_page => }/composer/mention_overlay.dart (59%) rename lib/widgets/{chat_page => }/composer/relation_preview.dart (57%) rename lib/widgets/{chat_page => }/emoji_picker_button.dart (100%) create mode 100644 lib/widgets/event_preview.dart rename lib/widgets/{chat_page => }/expandable_image.dart (100%) create mode 100644 lib/widgets/file_card.dart create mode 100644 lib/widgets/flash_wrapper.dart rename lib/widgets/{chat_page => }/html/code_block.dart (100%) rename lib/widgets/{chat_page => }/html/html.dart (90%) create mode 100644 lib/widgets/html/mention_chip.dart 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 (54%) rename lib/widgets/{chat_page => }/lazy_loading/message_displayname.dart (60%) create mode 100644 lib/widgets/member_list.dart create mode 100644 lib/widgets/players/audio.dart create mode 100644 lib/widgets/players/video.dart create mode 100644 lib/widgets/reaction_row.dart create mode 100644 lib/widgets/renderers/event.dart create mode 100644 lib/widgets/renderers/generic_event.dart create mode 100644 lib/widgets/renderers/membership.dart rename lib/widgets/{chat_page => }/room_appbar.dart (74%) create mode 100644 lib/widgets/room_chat.dart rename lib/widgets/{chat_page => }/room_menu.dart (100%) rename lib/widgets/{chat_page => }/sidebar.dart (96%) create mode 100644 lib/widgets/url_preview.dart rename lib/widgets/{chat_page => }/user_popover.dart (81%) diff --git a/.vscode/settings.json b/.vscode/settings.json index da80f4b..a0d46c9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,7 +6,9 @@ "Gomuks", "Homeserver", "localpart", + "msgtype", "muks", - "prefs" + "prefs", + "unban" ] } 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 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/controllers/author_controller.dart b/lib/controllers/author_controller.dart index 70b7343..70070e1 100644 --- a/lib/controllers/author_controller.dart +++ b/lib/controllers/author_controller.dart @@ -1,47 +1,31 @@ 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"; -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/configs/user_config.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.authorId).future, + UserController.provider( + UserConfig(roomId: event.roomId, userId: event.sender), + ).future, ); - final pmp = message.metadata?["pmp"] == null - ? null - : Membership.fromContent( - IMap(message.metadata?["pmp"]), - message.authorId, - 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.authorId.localpart, - userId: message.authorId, + 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 cc68871..5ccdc27 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -1,15 +1,13 @@ -import "dart:developer"; 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"; 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/rooms_controller.dart"; import "package:nexus/controllers/space_edges_controller.dart"; import "package:nexus/controllers/sync_status_controller.dart"; @@ -17,6 +15,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,12 +80,8 @@ class ClientController extends AsyncNotifier { case "send_complete": final event = Event.fromJson(decodedMuksEvent["event"]); - if (event.type == "m.room.message") { - ref - .watch( - NewEventsController.provider(event.roomId).notifier, - ) - .add(IList([event])); + if (event.type == EventType.message.type) { + // ref.watch(provider.notifier).addEvent(event); TODO } break; case "sync_complete": @@ -127,9 +122,12 @@ class ClientController extends AsyncNotifier { } debugPrint("Finished handling $muksEventType..."); } catch (error, stackTrace) { - debugger(); - showError(error, stackTrace); - debugPrintStack(stackTrace: stackTrace, label: error.toString()); + if (kDebugMode) { + debugPrintStack(stackTrace: stackTrace, label: error.toString()); + rethrow; + } else { + showError(error, stackTrace); + } } }); @@ -220,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); } @@ -232,8 +225,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.fromJsonWithCatch({ + ...(await _sendCommand("get_profile", {"user_id": userId})), + "id": userId, + }); Future reportEvent(ReportRequest request) => _sendCommand("report_event", request.toJson()); @@ -242,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 4f72963..94992ca 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,18 @@ 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.select((value) => value[request.roomId]), + ); + final event = room?.events.values.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/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_status_controller.dart b/lib/controllers/members_by_status_controller.dart new file mode 100644 index 0000000..2b49903 --- /dev/null +++ b/lib/controllers/members_by_status_controller.dart @@ -0,0 +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/configs/members_by_status_config.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/event.dart"; + +class MembersByStatusController extends AsyncNotifier> { + final MembersByStatusConfig config; + MembersByStatusController(this.config); + + @override + Future> build() => ref.watch( + MembersController.provider(config.roomId).selectAsync( + (members) => members + .where( + (membership) => switch (membership.content) { + MembershipContent(:final status) => config.status == status, + _ => false, + }, + ) + .toISet(), + ), + ); + + static final provider = + AsyncNotifierProvider.family< + MembersByStatusController, + ISet, + MembersByStatusConfig + >(MembersByStatusController.new); +} diff --git a/lib/controllers/members_by_type_controller.dart b/lib/controllers/members_by_type_controller.dart deleted file mode 100644 index cdc8d07..0000000 --- a/lib/controllers/members_by_type_controller.dart +++ /dev/null @@ -1,25 +0,0 @@ -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/membership_status.dart"; - -class MembersByTypeController extends AsyncNotifier> { - final MembershipStatus status; - MembersByTypeController(this.status); - - @override - Future> build() => ref.watch( - MembersController.provider.selectAsync( - (members) => - members.where((membership) => membership.status == status).toIList(), - ), - ); - - static final provider = - AsyncNotifierProvider.family< - MembersByTypeController, - IList, - MembershipStatus - >(MembersByTypeController.new); -} diff --git a/lib/controllers/members_controller.dart b/lib/controllers/members_controller.dart index 39666d4..64e1ef7 100644 --- a/lib/controllers/members_controller.dart +++ b/lib/controllers/members_controller.dart @@ -1,52 +1,46 @@ 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/controllers/rooms_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> { +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), - ), + Future> build() async { + final room = ref.watch( + RoomsController.provider.select((value) => value[roomId]), ); - 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 (room == null) return const ISet.empty(); - 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(); + if (!room.hasFetchedMembers) { + final fetchedState = await ref + .watch(ClientController.provider.notifier) + .getRoomState( + GetRoomStateRequest( + roomId: roomId, + fetchMembers: room.metadata?.hasMemberList ?? true, + includeMembers: true, + ), + ); + + await ref + .read(RoomsController.provider.notifier) + .addState(roomId, fetchedState, isMembers: true); + } + + return room.state[EventType.membership.type]?.values + .map((rowId) => room.events[rowId]) + .nonNulls + .toISet() ?? + const ISet.empty(); } - static final provider = - AsyncNotifierProvider>( - MembersController.new, - ); + static final provider = AsyncNotifierProvider.autoDispose + .family, String>(MembersController.new); } diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart deleted file mode 100644 index c65d18d..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_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"; -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.authorId), - ifAbsent: () => IList([event.authorId]), - ); - }) - .map((key, value) => MapEntry(key, value.unlock)) - .unlock; - - final asText = - Message.text( - metadata: metadata, - id: config.event.eventId, - reactions: reactions, - authorId: event.authorId, - 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, - authorId: event.authorId, - 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, - // authorId: senderId, - // ), - ("m.sticker" || "m.room.message") => switch (content["msgtype"]) { - null || "m.image" => Message.image( - id: config.event.eventId, - authorId: event.authorId, - 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, - authorId: event.authorId, - 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.authorId == 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.authorId} 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, - authorId: event.authorId, - 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 28885fb..0000000 --- a/lib/controllers/messages_controller.dart +++ /dev/null @@ -1,27 +0,0 @@ -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"; -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/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/power_level_controller.dart b/lib/controllers/power_level_controller.dart index 41b5f19..2539778 100644 --- a/lib/controllers/power_level_controller.dart +++ b/lib/controllers/power_level_controller.dart @@ -1,8 +1,9 @@ -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"; import "package:nexus/models/requests/membership_action.dart"; class PowerLevelController extends Notifier { @@ -11,56 +12,60 @@ class PowerLevelController extends Notifier { @override bool build() { - final room = ref.watch(SelectedRoomController.provider); - final event = room?.events.firstWhereOrNull( - (event) => event.rowId == room.state["m.room.power_levels"]?[""], + if (config case EventPowerLevelConfig(:final eventType)) { + assert( + eventType != EventType.redaction, + "Checking power level for a redaction should use [PowerLevelConfig.redaction].", + ); + } + + final room = ref.watch( + RoomsController.provider.select((value) => value[config.roomId]), ); - final user = ref.watch(ClientStateController.provider)?.userId; - if (event == null || user == null) return false; - final users = (event.content["users"] as Map? ?? {}); - final events = (event.content["events"] as Map? ?? {}); + final eventRowId = room?.state[EventType.powerLevels.type]?[""]; - int powerLevelOf(String userId) => users.containsKey(userId) - ? (users[userId] as int) - : (event.content["users_default"] as int? ?? 0); + final event = eventRowId == null ? null : room?.events[eventRowId]; + final content = event?.content is PowerLevelsContent + ? event!.content + : PowerLevelsContent(); + final user = ref.watch( + ClientStateController.provider.select((value) => value?.userId), + ); + if (user == null || content is! PowerLevelsContent) return false; + + int powerLevelOf(String userId) => + 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 >= (event.content["invite"] as int? ?? 0), + return switch (config) { + EventPowerLevelConfig(:final eventType) => + userLevel >= (content.events[eventType.type] ?? content.eventsDefault), - MembershipAction.kick => - targetLevel != null && - userLevel >= (event.content["kick"] as int? ?? 50) && - userLevel > targetLevel, + MembershipActionPowerLevelConfig(:final action, :final targetUser) => + switch (action) { + MembershipAction.invite => userLevel >= content.invite, - MembershipAction.ban => - targetLevel != null && - userLevel >= (event.content["ban"] as int? ?? 50) && - userLevel > targetLevel, + MembershipAction.kick => + userLevel >= content.kick && userLevel > powerLevelOf(targetUser), - MembershipAction.unban => - userLevel >= (event.content["ban"] as int? ?? 50), - }; - } + MembershipAction.ban => + userLevel >= content.ban && userLevel > powerLevelOf(targetUser), - if (config.eventType == "m.room.redaction") { - return userLevel >= (event.content["redact"] as int? ?? 50); - } + MembershipAction.unban => userLevel >= content.ban, + }, - 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)); + StatePowerLevelConfig(:final eventType) => + userLevel >= (content.events[eventType.type] ?? content.stateDefault), - return userLevel >= requiredLevel; + RedactionPowerLevelConfig(:final targetUser) => + userLevel >= + (targetUser == user + ? (content.events[EventType.redaction.type] ?? + content.eventsDefault) + : content.redact), + }; } static final provider = NotifierProvider.autoDispose 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/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/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index fa32bf8..5a6741e 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -1,17 +1,13 @@ import "dart:async"; +import "dart:math"; 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"; -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/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"; import "package:nexus/models/requests/paginate_request.dart"; @@ -21,203 +17,75 @@ 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(); - final state = await client.getRoomState( - GetRoomStateRequest(roomId: roomId), + final room = ref.watch( + RoomsController.provider.select((rooms) => rooms[roomId]), ); + if (room == null) return const IList.empty(); - 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(), - ); - - 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, - ); - 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. - for (var more = true; more == true && controller.messages.length < 20;) { - more = await loadOlder(controller); - } - - return controller; - } - - 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 - .watch(ClientController.provider.notifier) - .redactEvent( - RedactEventRequest(eventId: message.id, roomId: roomId, reason: reason), + if (!room.hasFetchedState) { + final state = await client.getRoomState( + GetRoomStateRequest(roomId: roomId), ); - Future loadOlder([InMemoryChatController? chatController]) async { + await ref.read(RoomsController.provider.notifier).addState(roomId, state); + } + + // 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(); + } + + return room.timeline + .toEntryIList(compare: (a, b) => (b?.key ?? 0).compareTo(a?.key ?? 0)) + .map((entry) { + if (entry.value == null) return null; + + final foundEvent = room.events[entry.value!]; + + final editedEvent = + foundEvent == null || foundEvent.lastEditRowId == 0 + ? null + : room.events[foundEvent.lastEditRowId]; + + return editedEvent == null + ? foundEvent + : foundEvent?.copyWith(content: editedEvent.content); + }) + .nonNulls + .toIList(); + } + + Future deleteMessage(Event event, {String? reason}) => ref + .watch(ClientController.provider.notifier) + .redactEvent( + RedactEventRequest( + eventId: event.eventId, + roomId: roomId, + reason: reason, + ), + ); + + 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)[roomId] - ?.timeline - .firstOrNull - ?.timelineRowId, + maxTimelineId: timelineKeys?.isNotEmpty == true + ? timelineKeys?.reduce(min) + : null, ), ); @@ -226,42 +94,22 @@ class RoomChatController extends AsyncNotifier { .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(), - 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; } @@ -270,7 +118,7 @@ class RoomChatController extends AsyncNotifier { bool shouldMention = true, required IList tags, required RelationType relationType, - Message? relation, + Event? relation, }) async { var taggedMessage = text; @@ -285,7 +133,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, @@ -294,52 +141,46 @@ class RoomChatController extends AsyncNotifier { if (shouldMention == true && relation != null && relationType == RelationType.reply) - relation.authorId, + relation.sender, ].toIList(), room: taggedMessage.contains("@room"), ), text: taggedMessage, relation: relation == null ? null - : Relation(eventId: relation.id, relationType: relationType), - ), - ); - final message = room == null - ? null - : await ref.watch( - MessageController.provider( - MessageConfig(room: room, event: event), - ).future, - ); - - if (message != null) insertMessage(message); - } - - Future scrollToMessage(Message message) async { - final controller = await future; - Future setFlashing(bool flashing) => controller.updateMessage( - message, - message.copyWith( - metadata: {...(message.metadata ?? {}), "flashing": flashing}, + : Relation(eventId: relation.eventId, relationType: relationType), ), ); - await setFlashing(true); - Timer(Duration(seconds: 1), () => setFlashing(false)); - - return await controller.scrollToMessage(message.id); + // 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( 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", ), ); @@ -349,9 +190,11 @@ class RoomChatController extends AsyncNotifier { .toIList(); final reactionEvent = reactionEvents?.firstWhereOrNull( - (event) => - event.authorId == userId && - event.content["m.relates_to"]?["key"] == reaction, + (event) => switch (event.content) { + ReactionContent(:final key) => + key == reaction && event.sender == userId, + _ => false, + }, ); if (reactionEvent != null) { @@ -363,7 +206,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( @@ -372,7 +215,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, }, @@ -384,7 +227,7 @@ class RoomChatController extends AsyncNotifier { } static final provider = AsyncNotifierProvider.family - .autoDispose( + .autoDispose, String>( RoomChatController.new, ); } diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart index 7013de0..382fac4 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -1,9 +1,8 @@ -import "package:collection/collection.dart"; +import "dart:isolate"; + 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/helpers/extensions/mxc_to_https.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/models/read_receipt.dart"; import "package:nexus/models/room.dart"; @@ -11,55 +10,50 @@ class RoomsController extends Notifier> { @override IMap build() => const IMap.empty(); - void update( - IMap rooms, - ISet leftRooms, { - bool addToNewEvents = true, - }) { - final homeserver = - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - ""; + Future addState( + String roomId, + IList state, { + bool isMembers = false, + }) async => update( + { + roomId: Room( + events: IMap.fromEntries( + state.map((event) => MapEntry(event.rowId, event)), + ), + hasFetchedState: true, + hasFetchedMembers: isMembers, + 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(), + ); + + void update(IMap rooms, ISet leftRooms) { 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, - ); - - if (addToNewEvents) { - ref - .watch(NewEventsController.provider(roomId).notifier) - .add( - incoming.timeline - .map( - (timelineTuple) => events?.firstWhereOrNull( - (event) => timelineTuple.eventRowId == event.rowId, - ), - ) - .nonNulls - .toIList(), - ); - } - return acc.add( roomId, existing?.copyWith( hasMore: incoming.hasMore, - metadata: - incoming.metadata?.copyWith( - avatar: - incoming.metadata?.avatar?.mxcToHttps(homeserver) ?? - existing.metadata?.avatar, - ) ?? - existing.metadata, - events: events!, + metadata: incoming.metadata ?? existing.metadata, + events: incoming.events.isEmpty + ? existing.events + : existing.events.addAll(incoming.events), state: incoming.state.entries.fold( existing.state, (previousValue, event) => previousValue.add( @@ -69,15 +63,14 @@ class RoomsController extends Notifier> { ), ), ), - timeline: - (incoming.reset - ? incoming.timeline - : existing.timeline.updateById( - incoming.timeline, - (item) => item.timelineRowId, - )) - .sortedBy((element) => element.timelineRowId) - .toIList(), + reset: false, + hasFetchedMembers: + incoming.hasFetchedMembers || existing.hasFetchedMembers, + hasFetchedState: + incoming.hasFetchedState || existing.hasFetchedState, + timeline: (incoming.reset + ? incoming.timeline + : existing.timeline.addAll(incoming.timeline)), receipts: incoming.receipts.entries.fold( existing.receipts, (receiptAcc, event) => receiptAcc.add( @@ -88,11 +81,7 @@ class RoomsController extends Notifier> { ), ), ) ?? - incoming.copyWith( - metadata: incoming.metadata?.copyWith( - avatar: incoming.metadata?.avatar?.mxcToHttps(homeserver), - ), - ), + incoming, ); }); @@ -100,6 +89,7 @@ class RoomsController extends Notifier> { merged, (acc, roomId) => acc.remove(roomId), ); + state = prunedList; } 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/url_preview_controller.dart b/lib/controllers/url_preview_controller.dart index c2161d5..ad89b55 100644 --- a/lib/controllers/url_preview_controller.dart +++ b/lib/controllers/url_preview_controller.dart @@ -1,18 +1,20 @@ 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"; 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 { - final homeserver = ref.watch(ClientStateController.provider)?.homeserverUrl; + Future build() async { + final homeserver = ref.watch( + ClientStateController.provider.select((value) => value?.homeserverUrl), + ); if (homeserver != null && !link.contains("matrix.to")) { { @@ -25,27 +27,14 @@ class UrlPreviewController extends AsyncNotifier { if (response.statusCode == 200) { final decodedValue = json.decode(response.body); + if (decodedValue is! Map) 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).copyWith(imageUrl: image); } } } @@ -54,7 +43,7 @@ class UrlPreviewController extends AsyncNotifier { } static final provider = AsyncNotifierProvider.autoDispose - .family( + .family( UrlPreviewController.new, ); } diff --git a/lib/controllers/user_controller.dart b/lib/controllers/user_controller.dart index e7ca973..5a47ba6 100644 --- a/lib/controllers/user_controller.dart +++ b/lib/controllers/user_controller.dart @@ -4,37 +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/membership.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); +class UserController extends AsyncNotifier { + final UserConfig config; + UserController(this.config); @override - Future build() async { - final member = await ref.watch( - MembersController.provider.selectAsync( - (value) => - value.firstWhereOrNull((membership) => membership.userId == userId), - ), + Future build() async { + 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, ); - - if (member != null) return member; - - 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!), - displayName: profile.displayName ?? userId.localpart, - userId: userId, + avatarUrl: profile.avatarUrl, + displayName: profile.displayName ?? config.userId.localpart, ); } static final provider = - AsyncNotifierProvider.family( - UserController.new, - ); + AsyncNotifierProvider.family< + UserController, + MembershipContent, + UserConfig + >(UserController.new); } diff --git a/lib/controllers/via_controller.dart b/lib/controllers/via_controller.dart index b423947..69e9f4a 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 { @@ -21,23 +25,29 @@ class ViaController extends Notifier { addUserId(ref.watch(ClientStateController.provider)?.userId); - final powerLevels = room.events.firstWhereOrNull( - (event) => event.rowId == room.state["m.room.power_levels"]?[""], - ); + final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""]; + final powerLevels = powerLevelsEventId == null + ? null + : room.events[powerLevelsEventId]; - 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), - ); + final membershipEventId = members?.getOrNull(i); + final member = membershipEventId == null + ? null + : room.events[membershipEventId]; - 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/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/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart index 1698879..1826037 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/widgets/chat_page/user_popover.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/widgets/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/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/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/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/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/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..197e171 100644 --- a/lib/models/configs/power_level_config.dart +++ b/lib/models/configs/power_level_config.dart @@ -1,17 +1,28 @@ 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 { +sealed class PowerLevelConfig with _$PowerLevelConfig { const factory PowerLevelConfig({ - @Default(false) bool isStateEvent, - required String eventType, - MembershipAction? action, - String? targetUser, - }) = _PowerLevelConfig; + required EventType eventType, + required String roomId, + }) = EventPowerLevelConfig; - factory PowerLevelConfig.fromJson(Map json) => - _$PowerLevelConfigFromJson(json); + const factory PowerLevelConfig.membershipAction({ + required MembershipAction action, + required String targetUser, + required String roomId, + }) = MembershipActionPowerLevelConfig; + + const factory PowerLevelConfig.state({ + required EventType eventType, + required String roomId, + }) = StatePowerLevelConfig; + + const factory PowerLevelConfig.redaction({ + required String targetUser, + required String roomId, + }) = RedactionPowerLevelConfig; } 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/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/models/content/avatar.dart b/lib/models/content/avatar.dart new file mode 100644 index 0000000..66d4c47 --- /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._(); + 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 new file mode 100644 index 0000000..c6ac400 --- /dev/null +++ b/lib/models/content/canonical_alias.dart @@ -0,0 +1,15 @@ +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._(); + 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 new file mode 100644 index 0000000..2896049 --- /dev/null +++ b/lib/models/content/content.dart @@ -0,0 +1,61 @@ +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"; +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/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 Error? parseError; + Content({this.parseError}); + + factory Content.fromJson(Map json) => Content(); + Map toJson() => {}; + + static Map readValue(Map json, _) => + json["decrypted"] ?? json["content"]; + + static Content fromEventJson(Map json, String type) { + try { + return (EventType.values + .firstWhereOrNull((eventType) => eventType.type == type) + ?.contentFromJson ?? + Content.fromJson)(json); + } catch (error) { + if (error is Error) return Content(parseError: error); + rethrow; + } + } +} + +enum EventType { + encrypted("m.room.encrypted", EncryptedContent.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), + 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), + reaction("m.reaction", ReactionContent.fromJson), + pinnedEvents("m.room.pinned_events", PinnedEventsContent.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/create.dart b/lib/models/content/create.dart new file mode 100644 index 0000000..6921c04 --- /dev/null +++ b/lib/models/content/create.dart @@ -0,0 +1,41 @@ +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._(); + 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, + @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; + + factory PreviousRoom.fromJson(Map json) => + _$PreviousRoomFromJson(json); +} 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/encryption.dart b/lib/models/content/encryption.dart new file mode 100644 index 0000000..3380632 --- /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._(); + 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/join_rules.dart b/lib/models/content/join_rules.dart new file mode 100644 index 0000000..1d14eee --- /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._(); + 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/content/membership.dart b/lib/models/content/membership.dart new file mode 100644 index 0000000..aa5a36d --- /dev/null +++ b/lib/models/content/membership.dart @@ -0,0 +1,19 @@ +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 { + MembershipContent._(); + factory MembershipContent({ + @JsonKey(name: "displayname") required String? displayName, + @JsonKey(name: "membership") required MembershipStatus status, + Uri? avatarUrl, + 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..b5e308c --- /dev/null +++ b/lib/models/content/message.dart @@ -0,0 +1,92 @@ +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"; +import "package:nexus/models/info/video.dart"; +part "message.freezed.dart"; +part "message.g.dart"; + +@Freezed(unionKey: "msgtype", fallbackUnion: "default") +abstract class MessageContent extends Content with _$MessageContent { + MessageContent._(); + factory MessageContent({required String body}) = UnknownMessageContent; + + @FreezedUnionValue("m.text") + factory MessageContent.text({ + required String body, + MessageFormat? format, + String? formattedBody, + }) = TextMessageContent; + + @FreezedUnionValue("m.notice") + factory MessageContent.notice({ + required String body, + MessageFormat? format, + String? formattedBody, + }) = NoticeMessageContent; + + @FreezedUnionValue("m.emote") + factory MessageContent.emote({ + required String body, + MessageFormat? format, + String? formattedBody, + }) = EmoteMessageContent; + + @FreezedUnionValue("m.image") + factory MessageContent.image({ + required String body, + MessageFormat? format, + String? formattedBody, + // EncryptedFile? file + String? filename, + ImageInfo? info, + Uri? url, + }) = ImageMessageContent; + + @FreezedUnionValue("m.file") + factory MessageContent.file({ + required String body, + MessageFormat? format, + String? formattedBody, + // EncryptedFile? file + String? filename, + FileInfo? info, + Uri? url, + }) = FileMessageContent; + + @FreezedUnionValue("m.audio") + factory MessageContent.audio({ + required String body, + MessageFormat? format, + String? formattedBody, + // EncryptedFile? file + String? filename, + AudioInfo? info, + Uri? url, + }) = AudioMessageContent; + + @FreezedUnionValue("m.video") + factory MessageContent.video({ + required String body, + MessageFormat? format, + String? formattedBody, + // EncryptedFile? file + String? filename, + VideoInfo? info, + Uri? url, + }) = VideoMessageContent; + + @FreezedUnionValue("m.location") + factory MessageContent.location({required String body, required Uri geoUri}) = + LocationMessageContent; + + factory MessageContent.fromJson(Map json) => + _$MessageContentFromJson(json); +} + +@JsonEnum() +enum MessageFormat { + @JsonValue("org.matrix.custom.html") + html, +} diff --git a/lib/models/content/name.dart b/lib/models/content/name.dart new file mode 100644 index 0000000..205f6bb --- /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._(); + 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 new file mode 100644 index 0000000..d17a0de --- /dev/null +++ b/lib/models/content/pinned_events.dart @@ -0,0 +1,15 @@ +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._(); + 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 new file mode 100644 index 0000000..3709c38 --- /dev/null +++ b/lib/models/content/power_levels.dart @@ -0,0 +1,36 @@ +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._(); + 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/reaction.dart b/lib/models/content/reaction.dart new file mode 100644 index 0000000..3115ae0 --- /dev/null +++ b/lib/models/content/reaction.dart @@ -0,0 +1,18 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "reaction.freezed.dart"; +part "reaction.g.dart"; + +@freezed +abstract class ReactionContent extends Content with _$ReactionContent { + ReactionContent._(); + static String? keyJsonFromJson(Map json, String key) => + json["m.relates_to"]?["key"]; + + factory ReactionContent({ + @JsonKey(readValue: ReactionContent.keyJsonFromJson) String? key, + }) = _ReactionContent; + + factory ReactionContent.fromJson(Map json) => + _$ReactionContentFromJson(json); +} diff --git a/lib/models/content/redaction.dart b/lib/models/content/redaction.dart new file mode 100644 index 0000000..e9c1a90 --- /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._(); + factory RedactionContent({String? reason, String? redacts}) = + _RedactionContent; + + factory RedactionContent.fromJson(Map json) => + _$RedactionContentFromJson(json); +} diff --git a/lib/models/content/server_acl.dart b/lib/models/content/server_acl.dart new file mode 100644 index 0000000..1e50988 --- /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._(); + 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..8fa5229 --- /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._(); + 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 { + 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 { + 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 734f667..c54dbc5 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -1,37 +1,69 @@ 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"; @freezed 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, required String roomId, required String eventId, - @JsonKey(name: "sender") required String authorId, - required String type, + required String sender, + @JsonKey(readValue: Event.typeJsonFromJson) 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? replyTo, 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, + Profile? pmp, + required Content content, + required Content? previousContent, }) = _Event; - factory Event.fromJson(Map json) => _$EventFromJson(json); + factory Event.fromJson(Map json) => + _$EventFromJson(json).copyWith( + 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"], + ), + previousContent: json["unsigned"]?["prev_content"] == null + ? null + : Content.fromEventJson( + json["unsigned"]?["prev_content"], + json["decrypted_type"] ?? json["type"], + ), + ); } @freezed 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..9833016 --- /dev/null +++ b/lib/models/info/image.dart @@ -0,0 +1,18 @@ +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") double? height, + @JsonKey(name: "w") double? width, + @JsonKey(name: "mimetype") String? mimeType, + @JsonKey(name: "xyz.amorgan.blurhash") String? blurHash, + 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/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 } 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/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/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; +} diff --git a/lib/models/open_graph_data.dart b/lib/models/open_graph_data.dart new file mode 100644 index 0000000..d7e840d --- /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 Uri? 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/models/profile.dart b/lib/models/profile.dart index 584f27b..6ba2656 100644 --- a/lib/models/profile.dart +++ b/lib/models/profile.dart @@ -12,18 +12,28 @@ Object? readTimezone(Map map, _) => @freezed abstract class Profile with _$Profile { const factory Profile({ - String? avatarUrl, + required String id, + String? parseError, + 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; - 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 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/models/room.dart b/lib/models/room.dart index 3c3eec0..8aaadbe 100644 --- a/lib/models/room.dart +++ b/lib/models/room.dart @@ -8,29 +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/pages/chat_page.dart b/lib/pages/chat_page.dart index 671891c..3d6a35f 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -1,9 +1,10 @@ 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/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 { @@ -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/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( 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/expandable_image_message.dart b/lib/widgets/chat_page/expandable_image_message.dart deleted file mode 100644 index f6e8a03..0000000 --- a/lib/widgets/chat_page/expandable_image_message.dart +++ /dev/null @@ -1,35 +0,0 @@ -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"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/widgets/chat_page/expandable_image.dart"; - -class ExpandableImageMessage extends ConsumerWidget { - final ImageMessage message; - final int index; - - const ExpandableImageMessage(this.message, {required this.index, 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), - ), - ), - message: message, - index: index, - ), - ); -} diff --git a/lib/widgets/chat_page/html/mention_chip.dart b/lib/widgets/chat_page/html/mention_chip.dart deleted file mode 100644 index 575ad03..0000000 --- a/lib/widgets/chat_page/html/mention_chip.dart +++ /dev/null @@ -1,44 +0,0 @@ -import "package:flutter/material.dart"; -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"; - -class MentionChip extends ConsumerWidget { - final String content; - const MentionChip(this.content, {super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final membership = content.mention!.startsWith("@") == true - ? ref - .watch(UserController.provider(content.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, - ), - ), - backgroundColor: Theme.of(context).colorScheme.primary, - ), - ), - ); - } -} diff --git a/lib/widgets/chat_page/member_list.dart b/lib/widgets/chat_page/member_list.dart deleted file mode 100644 index 8be1ddd..0000000 --- a/lib/widgets/chat_page/member_list.dart +++ /dev/null @@ -1,94 +0,0 @@ -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/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/show_user_popover.dart"; -import "package:nexus/models/membership_status.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; - -class MemberList extends HookConsumerWidget { - const MemberList({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final status = useState(MembershipStatus.join); - final membersProvider = ref.watch( - MembersByTypeController.provider(status.value), - ); - - return Drawer( - shape: Border(), - child: Column( - spacing: 8, - children: [ - AppBar( - scrolledUnderElevation: 0, - leading: Icon(Icons.people), - title: Text("Members"), - actionsPadding: EdgeInsets.only(right: 4), - actions: [ - if (Scaffold.of(context).hasEndDrawer) - IconButton( - onPressed: Scaffold.of(context).closeEndDrawer, - icon: Icon(Icons.close), - tooltip: "Close member list", - ), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 8, - children: [ - FilterChip( - label: Text("Joined"), - onSelected: (value) => status.value = MembershipStatus.join, - selected: status.value == MembershipStatus.join, - ), - FilterChip( - label: Text("Invited"), - onSelected: (value) => status.value = MembershipStatus.invite, - selected: status.value == MembershipStatus.invite, - ), - FilterChip( - label: Text("Banned"), - onSelected: (value) => status.value = MembershipStatus.ban, - selected: status.value == MembershipStatus.ban, - ), - ], - ), - membersProvider.betterWhen( - data: (members) => Expanded( - child: ListView( - children: members - .map( - (member) => InkWell( - onTapUp: (details) => context.showUserPopover( - member, - globalPosition: details.globalPosition, - ), - child: ListTile( - leading: AvatarOrHash( - member.avatarUrl, - member.displayName, - ), - title: Text( - member.displayName, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - member.userId, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ) - .toList(), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/widgets/chat_page/reply_widget.dart b/lib/widgets/chat_page/reply_widget.dart deleted file mode 100644 index b999be4..0000000 --- a/lib/widgets/chat_page/reply_widget.dart +++ /dev/null @@ -1,101 +0,0 @@ -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"; -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 deleted file mode 100644 index 1a1cfdd..0000000 --- a/lib/widgets/chat_page/room_chat.dart +++ /dev/null @@ -1,492 +0,0 @@ -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"; -import "package:nexus/controllers/client_state_controller.dart"; -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/helpers/extensions/show_context_menu.dart"; -import "package:nexus/models/configs/power_level_config.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/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/form_text_input.dart"; -import "package:nexus/main.dart"; - -class RoomChat extends HookConsumerWidget { - final bool isDesktop; - final bool showMembersByDefault; - const RoomChat({ - required this.isDesktop, - required this.showMembersByDefault, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final client = ref.watch(ClientController.provider.notifier); - final relatedMessage = useState(null); - final memberListOpened = useState(showMembersByDefault); - final relationType = useState(RelationType.reply); - 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( - appBar: RoomAppbar( - isDesktop: isDesktop, - onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), - onOpenMemberList: null, - ), - body: Center( - child: Text( - "Nothing to see here...", - style: theme.textTheme.headlineMedium, - ), - ), - ); - } - - final controllerProvider = RoomChatController.provider(roomId); - final notifier = ref.watch(controllerProvider.notifier); - - final composerNode = useFocusNode( - onKeyEvent: (_, event) { - if (event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.escape) { - relatedMessage.value = null; - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - }, - ); - - List getMessageOptions(Message message) { - final isSentByMe = message.authorId == userId; - return [ - if (ref.watch( - PowerLevelController.provider( - PowerLevelConfig(eventType: "m.reaction"), - ), - )) - PopupMenuItem( - 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, message) - .onError(showError); - }, - icon: Text(emoji), - ), - ), - EmojiPickerButton( - context: context, - onPressed: Navigator.of(context).pop, - onSelection: (emoji) => - notifier.sendReaction(emoji, message).onError(showError), - ), - ], - ), - ), - if (ref.watch( - PowerLevelController.provider( - PowerLevelConfig(eventType: "m.room.message"), - ), - )) - PopupMenuItem( - onTap: () { - relatedMessage.value = message; - relationType.value = RelationType.reply; - composerNode.requestFocus(); - }, - child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")), - ), - if (message is TextMessage && isSentByMe) - PopupMenuItem( - onTap: () { - relatedMessage.value = message; - relationType.value = RelationType.edit; - composerNode.requestFocus(); - }, - child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")), - ), - PopupMenuItem( - onTap: () async { - final room = ref.watch(SelectedRoomController.provider); - if (room == null) return; - - final vias = ref.watch(ViaController.provider(room)); - - await Clipboard.setData( - ClipboardData( - text: - "matrix:roomid/${room.metadata?.id.substring(1)}/e/${message.id}$vias)", - ), - ); - }, - child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), - ), - if (ref.watch( - PowerLevelController.provider( - PowerLevelConfig(eventType: "m.room.redaction"), - ), - )) - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (_) { - final deleteReasonController = useTextEditingController(); - return AlertDialog( - title: Text("Delete Message"), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Are you sure you want to delete this message? This can not be reversed.", - ), - SizedBox(height: 12), - FormTextInput( - required: false, - capitalize: true, - controller: deleteReasonController, - title: "Reason for deletion (optional)", - ), - ], - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - await notifier - .deleteMessage( - message, - reason: deleteReasonController.text, - ) - .onError(showError); - }, - child: Text("Delete"), - ), - ], - ); - }, - ), - ), - child: ListTile( - leading: Icon(Icons.delete, color: danger), - title: Text("Delete", style: TextStyle(color: danger)), - ), - ), - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (_) { - final reasonController = useTextEditingController(); - return AlertDialog( - title: Text("Report"), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Report this event to your server administrators, who can take action like banning this server or room.", - ), - - SizedBox(height: 12), - FormTextInput( - required: false, - capitalize: true, - controller: reasonController, - title: "Reason for report (optional)", - ), - ], - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () { - client.reportEvent( - ReportRequest( - roomId: roomId, - eventId: message.id, - reason: reasonController.text.isEmpty - ? null - : reasonController.text, - ), - ); - Navigator.of(context).pop(); - }, - child: Text("Report"), - ), - ], - ); - }, - ), - ), - child: ListTile( - leading: Icon(Icons.report, color: danger), - title: Text("Report", style: TextStyle(color: danger)), - ), - ), - ]; - } - - 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, - onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), - onOpenMemberList: (thisContext) { - memberListOpened.value = !memberListOpened.value; - Scaffold.of(thisContext).openEndDrawer(); - }, - ), - body: Row( - children: [ - Expanded( - child: Column( - children: [ - Expanded( - 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, - relatedMessage: relatedMessage.value, - onDismiss: () => relatedMessage.value = null, - ), - - textMessageBuilder: - ( - context, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => TextMessageWrapper( - message, - content: message.text, - groupStatus: groupStatus, - onTapReply: notifier.scrollToMessage, - updateMessage: controller.updateMessage, - isSentByMe: isSentByMe, - ), - - imageMessageBuilder: - ( - context, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => TextMessageWrapper( - message, - content: message.text, - groupStatus: groupStatus, - onTapReply: notifier.scrollToMessage, - updateMessage: controller.updateMessage, - isSentByMe: isSentByMe, - extra: ExpandableImageMessage( - message, - index: index, - ), - ), - - 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.scrollToMessage, - 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.authorId} sent ${message.metadata?["eventType"]}", - style: theme.textTheme.labelSmall?.copyWith( - color: Colors.grey, - ), - ), - ), - resolveUser: (_) async => null, - chatController: controller, - ), - ), - ), - ], - ), - ), - - if (memberListOpened.value == true && showMembersByDefault) - MemberList(), - ], - ), - - endDrawer: showMembersByDefault ? null : MemberList(), - ); - } -} 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 9c70c27..0000000 --- a/lib/widgets/chat_page/wrappers/message_wrapper.dart +++ /dev/null @@ -1,83 +0,0 @@ -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"; -import "package:timeago/timeago.dart"; - -class MessageWrapper extends StatelessWidget { - final Message message; - final Widget child; - final MessageGroupStatus? groupStatus; - const MessageWrapper(this.message, this.child, this.groupStatus, {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 - ? Theme.of(context).colorScheme.onSurface.withAlpha(50) - : Colors.transparent, - duration: Duration(milliseconds: 250), - child: Row( - spacing: 8, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - groupStatus?.isFirst != false - ? MessageAvatar(message, height: 40) - : SizedBox(width: 40), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, - children: [ - if (groupStatus?.isFirst != false) - Row( - spacing: 4, - children: [ - Flexible( - child: MessageDisplayname( - message, - 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, - ), - ), - ), - ], - ), - child, - if (error != null && error != "not sent") - Text( - error, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ReactionRow(message), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/chat_page/wrappers/reaction_row.dart b/lib/widgets/chat_page/wrappers/reaction_row.dart deleted file mode 100644 index 5e8fe86..0000000 --- a/lib/widgets/chat_page/wrappers/reaction_row.dart +++ /dev/null @@ -1,116 +0,0 @@ -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"; -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"; - -class ReactionRow extends ConsumerWidget { - final Message message; - const ReactionRow(this.message, {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; - } - - final controller = ref.watch( - RoomChatController.provider( - roomId, - ).notifier, - ); - - 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(), - ); - } -} 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 8d7a625..0000000 --- a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart +++ /dev/null @@ -1,147 +0,0 @@ -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"; -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() - : LinkPreview( - onTap: (url) => ref - .watch(LaunchHelper.provider) - .launchUrl(Uri.parse(url)), - imageBuilder: (url) => Image( - image: CachedNetworkImage( - url, - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - 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, - ), - ), - if (extra != null) extra!, - ], - ), - ), - ), - groupStatus, - ); - } -} diff --git a/lib/widgets/chat_page/composer/chat_box.dart b/lib/widgets/composer/chat_box.dart similarity index 89% rename from lib/widgets/chat_page/composer/chat_box.dart rename to lib/widgets/composer/chat_box.dart index ac44aa3..4cb3835 100644 --- a/lib/widgets/chat_page/composer/chat_box.dart +++ b/lib/widgets/composer/chat_box.dart @@ -1,18 +1,20 @@ 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"; 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"; -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 Message? relatedMessage; + final String roomId; + final Event? relatedEvent; final RelationType relationType; final VoidCallback onDismiss; final FocusNode? node; @@ -22,8 +24,9 @@ class ChatBox extends HookConsumerWidget { required IList tags, }) onSend; - const ChatBox({ - required this.relatedMessage, + const ChatBox( + this.roomId, { + required this.relatedEvent, required this.relationType, required this.onDismiss, required this.onSend, @@ -39,10 +42,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() { @@ -73,7 +74,7 @@ class ChatBox extends HookConsumerWidget { child: Column( children: [ RelationPreview( - relatedMessage, + relatedEvent, shouldMention: shouldMention.value, toggleShouldMention: () => shouldMention.value = !shouldMention.value, @@ -89,7 +90,10 @@ class ChatBox extends HookConsumerWidget { children: ref.watch( PowerLevelController.provider( - PowerLevelConfig(eventType: "m.room.message"), + PowerLevelConfig( + eventType: EventType.message, + roomId: roomId, + ), ), ) ? [ @@ -126,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/chat_page/composer/mention_overlay.dart b/lib/widgets/composer/mention_overlay.dart similarity index 59% rename from lib/widgets/chat_page/composer/mention_overlay.dart rename to lib/widgets/composer/mention_overlay.dart index b650421..b4af97a 100644 --- a/lib/widgets/chat_page/composer/mention_overlay.dart +++ b/lib/widgets/composer/mention_overlay.dart @@ -1,9 +1,12 @@ 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"; import "package:nexus/widgets/loading.dart"; @@ -11,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, @@ -34,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( @@ -43,33 +53,49 @@ 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 ?? + member.stateKey!.localpart, + ), + title: Text( + displayName ?? + member.stateKey!.localpart, + ), + subtitle: Text(member.stateKey!), + onTap: () => addTag( + id: "[@$displayName](matrix:u/${member.stateKey!.substring(1)})", + name: member.stateKey!.localpart, + ), + ), + _ => SizedBox.shrink(), + }, ) .toList(), ), diff --git a/lib/widgets/chat_page/composer/relation_preview.dart b/lib/widgets/composer/relation_preview.dart similarity index 57% rename from lib/widgets/chat_page/composer/relation_preview.dart rename to lib/widgets/composer/relation_preview.dart index c90b07b..f2bcaf6 100644 --- a/lib/widgets/chat_page/composer/relation_preview.dart +++ b/lib/widgets/composer/relation_preview.dart @@ -1,19 +1,18 @@ 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/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"; +import "package:nexus/widgets/event_preview.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, @@ -23,12 +22,12 @@ 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( color: theme.colorScheme.surfaceContainerHigh, - padding: EdgeInsets.symmetric(horizontal: 8), + padding: EdgeInsets.symmetric(horizontal: 12), child: Row( spacing: 8, children: [ @@ -38,32 +37,10 @@ class RelationPreview extends ConsumerWidget { style: TextStyle(fontWeight: FontWeight.bold), ), - MessageAvatar(relatedMessage!), - Expanded( - child: Row( - spacing: 8, - children: [ - Flexible( - child: MessageDisplayname( - relatedMessage!, - style: theme.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - Expanded( - child: Text( - relatedMessage?.metadata?["body"] ?? - relatedMessage?.metadata?["eventType"] ?? - "", - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: theme.textTheme.labelMedium, - ), - ), - ], + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: EventPreview(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/event_preview.dart b/lib/widgets/event_preview.dart new file mode 100644 index 0000000..f092fd2 --- /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: 8, + runSpacing: 2, + children: [ + if (event.content is MessageContent) MessageDisplayname(event), + EventRenderer(event, textOnly: true, maxLines: 1), + ], + ), + ), + ], + ), + ), + ); +} 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/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/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/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 90% rename from lib/widgets/chat_page/html/html.dart rename to lib/widgets/html/html.dart index fb533ad..449f400 100644 --- a/lib/widgets/chat_page/html/html.dart +++ b/lib/widgets/html/html.dart @@ -9,20 +9,22 @@ 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; + 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( html, + buildAsync: false, textStyle: textStyle, customWidgetBuilder: (element) { if (element.attributes.keys.contains("data-mx-profile-fallback")) { @@ -58,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 new file mode 100644 index 0000000..4791ed8 --- /dev/null +++ b/lib/widgets/html/mention_chip.dart @@ -0,0 +1,53 @@ +import "package:flutter/material.dart"; +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, this.roomId, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mention = content.mention; + final membership = mention?.startsWith("@") == true + ? ref + .watch( + UserController.provider( + UserConfig(roomId: roomId, userId: mention!), + ), + ) + .whenOrNull(data: (data) => data) + : null; + + 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, + ), + ), + ); + } +} 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 54% rename from lib/widgets/chat_page/lazy_loading/message_avatar.dart rename to lib/widgets/lazy_loading/message_avatar.dart index dc8dfef..215bb35 100644 --- a/lib/widgets/chat_page/lazy_loading/message_avatar.dart +++ b/lib/widgets/lazy_loading/message_avatar.dart @@ -1,32 +1,36 @@ 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"; +import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.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 = 24, 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) { + context.showUserPopover( + membership, + event.sender, + globalPosition: details.globalPosition, + ); + }, child: AvatarOrHash( membership.avatarUrl, - membership.displayName, + membership.displayName ?? event.sender.localpart, height: height, ), ), loading: () => - AvatarOrHash(null, message.authorId.substring(1), height: height), + AvatarOrHash(null, event.sender.localpart, height: height), ); } diff --git a/lib/widgets/chat_page/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart similarity index 60% rename from lib/widgets/chat_page/lazy_loading/message_displayname.dart rename to lib/widgets/lazy_loading/message_displayname.dart index 88d2fa6..b1c1460 100644 --- a/lib/widgets/chat_page/lazy_loading/message_displayname.dart +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -1,16 +1,18 @@ 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"; +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 { - 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, @@ -18,18 +20,25 @@ 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.sender, globalPosition: details.globalPosition, ) : null, child: Text( - "${membership.displayName}${message.metadata?["pmp"] == null ? "" : " (via ${message.authorId})"}", - style: style, + "${membership.displayName ?? event.sender.localpart}${event.pmp == null ? "" : " (via ${event.sender})"}", + style: + style ?? + TextStyle( + color: event.sender.colorHash, + fontWeight: FontWeight.bold, + ), + maxLines: 1, overflow: TextOverflow.ellipsis, ), ), diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart new file mode 100644 index 0000000..e5d41d7 --- /dev/null +++ b/lib/widgets/member_list.dart @@ -0,0 +1,119 @@ +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"; +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; + const MemberList(this.roomId, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final status = useState(MembershipStatus.join); + final membersProvider = ref.watch( + MembersByStatusController.provider( + MembersByStatusConfig(roomId: roomId, status: status.value), + ), + ); + + return Drawer( + shape: Border(), + child: Column( + spacing: 8, + children: [ + AppBar( + scrolledUnderElevation: 0, + leading: Icon(Icons.people), + title: Text("Members"), + actionsPadding: EdgeInsets.only(right: 4), + actions: [ + if (Scaffold.of(context).hasEndDrawer) + IconButton( + onPressed: Scaffold.of(context).closeEndDrawer, + icon: Icon(Icons.close), + tooltip: "Close member list", + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8, + children: [ + FilterChip( + label: Text("Joined"), + onSelected: (value) => status.value = MembershipStatus.join, + selected: status.value == MembershipStatus.join, + ), + FilterChip( + label: Text("Invited"), + onSelected: (value) => status.value = MembershipStatus.invite, + selected: status.value == MembershipStatus.invite, + ), + FilterChip( + label: Text("Banned"), + onSelected: (value) => status.value = MembershipStatus.ban, + selected: status.value == MembershipStatus.ban, + ), + ], + ), + switch (membersProvider) { + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + AsyncData(:final value) || AsyncLoading(:final value?) => Expanded( + child: ListView( + children: value + .map( + (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 ?? member.sender.localpart, + ), + title: Text( + displayName ?? member.stateKey!.localpart, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: member.stateKey!.colorHash, + fontWeight: FontWeight.bold, + ), + ), + subtitle: Text( + member.stateKey!, + overflow: TextOverflow.ellipsis, + ), + ), + ), + _ => SizedBox.shrink(), + }, + ) + .toList(), + ), + ), + AsyncLoading _ => Loading(), + }, + ], + ), + ); + } +} diff --git a/lib/widgets/players/audio.dart b/lib/widgets/players/audio.dart new file mode 100644 index 0000000..f75afe5 --- /dev/null +++ b/lib/widgets/players/audio.dart @@ -0,0 +1,104 @@ +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 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())), + ), + ), + Text( + format(duration.value), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart new file mode 100644 index 0000000..9621e4f --- /dev/null +++ b/lib/widgets/players/video.dart @@ -0,0 +1,38 @@ +import "dart:async"; + +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( + configuration: PlayerConfiguration(bufferSize: 128 * 1024 * 1024), + ), + ); + final controller = useMemoized(() => VideoController(player)); + + useEffect(() { + scheduleMicrotask( + () => player.open( + Media(url.toString(), httpHeaders: ref.headers), + play: false, + ), + ); + + return player.dispose; + }, []); + + return SizedBox(height: 300, child: Video(controller: controller)); + } +} diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reaction_row.dart new file mode 100644 index 0000000..4935ed7 --- /dev/null +++ b/lib/widgets/reaction_row.dart @@ -0,0 +1,120 @@ +import "package:cross_cache/cross_cache.dart"; +import "package:flutter/material.dart"; +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/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; + const ReactionRow(this.event, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final clientState = ref.watch(ClientStateController.provider); + + 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); + + 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, + ); + + 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 new file mode 100644 index 0000000..611f7ee --- /dev/null +++ b/lib/widgets/renderers/event.dart @@ -0,0 +1,451 @@ +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/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"; +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/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"; +import "package:nexus/widgets/lazy_loading/message_displayname.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"; +import "package:timeago/timeago.dart"; +import "package:flutter_linkify/flutter_linkify.dart"; + +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 EventRenderer( + this.event, { + this.onTapReply, + this.textOnly = false, + this.isGrouped = false, + this.maxLines, + this.getEventOptions, + super.key, + }); + + @override + 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(), + child: Text( + format(event.timestamp), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith(color: Colors.grey), + ), + ); + final contextMenuCallback = getEventOptions == null + ? null + : (details) => context.showContextMenu( + globalPosition: details.globalPosition, + children: getEventOptions!(event).toList(), + ); + + final textStyle = TextStyle( + fontSize: event.localContent?.bigEmoji == true ? 32 : null, + fontStyle: event.content is EmoteMessageContent ? FontStyle.italic : null, + ); + + final child = event.redactedBy != null || event.relationType == "m.replace" + ? 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, + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + if (!textOnly) + if (isGrouped) + SizedBox(width: 40) + else + MessageAvatar(event, height: 40), + Flexible( + child: Column( + spacing: 4, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isGrouped && !textOnly) + Row( + spacing: 4, + children: [ + Flexible(child: MessageDisplayname(event)), + Flexible(flex: 0, child: timestamp), + ], + ), + Card( + margin: textOnly + ? EdgeInsets.zero + : EdgeInsets.only(bottom: 4), + 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: textOnly + ? EdgeInsets.zero + : EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!textOnly && event.replyTo != null) + Card( + margin: EdgeInsets.only(bottom: 8), + color: theme.colorScheme.surfaceContainerHigh, + 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.replyTo!, + ), + ), + )) { + AsyncData(:final value?) || + AsyncLoading( + :final value?, + ) => EventPreview(value), + AsyncError _ => Text( + "An error occurred while fetching the reply", + style: errorStyle, + ), + _ => Text("Fetching event..."), + }, + ), + ), + ), + switch (event.content) { + EncryptedContent() => Text( + "Unable to decrypt event", + style: errorStyle, + ), + // TODO: Handle locations + // LocationMessageContent(:final body , :final geoUri) => + 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, + ) || + 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: [ + format == MessageFormat.html && !textOnly + ? Html( + roomId: event.roomId, + textStyle: textStyle, + formattedBody!.replaceAllMapped( + RegExp( + r"(]*>.*?<\/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: maxLines == null + ? null + : 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, + ) || + FileMessageContent(:final url) || + VideoMessageContent(:final url) || + AudioMessageContent(:final url)) + switch (url?.mxcToHttps( + ref.watch( + ClientStateController.provider + .select( + (value) => + value!.homeserverUrl!, + ), + ), + )) { + final url? => ConstrainedBox( + constraints: BoxConstraints.loose( + Size.square(500), + ), + child: switch (event.content) { + VideoMessageContent( + :final info, + ) => + VideoPlayer(url, info), + AudioMessageContent( + :final info, + ) => + AudioPlayer(url, info), + FileMessageContent( + :final info, + :final filename, + ) => + FileCard( + url, + info, + filename: filename, + ), + 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, + ), + 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, + ), + ), + ), + ), + ), + ), + _ => SizedBox.shrink(), + }, + ), + _ => Text( + "Nexus currently cannot handle encrypted media", + style: errorStyle, + ), + }, + + if (event.lastEditRowId != 0) + Text( + "(edited)", + style: theme.textTheme.labelSmall, + ), + + if (linkify(body).firstWhereOrNull( + (element) => element is UrlElement, + ) + case final UrlElement link?) + UrlPreview(link.url), + + SizedBox(height: 4), + ReactionRow(event), + ], + ], + ), + 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 => + event.previousContent is MembershipContent && + (event.previousContent as MembershipContent).status == + content.status + ? null + : MembershipRenderer(event), + 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, + }; + + 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/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 new file mode 100644 index 0000000..9012ba2 --- /dev/null +++ b/lib/widgets/renderers/membership.dart @@ -0,0 +1,57 @@ +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"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/renderers/generic_event.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 => GenericEventRenderer(Icons.people, [ + 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}\""), + ]), + _ => SizedBox.shrink(), + }; + } +} diff --git a/lib/widgets/chat_page/room_appbar.dart b/lib/widgets/room_appbar.dart similarity index 74% rename from lib/widgets/chat_page/room_appbar.dart rename to lib/widgets/room_appbar.dart index 62e282d..a77e101 100644 --- a/lib/widgets/chat_page/room_appbar.dart +++ b/lib/widgets/room_appbar.dart @@ -1,17 +1,21 @@ 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/selected_room_controller.dart"; +import "package:nexus/controllers/client_state_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"; -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; 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, @@ -23,13 +27,23 @@ 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 ? 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/room_chat.dart b/lib/widgets/room_chat.dart new file mode 100644 index 0000000..249f2d2 --- /dev/null +++ b/lib/widgets/room_chat.dart @@ -0,0 +1,448 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +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/room_chat_controller.dart"; +import "package:nexus/controllers/via_controller.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/composer/chat_box.dart"; +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/flash_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 { + final bool isDesktop; + final bool showMembersByDefault; + final String? roomId; + const RoomChat({ + required this.roomId, + required this.isDesktop, + required this.showMembersByDefault, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final relatedEvent = useState(null); + final relationType = useState(RelationType.reply); + final flashingEvent = useState(null); + + final memberListOpened = useState(showMembersByDefault); + + final userId = ref.watch(ClientStateController.provider)?.userId; + final theme = Theme.of(context); + + if (userId == null || this.roomId == null) { + return Scaffold( + appBar: RoomAppbar( + roomId: this.roomId, + isDesktop: isDesktop, + onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), + onOpenMemberList: null, + ), + body: Center( + child: Text( + "Nothing to see here...", + style: theme.textTheme.headlineMedium, + ), + ), + ); + } + + final roomId = this.roomId!; + + 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(); + + useEffect(() { + 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) { + await client.markRead(room); + } else { + if (room.hasMore) await notifier.loadOlder(); + } + } + + scrollController.addListener(listener); + return () => scrollController.removeListener(listener); + }, [roomId]); + + final composerNode = useFocusNode( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { + relatedEvent.value = null; + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + }, + ); + + IList getEventOptions(Event event) { + final danger = theme.colorScheme.error; + final isSentByMe = event.sender == userId; + return [ + if (ref.watch( + PowerLevelController.provider( + PowerLevelConfig(eventType: EventType.reaction, roomId: roomId), + ), + )) + PopupMenuItem( + 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), + ), + ), + EmojiPickerButton( + context: context, + onPressed: Navigator.of(context).pop, + onSelection: (emoji) => + notifier.sendReaction(emoji, event).onError(showError), + ), + ], + ), + ), + ), + if (ref.watch( + PowerLevelController.provider( + PowerLevelConfig(eventType: EventType.message, roomId: roomId), + ), + )) + PopupMenuItem( + onTap: () { + relatedEvent.value = event; + relationType.value = RelationType.reply; + composerNode.requestFocus(); + }, + child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")), + ), + if (event.content is MessageContent && isSentByMe) + PopupMenuItem( + onTap: () { + relatedEvent.value = event; + relationType.value = RelationType.edit; + composerNode.requestFocus(); + }, + child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")), + ), + PopupMenuItem( + onTap: () async { + final room = ref.watch( + RoomsController.provider.select((value) => value[roomId]), + ); + if (room == null) return; + + final vias = ref.watch(ViaController.provider(room)); + + await Clipboard.setData( + ClipboardData( + text: + "matrix:roomid/${room.metadata?.id.substring(1)}/e/${event.eventId}$vias)", + ), + ); + }, + child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), + ), + if (ref.watch( + PowerLevelController.provider( + PowerLevelConfig.redaction( + targetUser: event.sender, + roomId: roomId, + ), + ), + )) + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (context) => HookBuilder( + builder: (_) { + final deleteReasonController = useTextEditingController(); + return AlertDialog( + title: Text("Delete Message"), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Are you sure you want to delete this message? This can not be reversed.", + ), + SizedBox(height: 12), + FormTextInput( + required: false, + capitalize: true, + controller: deleteReasonController, + title: "Reason for deletion (optional)", + ), + ], + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + await notifier + .deleteMessage( + event, + reason: deleteReasonController.text, + ) + .onError(showError); + }, + child: Text("Delete"), + ), + ], + ); + }, + ), + ), + child: ListTile( + leading: Icon(Icons.delete, color: danger), + title: Text("Delete", style: TextStyle(color: danger)), + ), + ), + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (context) => HookBuilder( + builder: (_) { + final reasonController = useTextEditingController(); + return AlertDialog( + title: Text("Report"), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Report this event to your server administrators, who can take action like banning this server or room.", + ), + + SizedBox(height: 12), + FormTextInput( + required: false, + capitalize: true, + controller: reasonController, + title: "Reason for report (optional)", + ), + ], + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () { + client.reportEvent( + ReportRequest( + roomId: roomId, + eventId: event.eventId, + reason: reasonController.text.isEmpty + ? null + : reasonController.text, + ), + ); + Navigator.of(context).pop(); + }, + child: Text("Report"), + ), + ], + ); + }, + ), + ), + child: ListTile( + leading: Icon(Icons.report, color: danger), + title: Text("Report", style: TextStyle(color: danger)), + ), + ), + ].toIList(); + } + + final controllerData = ref.watch(controllerProvider); + + return Scaffold( + appBar: RoomAppbar( + roomId: roomId, + isDesktop: isDesktop, + onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), + onOpenMemberList: (thisContext) { + memberListOpened.value = !memberListOpened.value; + Scaffold.of(thisContext).openEndDrawer(); + }, + ), + body: Row( + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: switch (controllerData) { + 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, + itemBuilder: (_, index) { + final event = value[index]; + final previousEvent = value.getOrNull(index + 1); + return FlashWrapper( + 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: + previousEvent?.content + is MessageContent && + event.redactedBy == null && + event.relationType != "m.replace" && + "${event.sender}${event.pmp?.id}" == + "${previousEvent?.sender}${previousEvent?.pmp?.id}", + ), + isFlashing: + flashingEvent.value == event.eventId, + ); + }, + ), + + 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(), + AsyncError(:final error, :final stackTrace) => + ErrorDialog(error, stackTrace), + }, + ), + ), + ChatBox( + roomId, + 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, + ), + ], + ), + ), + + if (memberListOpened.value == true && showMembersByDefault) + MemberList(roomId), + ], + ), + + endDrawer: showMembersByDefault ? null : MemberList(roomId), + ); + } +} 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 96% rename from lib/widgets/chat_page/sidebar.dart rename to lib/widgets/sidebar.dart index 77b8cd6..8afe3c5 100644 --- a/lib/widgets/chat_page/sidebar.dart +++ b/lib/widgets/sidebar.dart @@ -1,11 +1,11 @@ +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/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; @@ -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/url_preview.dart b/lib/widgets/url_preview.dart new file mode 100644 index 0000000..83c6604 --- /dev/null +++ b/lib/widgets/url_preview.dart @@ -0,0 +1,69 @@ +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 UrlPreview extends ConsumerWidget { + final String link; + const UrlPreview(this.link, {super.key}); + + @override + 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)), + child: Card( + margin: EdgeInsets.symmetric(vertical: 4), + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: Padding( + padding: EdgeInsetsGeometry.all(16), + 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, + ), + ), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/lib/widgets/chat_page/user_popover.dart b/lib/widgets/user_popover.dart similarity index 81% rename from lib/widgets/chat_page/user_popover.dart rename to lib/widgets/user_popover.dart index a9a4799..207d723 100644 --- a/lib/widgets/chat_page/user_popover.dart +++ b/lib/widgets/user_popover.dart @@ -6,30 +6,30 @@ 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"; 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"; 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 { - final Membership member; - const UserPopover(this.member, {super.key}); + final MembershipContent member; + final String userId; + 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, @@ -37,16 +37,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 +63,7 @@ class UserPopover extends ConsumerWidget { client .setMembership( SetMembershipRequest( - userId: member.userId, + userId: userId, roomId: roomId!, action: action, reason: actionReasonController.text, @@ -93,10 +89,18 @@ 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, + member.displayName ?? userId.localpart, height: 80, ), ), @@ -104,13 +108,13 @@ class UserPopover extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ SelectableText( - member.displayName, + member.displayName ?? userId.localpart, 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 +149,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, @@ -156,11 +159,10 @@ class UserPopover extends ConsumerWidget { if (ref.watch( PowerLevelController.provider( - PowerLevelConfig( - eventType: "m.room.member", + PowerLevelConfig.membershipAction( action: MembershipAction.kick, - isStateEvent: true, - targetUser: member.userId, + roomId: roomId!, + targetUser: userId, ), ), ) && @@ -180,11 +182,10 @@ class UserPopover extends ConsumerWidget { ), if (ref.watch( PowerLevelController.provider( - PowerLevelConfig( - eventType: "m.room.member", + PowerLevelConfig.membershipAction( + roomId: roomId!, action: MembershipAction.ban, - isStateEvent: true, - targetUser: member.userId, + targetUser: userId, ), ), )) 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/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index 0e8bc2a..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,9 +31,8 @@ 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="; + linkify = "sha256-mxV/XHLxF9cn7sUPr2SUNjVmDr5lbxkuGCbNdyiZi2c="; }; postInstall = '' diff --git a/pubspec.lock b/pubspec.lock index 9b55aa4..6d3aa22 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,14 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_chat_core: + flutter_blurhash: dependency: "direct main" description: - name: flutter_chat_core - sha256: "8c46790f64f106bf6e610e2a7324b3844320e9e295867c06d45d9deb134d848d" + name: flutter_blurhash + sha256: e97b9aff13b9930bbaa74d0d899fec76e3f320aba3190322dcc5d32104e3d25d 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" + version: "0.9.1" flutter_hooks: dependency: "direct main" description: @@ -440,14 +416,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 +487,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: @@ -776,12 +720,13 @@ packages: source: hosted version: "3.0.2" linkify: - dependency: transitive + dependency: "direct main" 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 @@ -815,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: @@ -839,14 +848,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: @@ -871,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: @@ -975,14 +992,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: @@ -999,14 +1008,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: @@ -1055,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: @@ -1095,14 +1104,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: @@ -1284,6 +1285,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: @@ -1324,14 +1333,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: @@ -1364,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: @@ -1428,14 +1445,22 @@ 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: 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: @@ -1448,10 +1473,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: @@ -1468,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: @@ -1557,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 f7532bf..f124f40 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,7 +9,13 @@ flutter: uses-material-design: true environment: - sdk: "3.11.4" + sdk: "3.11.5" + +dependency_overrides: + linkify: + git: + url: https://github.com/appelladev/linkify + ref: fix/consecutive-periods-loose-url dependencies: flutter: @@ -32,12 +38,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 @@ -54,9 +54,15 @@ 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 + 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