diff --git a/.gitmodules b/.gitmodules index 145276a..17d64ba 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "gomuks"] path = gomuks - url = https://github.com/gomuks/gomuks - branch = main + url = https://github.com/zachatrocity/gomuks + branch = init-root-dir diff --git a/.vscode/settings.json b/.vscode/settings.json index da80f4b..25ea52b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,11 +2,8 @@ "cSpell.words": [ "Appbar", "Displayname", - "fluttertagger", - "Gomuks", "Homeserver", - "localpart", - "muks", - "prefs" + "prefs", + "vodozemac" ] } diff --git a/README.md b/README.md index 0fe2a1b..7af23a0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Nexus Client > [!WARNING] -> Nexus Client is still in development, and doesn't support everything needed for daily use. +> Nexus Client is still heavily in development, and is not ready for use! ## Description @@ -21,9 +21,9 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [ ] Allow using remote Gomuks over websocket - [ ] Platform Support - [x] Linux - - [ ] Windows (WIP) + - [x] Windows - [ ] MacOS - - [x] Android + - [ ] Android - [ ] iOS - [ ] Web (may not be possible) - [x] Login @@ -37,7 +37,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [ ] Searching - [ ] Creating (Rooms, Spaces, and DMs) - [x] Joining - - [x] Parse vias + - [ ] Parse vias - [x] Using a text/uri/link - [x] Plain text - [x] `matrix:` Uri @@ -63,11 +63,10 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions) - [ ] Custom emojis/stickers - [ ] GIFs using Gomuks' GIF proxies - - [x] Receiving + - [x] Recieving - [x] Plain text - [x] Per message profiles - [x] HTML - - [x] URL Previews - [x] Replies - [x] Viewing - [ ] Jump to original message @@ -80,34 +79,28 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [x] Blurhashing - [ ] Downloading attachments - [x] Opening attachments in their own view - - [ ] Polls + - [ ] Polls: Waiting on https://github.com/SwanFlutter/dynamic_polls/issues/1 - [x] Mentions - [x] Users - - [x] Clickable - [x] Rooms - - [x] Clickable + - [ ] Plain text (not sure if I want to add this or not, I probably won't unless there's interest) - [x] Matrix URIs - [x] Matrix.to links - - [x] Events - - [ ] Render more nicely - - [ ] Clickable + - [ ] Do some fancy fetching to get nice names + - [ ] Make clickable - [x] Custom emojis/stickers - [x] History loading - [x] Backwards - [ ] Forwards - [x] Editing - [x] Deleting -- [x] Reactions +- [ ] Reactions: Waiting on https://github.com/flyerhq/flutter_chat_ui/pull/838 or me doing a custom impl - [ ] Pins - [ ] Displaying - [ ] Creating - [ ] Threads -- [x] Profile popouts - - [x] Working actions -- [x] Copy link to: - - [x] Room - - [x] Space - - [x] Message +- [ ] Profile popouts +- [ ] Copy link to [room, space] - [ ] Reporting - [x] Events - [ ] Rooms @@ -115,7 +108,6 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) - [ ] Invites - [ ] Settings - - [ ] Matrix: URIs vs Matrix.to links - [ ] Light/Dark mode - [ ] SSD or CSD - [ ] Show media by default @@ -123,7 +115,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [ ] Devices - [ ] Viewing devices - [ ] Verifying devices - - [ ] URL preview: Server / Sending Client (Beeper spec) / None + - [ ] URL preview: Server / Client / None - [ ] Account changes - [ ] Display name - [ ] Profile picture @@ -133,34 +125,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [ ] About - [x] Log Out -## Try it out - -If you want to try out Nexus, grab one of the following artifacts from CI: - -- [Android APK](https://nightly.link/Henry-Hiles/nexus/workflows/android/main/APK.zip) -- Windows - - [Portable Build](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-portable.zip) - - [Installer](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip) -- Flatpak - - [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip) - - [x86_64/AMD64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-x86_64.zip) - -Or, try the Nix package: `nix run git+https://git.federated.nexus/Henry-Hiles/nexus` - -## Build it yourself - -### Prerequisites - -#### Linux - -- With Nix: Either use direnv and `direnv allow`, or `nix flake develop` -- Without Nix: Install Flutter, Go, Git, Libclang, and Glibc. Do not use any Snap packages, they cause various compilation issues. - -#### Windows / MacOS - -I don't really know. You will need Flutter, Git, Go, and Visual Studio tools, and otherwise I guess just keep installing stuff until there aren't any errors. I will look into this sometimeTM. - -### Clone repo +## Build Instructions First, clone and open the repo: @@ -169,6 +134,17 @@ git clone --recurse-submodules https://git.federated.nexus/Henry-Hiles/nexus cd nexus ``` +### Prerequisites + +#### Linux + +- With Nix: Either use direnv and `direnv allow`, or `nix flake develop` +- Without Nix: Install Flutter, Go, Olm, Git, Clang, and GLibc. + +#### Windows / MacOS + +I don't really know. You will need Flutter, Git, Go, and Visual Studio tools, and otherwise I guess just keep installing stuff until there aren't any errors. I will look into this sometimeTM. + ### Set up Flutter Get dependencies: @@ -180,7 +156,7 @@ flutter pub get Generate Gomuks bindings: ```sh -dart scripts/generate.dart +scripts/generate.sh ``` Build generated files, and watch for new changes: diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png deleted file mode 100644 index 791aed8..0000000 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index 86ebaa5..57feff2 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png deleted file mode 100644 index b00666d..0000000 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png index 3c64f70..8bb5bb4 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png deleted file mode 100644 index bad307d..0000000 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png index c2b441b..df784a1 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png deleted file mode 100644 index b3e4f12..0000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index e472dab..d732425 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png deleted file mode 100644 index 0aac7b2..0000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png index 64a5154..963a077 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index d047760..c79c58a 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ - + - - - diff --git a/lib/controllers/author_controller.dart b/lib/controllers/author_controller.dart index 70b7343..72b0f72 100644 --- a/lib/controllers/author_controller.dart +++ b/lib/controllers/author_controller.dart @@ -1,28 +1,31 @@ import "dart:async"; +import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/user_controller.dart"; -import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/controllers/members_controller.dart"; +import "package:nexus/models/configs/author_config.dart"; import "package:nexus/models/membership.dart"; -import "package:nexus/models/membership_status.dart"; class AuthorController extends AsyncNotifier { - final Message message; - AuthorController(this.message); + final AuthorConfig config; + AuthorController(this.config); @override Future build() async { final member = await ref.watch( - UserController.provider(message.authorId).future, + MembersController.provider(config.room).selectAsync( + (value) => value.firstWhereOrNull( + (membership) => membership.userId == config.message.authorId, + ), + ), ); - final pmp = message.metadata?["pmp"] == null + final pmp = config.message.metadata?["pmp"] == null ? null : Membership.fromContent( - IMap(message.metadata?["pmp"]), - message.authorId, + IMap(config.message.metadata?["pmp"]), + config.message.authorId, ref.watch( ClientStateController.provider.select( (value) => value?.homeserverUrl, @@ -32,16 +35,17 @@ class AuthorController extends AsyncNotifier { ); return Membership( - status: member?.status ?? MembershipStatus.leave, avatarUrl: pmp?.avatarUrl ?? member?.avatarUrl, displayName: - pmp?.displayName ?? member?.displayName ?? message.authorId.localpart, - userId: message.authorId, + pmp?.displayName ?? + member?.displayName ?? + config.message.authorId.substring(1).split(":").first, + userId: config.message.authorId, ); } - static final provider = - AsyncNotifierProvider.family( + static final provider = AsyncNotifierProvider.family + .autoDispose( AuthorController.new, ); } diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index cc68871..37dde3d 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -9,13 +9,11 @@ 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"; import "package:nexus/controllers/top_level_spaces_controller.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; -import "package:nexus/main.dart"; import "package:nexus/models/client_state.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/paginate.dart"; @@ -28,9 +26,7 @@ import "package:nexus/models/profile.dart"; import "package:nexus/models/requests/paginate_request.dart"; import "package:nexus/models/requests/redact_event_request.dart"; import "package:nexus/models/requests/report_request.dart"; -import "package:nexus/models/requests/send_event_request.dart"; import "package:nexus/models/requests/send_message_request.dart"; -import "package:nexus/models/requests/set_membership_request.dart"; import "package:nexus/models/room.dart"; import "package:nexus/models/sync_data.dart"; import "package:nexus/models/sync_status.dart"; @@ -78,17 +74,6 @@ class ClientController extends AsyncNotifier { case "init_complete": ref.watch(InitCompleteController.provider.notifier).complete(); break; - 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])); - } - break; case "sync_complete": final syncData = SyncData.fromJson(decodedMuksEvent); final roomProvider = RoomsController.provider; @@ -128,7 +113,6 @@ class ClientController extends AsyncNotifier { debugPrint("Finished handling $muksEventType..."); } catch (error, stackTrace) { debugger(); - showError(error, stackTrace); debugPrintStack(stackTrace: stackTrace, label: error.toString()); } }); @@ -166,11 +150,8 @@ class ClientController extends AsyncNotifier { Future redactEvent(RedactEventRequest report) => _sendCommand("redact_event", report.toJson()); - Future sendMessage(SendMessageRequest request) async => - Event.fromJson(await _sendCommand("send_message", request.toJson())); - - Future sendEvent(SendEventRequest request) async => - Event.fromJson(await _sendCommand("send_event", request.toJson())); + Future sendMessage(SendMessageRequest request) => + _sendCommand("send_message", request.toJson()); Future verify(String recoveryKey) async { try { @@ -202,13 +183,9 @@ class ClientController extends AsyncNotifier { // })); Future> getRoomState(GetRoomStateRequest request) async { - Future getState(GetRoomStateRequest request) async => - (await _sendCommand("get_room_state", request.toJson())) as List?; - final response = await getState(request); - - return (response ?? await getState(request.copyWith(refetch: true)) ?? []) - .map((event) => Event.fromJson(event)) - .toIList(); + final response = + (await _sendCommand("get_room_state", request.toJson())) as List; + return response.map((event) => Event.fromJson(event)).toIList(); } Future?> getRelatedEvents( @@ -235,11 +212,8 @@ class ClientController extends AsyncNotifier { Future getProfile(String userId) async => Profile.fromJson(await _sendCommand("get_profile", {"user_id": userId})); - Future reportEvent(ReportRequest request) => - _sendCommand("report_event", request.toJson()); - - Future setMembership(SetMembershipRequest request) => - _sendCommand("set_membership", request.toJson()); + Future reportEvent(ReportRequest report) => + _sendCommand("report_event", report.toJson()); Future markRead(Room room) async { final event = room.events.firstWhereOrNull( @@ -266,7 +240,7 @@ class ClientController extends AsyncNotifier { Future discoverHomeserver(Uri homeserver) async { try { final response = await _sendCommand("discover_homeserver", { - "user_id": "@fake-user:${homeserver.host}", + "user_id": "@fakeuser:${homeserver.host}", }); return response["m.homeserver"]?["base_url"]; } catch (error) { 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..8d79f71 100644 --- a/lib/controllers/members_controller.dart +++ b/lib/controllers/members_controller.dart @@ -2,34 +2,30 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/models/membership.dart"; import "package:nexus/models/requests/get_room_state_request.dart"; +import "package:nexus/models/room.dart"; class MembersController extends AsyncNotifier> { + final Room room; + MembersController(this.room); + @override Future> build() async { - final data = ref.watch( - SelectedRoomController.provider.select( - (value) => value?.metadata == null - ? null - : (value!.metadata!.id, value.metadata!.hasMemberList), - ), - ); - if (data == null) return const IList.empty(); + if (room.metadata == null) return const IList.empty(); final state = await ref .watch(ClientController.provider.notifier) .getRoomState( GetRoomStateRequest( - roomId: data.$1, - fetchMembers: data.$2 == false, + roomId: room.metadata!.id, + fetchMembers: room.metadata!.hasMemberList == false, includeMembers: true, ), ); return state.nonNulls - .where((state) => state.type == "m.room.member") + .where((member) => member.content["membership"] == "join") .map( (membership) => Membership.fromContent( membership.content, @@ -46,7 +42,7 @@ class MembersController extends AsyncNotifier> { } static final provider = - AsyncNotifierProvider>( + AsyncNotifierProvider.family, Room>( MembersController.new, ); } diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart index c65d18d..d84aabb 100644 --- a/lib/controllers/message_controller.dart +++ b/lib/controllers/message_controller.dart @@ -1,12 +1,9 @@ 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; @@ -15,11 +12,11 @@ class MessageController extends AsyncNotifier { @override Future build() async { try { - final isEdit = config.event.relationType == "m.replace"; - if ((isEdit && !config.includeEdits) || config.room.metadata == null) { + if (config.event.relationType == "m.replace" && !config.includeEdits) { return null; } + if (!ref.mounted) return null; final event = config.event.lastEditRowId == null ? config.event : config.room.events.firstWhereOrNull( @@ -27,11 +24,11 @@ class MessageController extends AsyncNotifier { ) ?? config.event; - final decrypted = (event.decrypted ?? event.content); + if (!ref.mounted) return null; + + final content = (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 newContent = content["m.new_content"] as Map?; final homeserver = ref .read(ClientStateController.provider) @@ -42,19 +39,22 @@ class MessageController extends AsyncNotifier { final metadata = { "body": config.event.redactedBy == null - ? (content["body"] ?? "") + ? (newContent?["body"] ?? 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"], + "pmp": event.content["com.beeper.per_message_profile"], + "editSource": + event.localContent?.editSource ?? + newContent?["body"] ?? + content["body"], "txnId": config.event.transactionId, }; + if (!ref.mounted) return null; + final editedAt = event.relationType == "m.replace" ? event.timestamp : null; @@ -65,60 +65,32 @@ class MessageController extends AsyncNotifier { return null; } + // TODO: Use server-generated preview if enabled + + // final match = Uri.tryParse( + // RegExp(regexLink, caseSensitive: false).firstMatch(body)?.group(0) ?? "", + // ); + 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"] ?? "", + text: + newContent?["formatted_body"] ?? + newContent?["body"] ?? + 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.", @@ -138,7 +110,6 @@ class MessageController extends AsyncNotifier { null || "m.image" => Message.image( id: config.event.eventId, authorId: event.authorId, - reactions: reactions, source: source, replyToMessageId: replyId, metadata: metadata, @@ -151,7 +122,6 @@ class MessageController extends AsyncNotifier { size: content["info"]["size"], metadata: metadata, id: config.event.eventId, - reactions: reactions, authorId: event.authorId, source: source, replyToMessageId: replyId, @@ -162,21 +132,33 @@ class MessageController extends AsyncNotifier { "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"] ?? ""}", + : Message.system( + metadata: { + ...metadata, + "body": + "${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) { + "invite" => "was invited to", + "join" => "joined", + "leave" => "left", + "knock" => "asked to join", + "ban" => "was banned from", + _ => "did something relating to", + }} the room.", + }, + id: config.event.eventId, + authorId: event.authorId, + deliveredAt: config.event.timestamp, + text: + "${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) { + "invite" => "was invited to", + "join" => "joined", + "leave" => "left", + "knock" => "asked to join", + "ban" => "was banned from", + _ => "did something relating to", + }} the room.", ), - "m.room.server_acl" => toSystemMessage( - "${event.authorId} updated the server ban list.", - ), - "m.room.redaction" => config.alwaysReturn ? asText.copyWith( @@ -195,7 +177,6 @@ class MessageController extends AsyncNotifier { // ignore: dead_code ? Message.unsupported( metadata: metadata, - reactions: reactions, id: config.event.eventId, authorId: event.authorId, replyToMessageId: replyId, diff --git a/lib/controllers/power_level_controller.dart b/lib/controllers/power_level_controller.dart deleted file mode 100644 index 41b5f19..0000000 --- a/lib/controllers/power_level_controller.dart +++ /dev/null @@ -1,70 +0,0 @@ -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/models/configs/power_level_config.dart"; -import "package:nexus/models/requests/membership_action.dart"; - -class PowerLevelController extends Notifier { - final PowerLevelConfig config; - PowerLevelController(this.config); - - @override - bool build() { - final room = ref.watch(SelectedRoomController.provider); - final event = room?.events.firstWhereOrNull( - (event) => event.rowId == room.state["m.room.power_levels"]?[""], - ); - 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? ?? {}); - - int powerLevelOf(String userId) => users.containsKey(userId) - ? (users[userId] as int) - : (event.content["users_default"] as int? ?? 0); - - 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), - - MembershipAction.kick => - targetLevel != null && - userLevel >= (event.content["kick"] as int? ?? 50) && - userLevel > targetLevel, - - MembershipAction.ban => - targetLevel != null && - userLevel >= (event.content["ban"] as int? ?? 50) && - userLevel > targetLevel, - - MembershipAction.unban => - userLevel >= (event.content["ban"] as int? ?? 50), - }; - } - - if (config.eventType == "m.room.redaction") { - return userLevel >= (event.content["redact"] as int? ?? 50); - } - - 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)); - - return userLevel >= requiredLevel; - } - - static final provider = NotifierProvider.autoDispose - .family( - PowerLevelController.new, - ); -} diff --git a/lib/controllers/profile_controller.dart b/lib/controllers/profile_controller.dart deleted file mode 100644 index 120d4e4..0000000 --- a/lib/controllers/profile_controller.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/models/profile.dart"; - -class ProfileController extends AsyncNotifier { - final String userId; - ProfileController(this.userId); - - @override - Future build() { - final client = ref.watch(ClientController.provider.notifier); - return client.getProfile(userId); - } - - static final provider = AsyncNotifierProvider.autoDispose - .family(ProfileController.new); -} diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index fa32bf8..d737154 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -2,6 +2,7 @@ import "dart:async"; import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:flutter_chat_core/flutter_chat_core.dart" as chat; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:fluttertagger/fluttertagger.dart"; import "package:nexus/controllers/client_controller.dart"; @@ -9,15 +10,12 @@ 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/requests/get_related_events_request.dart"; import "package:nexus/models/requests/get_room_state_request.dart"; import "package:nexus/models/requests/paginate_request.dart"; import "package:nexus/models/requests/redact_event_request.dart"; import "package:nexus/models/relation_type.dart"; -import "package:nexus/models/requests/send_event_request.dart"; import "package:nexus/models/requests/send_message_request.dart"; import "package:nexus/models/room.dart"; @@ -30,6 +28,7 @@ class RoomChatController extends AsyncNotifier { 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), ); @@ -79,68 +78,16 @@ class RoomChatController extends AsyncNotifier { ref.onDispose( ref.listen(NewEventsController.provider(roomId), (_, next) async { + final controller = await future; 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, + final message = controller.messages.firstWhereOrNull( + (message) => message.id == event.content["redacts"], ); - if (!ref.mounted) return; + if (message == null || !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, - ), - ); - } + await controller.removeMessage(message); } else { final message = await ref.watch( MessageController.provider( @@ -169,8 +116,12 @@ class RoomChatController extends AsyncNotifier { ), ); } - if (message != null && ref.mounted) { - await insertMessage(message); + if (message != null && + !controller.messages.any( + (oldMessage) => oldMessage.id == message.id, + ) && + ref.mounted) { + await controller.insertMessage(message); } } } @@ -179,9 +130,9 @@ class RoomChatController extends AsyncNotifier { 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); + // While there are under 20 messages, try up to two times to load more messages. + for (var i = 0; i < 2 && messages.length < 20; i++) { + await loadOlder(controller); } return controller; @@ -201,13 +152,21 @@ class RoomChatController extends AsyncNotifier { : controller.updateMessage(oldMessage, message); } - Future deleteMessage(Message message, {String? reason}) => ref - .watch(ClientController.provider.notifier) - .redactEvent( - RedactEventRequest(eventId: message.id, roomId: roomId, reason: reason), - ); + Future deleteMessage(Message message, {String? reason}) async { + final controller = await future; + await controller.removeMessage(message); + await ref + .watch(ClientController.provider.notifier) + .redactEvent( + RedactEventRequest( + eventId: message.id, + roomId: roomId, + reason: reason, + ), + ); + } - Future loadOlder([InMemoryChatController? chatController]) async { + Future loadOlder([InMemoryChatController? chatController]) async { final response = await ref .watch(ClientController.provider.notifier) .paginate( @@ -239,40 +198,38 @@ class RoomChatController extends AsyncNotifier { ), }), 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, - ); + if (room == null) return; - 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; + 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, + ); } Future send( - String text, { + String message, { bool shouldMention = true, - required IList tags, + required Iterable tags, required RelationType relationType, Message? relation, }) async { - var taggedMessage = text; + var taggedMessage = message; for (final tag in tags) { final escaped = RegExp.escape(tag.id); @@ -285,8 +242,7 @@ class RoomChatController extends AsyncNotifier { } final client = ref.watch(ClientController.provider.notifier); - final room = ref.read(RoomsController.provider)[roomId]; - final event = await client.sendMessage( + client.sendMessage( SendMessageRequest( roomId: roomId, mentions: Mentions( @@ -304,15 +260,21 @@ class RoomChatController extends AsyncNotifier { : 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 resolveUser(String id) async { + final user = await ref + .watch(ClientController.provider.notifier) + .getProfile(id); + return chat.User( + id: id, + name: user.displayName, + // imageSource: user.avatarUrl == null + // ? null + // : (await ref.watch( + // AvatarController.provider(user.avatarUrl!.toString()).future, + // )).toString(), + ); } Future scrollToMessage(Message message) async { @@ -330,59 +292,6 @@ class RoomChatController extends AsyncNotifier { return await controller.scrollToMessage(message.id); } - Future removeReaction( - String reaction, - Message message, - String userId, - ) async { - final client = ref.watch(ClientController.provider.notifier); - final allReactionEvents = await client.getRelatedEvents( - GetRelatedEventsRequest( - roomId: roomId, - eventId: message.id, - relationType: "m.annotation", - ), - ); - - final reactionEvents = allReactionEvents - ?.where((event) => event.redactedBy == null) - .toIList(); - - final reactionEvent = reactionEvents?.firstWhereOrNull( - (event) => - event.authorId == userId && - event.content["m.relates_to"]?["key"] == reaction, - ); - - if (reactionEvent != null) { - await ref - .watch(ClientController.provider.notifier) - .redactEvent( - RedactEventRequest(eventId: reactionEvent.eventId, roomId: roomId), - ); - } - } - - Future sendReaction(String reaction, Message message) async { - final client = ref.watch(ClientController.provider.notifier); - - await client.sendEvent( - SendEventRequest( - roomId: roomId, - type: "m.reaction", - content: { - "m.relates_to": { - "event_id": message.id, - "rel_type": "m.annotation", - "key": reaction, - }, - }, - synchronous: true, - disableEncryption: true, - ), - ); - } - static final provider = AsyncNotifierProvider.family .autoDispose( RoomChatController.new, diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart index 7013de0..27eb18e 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -11,11 +11,7 @@ class RoomsController extends Notifier> { @override IMap build() => const IMap.empty(); - void update( - IMap rooms, - ISet leftRooms, { - bool addToNewEvents = true, - }) { + void update(IMap rooms, ISet leftRooms) { final homeserver = ref.watch( ClientStateController.provider.select( @@ -33,20 +29,18 @@ class RoomsController extends Notifier> { (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(), - ); - } + ref + .watch(NewEventsController.provider(roomId).notifier) + .add( + incoming.timeline + .map( + (timelineTuple) => events?.firstWhereOrNull( + (event) => timelineTuple.eventRowId == event.rowId, + ), + ) + .nonNulls + .toIList(), + ); return acc.add( roomId, diff --git a/lib/controllers/sync_status_controller.dart b/lib/controllers/sync_status_controller.dart index 8475d9d..fe65732 100644 --- a/lib/controllers/sync_status_controller.dart +++ b/lib/controllers/sync_status_controller.dart @@ -1,17 +1,11 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/main.dart"; import "package:nexus/models/sync_status.dart"; class SyncStatusController extends Notifier { @override Null build() => null; - void set(SyncStatus newStatus) { - if (newStatus.type == SyncStatusType.permanentlyFailed) { - showError(newStatus.error ?? "Syncing failed"); - } - state = newStatus; - } + void set(SyncStatus newStatus) => state = newStatus; static final provider = NotifierProvider( SyncStatusController.new, diff --git a/lib/controllers/url_preview_controller.dart b/lib/controllers/url_preview_controller.dart deleted file mode 100644 index c2161d5..0000000 --- a/lib/controllers/url_preview_controller.dart +++ /dev/null @@ -1,60 +0,0 @@ -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"; - -class UrlPreviewController extends AsyncNotifier { - final String link; - UrlPreviewController(this.link); - - @override - Future build() async { - final homeserver = ref.watch(ClientStateController.provider)?.homeserverUrl; - - if (homeserver != null && !link.contains("matrix.to")) { - { - final response = await get( - Uri.parse(homeserver) - .resolve("/_matrix/client/v1/media/preview_url") - .replace(queryParameters: {"url": link}), - headers: await ref.watch(HeaderController.provider.future), - ); - - if (response.statusCode == 200) { - final decodedValue = json.decode(response.body); - 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 null; - } - - static final provider = AsyncNotifierProvider.autoDispose - .family( - UrlPreviewController.new, - ); -} diff --git a/lib/controllers/user_controller.dart b/lib/controllers/user_controller.dart deleted file mode 100644 index e7ca973..0000000 --- a/lib/controllers/user_controller.dart +++ /dev/null @@ -1,40 +0,0 @@ -import "dart:async"; -import "package:collection/collection.dart"; -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/membership_status.dart"; - -class UserController extends AsyncNotifier { - final String userId; - UserController(this.userId); - - @override - Future build() async { - final member = await ref.watch( - MembersController.provider.selectAsync( - (value) => - value.firstWhereOrNull((membership) => membership.userId == userId), - ), - ); - - if (member != null) return member; - - final profile = await ref.watch(ProfileController.provider(userId).future); - return Membership( - status: MembershipStatus.leave, - avatarUrl: profile.avatarUrl == null - ? null - : Uri.tryParse(profile.avatarUrl!), - displayName: profile.displayName ?? userId.localpart, - userId: userId, - ); - } - - static final provider = - AsyncNotifierProvider.family( - UserController.new, - ); -} diff --git a/lib/controllers/via_controller.dart b/lib/controllers/via_controller.dart deleted file mode 100644 index b423947..0000000 --- a/lib/controllers/via_controller.dart +++ /dev/null @@ -1,54 +0,0 @@ -import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/models/room.dart"; - -class ViaController extends Notifier { - final Room room; - ViaController(this.room); - - @override - String build() { - final servers = {}; - - void addUserId(String? userId) { - final server = userId?.split(":").lastOrNull; - if (server != null) { - servers.add(server); - } - } - - addUserId(ref.watch(ClientStateController.provider)?.userId); - - final powerLevels = room.events.firstWhereOrNull( - (event) => event.rowId == room.state["m.room.power_levels"]?[""], - ); - - for (final userId in IMap(powerLevels?.content["users"]).keys) { - addUserId(userId); - if (servers.length >= 5) break; - } - - final members = room.state["m.room.member"]?.values.toIList(); - for (var i = 0; servers.length < 5; i++) { - final member = room.events.firstWhereOrNull( - (event) => event.rowId == members?.getOrNull(i), - ); - - if (member?.content["membership"] == "join") { - addUserId(member?.stateKey); - } - - if (members?.getOrNull(i) == null) break; - } - - return servers.isEmpty - ? "" - : "?${servers.map((server) => "via=$server").join("&")}"; - } - - static final provider = NotifierProvider.family( - ViaController.new, - ); -} diff --git a/lib/helpers/extensions/get_localpart.dart b/lib/helpers/extensions/get_localpart.dart deleted file mode 100644 index 445351f..0000000 --- a/lib/helpers/extensions/get_localpart.dart +++ /dev/null @@ -1,3 +0,0 @@ -extension GetLocalpart on String { - String get localpart => substring(1).split(":").first; -} diff --git a/lib/helpers/extensions/join_room_with_snackbars.dart b/lib/helpers/extensions/join_room_with_snackbars.dart new file mode 100644 index 0000000..eaa0659 --- /dev/null +++ b/lib/helpers/extensions/join_room_with_snackbars.dart @@ -0,0 +1,91 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/material.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/controllers/key_controller.dart"; +import "package:nexus/controllers/spaces_controller.dart"; +import "package:nexus/helpers/extensions/link_to_mention.dart"; +import "package:nexus/models/requests/join_room_request.dart"; + +extension JoinRoomWithSnackbars on ClientController { + Future joinRoomWithSnackBars( + BuildContext context, + String roomAlias, + WidgetRef ref, + ) async { + final roomIdOrAlias = roomAlias.mention ?? roomAlias; + // TODO: Parse vias properly + + final scaffoldMessenger = ScaffoldMessenger.of(context); + + final snackbar = scaffoldMessenger.showSnackBar( + SnackBar( + content: Text("Joining room $roomIdOrAlias."), + duration: Duration(days: 999), + ), + ); + + try { + final id = await joinRoom( + JoinRoomRequest( + roomIdOrAlias: roomIdOrAlias, + via: IList(Uri.tryParse(roomAlias)?.queryParametersAll["via"] ?? []), + ), + ); + + snackbar.close(); + + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text("Room $roomIdOrAlias successfully joined."), + action: SnackBarAction( + label: "Open", + onPressed: () async { + final spaces = ref.watch(SpacesController.provider); + final space = spaces.firstWhereOrNull((space) => space.id == id); + + await ref + .watch( + KeyController.provider(KeyController.spaceKey).notifier, + ) + .set( + space?.id ?? + spaces + .firstWhere( + (space) => space.children.any( + (child) => child.metadata?.id == id, + ), + ) + .id, + ); + + if (space == null) { + await ref + .watch( + KeyController.provider(KeyController.roomKey).notifier, + ) + .set(id); + } + }, + ), + ), + ); + } catch (error) { + snackbar.close(); + if (context.mounted) { + scaffoldMessenger.showSnackBar( + SnackBar( + backgroundColor: Theme.of(context).colorScheme.errorContainer, + content: Text( + error.toString(), + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + ), + ); + } + } + } +} diff --git a/lib/helpers/extensions/link_to_mention.dart b/lib/helpers/extensions/link_to_mention.dart index f4868d3..b0e62aa 100644 --- a/lib/helpers/extensions/link_to_mention.dart +++ b/lib/helpers/extensions/link_to_mention.dart @@ -30,8 +30,7 @@ extension LinkToMention on String { final identifier = uri.pathSegments.last; if (identifier.isNotEmpty) { return "${switch (uri.pathSegments.firstOrNull) { - "r" => "#", - "roomid" => "!", + "r" || "roomid" => "#", "u" => "@", _ => "", }}${Uri.decodeComponent(identifier)}"; diff --git a/lib/helpers/extensions/scheme_to_theme.dart b/lib/helpers/extensions/scheme_to_theme.dart index df68a05..aff5d52 100644 --- a/lib/helpers/extensions/scheme_to_theme.dart +++ b/lib/helpers/extensions/scheme_to_theme.dart @@ -7,13 +7,8 @@ extension SchemeToTheme on ColorScheme { titleSpacing: 0, backgroundColor: surfaceContainerLow, ), - menuTheme: MenuThemeData( - style: MenuStyle( - backgroundColor: WidgetStatePropertyAll(primaryContainer), - ), - ), textTheme: ThemeData( - fontFamilyFallback: ["sans", "emoji"], + fontFamilyFallback: ["sans"], brightness: brightness, ).textTheme, inputDecorationTheme: const InputDecorationTheme( diff --git a/lib/helpers/extensions/show_context_menu.dart b/lib/helpers/extensions/show_context_menu.dart index 7d8cab6..f4762c3 100644 --- a/lib/helpers/extensions/show_context_menu.dart +++ b/lib/helpers/extensions/show_context_menu.dart @@ -9,7 +9,6 @@ extension ShowContextMenu on BuildContext { showMenu( context: this, - constraints: BoxConstraints.loose(Size.infinite), position: RelativeRect.fromLTRB( globalPosition.dx, globalPosition.dy, diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart deleted file mode 100644 index 1698879..0000000 --- a/lib/helpers/extensions/show_user_popover.dart +++ /dev/null @@ -1,18 +0,0 @@ -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"; - -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)), - ), - ], - ); -} diff --git a/lib/main.dart b/lib/main.dart index 846f075..192ca29 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ 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/header_controller.dart"; +import "package:nexus/controllers/init_complete_controller.dart"; import "package:nexus/controllers/multi_provider_controller.dart"; import "package:nexus/controllers/shared_prefs_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; @@ -126,7 +127,9 @@ class App extends StatelessWidget { } else if (!clientState.isVerified) { return VerifyPage(); } else { - return ChatPage(); + return ref.watch(InitCompleteController.provider) + ? ChatPage() + : Loading(); } }, ), diff --git a/lib/models/configs/author_config.dart b/lib/models/configs/author_config.dart new file mode 100644 index 0000000..af63c63 --- /dev/null +++ b/lib/models/configs/author_config.dart @@ -0,0 +1,14 @@ +import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/room.dart"; +part "author_config.freezed.dart"; +part "author_config.g.dart"; + +@freezed +abstract class AuthorConfig with _$AuthorConfig { + const factory AuthorConfig({required Message message, required Room room}) = + _AuthorConfig; + + factory AuthorConfig.fromJson(Map json) => + _$AuthorConfigFromJson(json); +} diff --git a/lib/models/configs/message_config.dart b/lib/models/configs/message_config.dart index 66a437c..9020f78 100644 --- a/lib/models/configs/message_config.dart +++ b/lib/models/configs/message_config.dart @@ -18,10 +18,10 @@ abstract class MessageConfig with _$MessageConfig { bool operator ==(Object other) => other.runtimeType == runtimeType && other is MessageConfig && - other.event == event; + other.event.eventId == event.eventId; @override - int get hashCode => Object.hash(runtimeType, event); + int get hashCode => Object.hash(runtimeType, event.eventId); factory MessageConfig.fromJson(Map json) => _$MessageConfigFromJson(json); diff --git a/lib/models/configs/power_level_config.dart b/lib/models/configs/power_level_config.dart deleted file mode 100644 index 31cc08c..0000000 --- a/lib/models/configs/power_level_config.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/requests/membership_action.dart"; -part "power_level_config.freezed.dart"; -part "power_level_config.g.dart"; - -@freezed -abstract class PowerLevelConfig with _$PowerLevelConfig { - const factory PowerLevelConfig({ - @Default(false) bool isStateEvent, - required String eventType, - MembershipAction? action, - String? targetUser, - }) = _PowerLevelConfig; - - factory PowerLevelConfig.fromJson(Map json) => - _$PowerLevelConfigFromJson(json); -} diff --git a/lib/models/membership.dart b/lib/models/membership.dart index ce0cc42..4e2bf4c 100644 --- a/lib/models/membership.dart +++ b/lib/models/membership.dart @@ -1,14 +1,12 @@ 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, @@ -19,10 +17,6 @@ abstract class Membership with _$Membership { 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), diff --git a/lib/models/membership_status.dart b/lib/models/membership_status.dart deleted file mode 100644 index bc85e22..0000000 --- a/lib/models/membership_status.dart +++ /dev/null @@ -1,4 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -@JsonEnum() -enum MembershipStatus { leave, invite, ban, join } diff --git a/lib/models/profile.dart b/lib/models/profile.dart index 584f27b..d92b4f6 100644 --- a/lib/models/profile.dart +++ b/lib/models/profile.dart @@ -3,22 +3,15 @@ import "package:freezed_annotation/freezed_annotation.dart"; part "profile.freezed.dart"; part "profile.g.dart"; -Object? readPronouns(Map map, _) => - map["m.pronouns"] ?? map["io.fsky.nyx.pronouns"]; - -Object? readTimezone(Map map, _) => - map["m.tz"] ?? map["us.cloke.msc4175.tz"]; - @freezed abstract class Profile with _$Profile { const factory Profile({ String? avatarUrl, @JsonKey(name: "displayname") String? displayName, - - @JsonKey(readValue: readTimezone) String? timezone, + @JsonKey(name: "us.cloke.msc4175.tz") String? timezone, @Default(IList.empty()) - @JsonKey(readValue: readPronouns) + @JsonKey(name: "io.fsky.nyx.pronouns") IList pronouns, }) = _Profile; diff --git a/lib/models/requests/get_room_state_request.dart b/lib/models/requests/get_room_state_request.dart index 8ee05f0..de66b72 100644 --- a/lib/models/requests/get_room_state_request.dart +++ b/lib/models/requests/get_room_state_request.dart @@ -6,7 +6,6 @@ part "get_room_state_request.g.dart"; abstract class GetRoomStateRequest with _$GetRoomStateRequest { const factory GetRoomStateRequest({ required String roomId, - @Default(false) bool refetch, @Default(false) bool fetchMembers, @Default(false) bool includeMembers, }) = _GetRoomStateRequest; diff --git a/lib/models/requests/membership_action.dart b/lib/models/requests/membership_action.dart deleted file mode 100644 index d852164..0000000 --- a/lib/models/requests/membership_action.dart +++ /dev/null @@ -1,4 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -@JsonEnum() -enum MembershipAction { ban, kick, unban, invite } diff --git a/lib/models/requests/send_event_request.dart b/lib/models/requests/send_event_request.dart deleted file mode 100644 index da5de32..0000000 --- a/lib/models/requests/send_event_request.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "send_event_request.freezed.dart"; -part "send_event_request.g.dart"; - -@freezed -abstract class SendEventRequest with _$SendEventRequest { - const factory SendEventRequest({ - required String roomId, - required String type, - required Map content, - @Default(false) bool synchronous, - @Default(false) bool disableEncryption, - }) = _SendEventRequest; - - factory SendEventRequest.fromJson(Map json) => - _$SendEventRequestFromJson(json); -} diff --git a/lib/models/requests/set_membership_request.dart b/lib/models/requests/set_membership_request.dart deleted file mode 100644 index dd0e1f2..0000000 --- a/lib/models/requests/set_membership_request.dart +++ /dev/null @@ -1,19 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/requests/membership_action.dart"; -part "set_membership_request.freezed.dart"; -part "set_membership_request.g.dart"; - -@freezed -abstract class SetMembershipRequest with _$SetMembershipRequest { - const factory SetMembershipRequest({ - required String userId, - required String roomId, - - String? reason, - @JsonKey(name: "action") required MembershipAction action, - @Default(false) @JsonKey(name: "msc4293_redact_events") bool redact, - }) = _SetMembershipRequest; - - factory SetMembershipRequest.fromJson(Map json) => - _$SetMembershipRequestFromJson(json); -} diff --git a/lib/models/sync_status.dart b/lib/models/sync_status.dart index 7848fbe..42c5f2a 100644 --- a/lib/models/sync_status.dart +++ b/lib/models/sync_status.dart @@ -14,5 +14,5 @@ abstract class SyncStatus with _$SyncStatus { _$SyncStatusFromJson(json); } -@JsonEnum(fieldRename: FieldRename.kebab) +@JsonEnum(fieldRename: FieldRename.snake) enum SyncStatusType { ok, waiting, erroring, permanentlyFailed } diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index 671891c..7aa8156 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -1,10 +1,7 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/init_complete_controller.dart"; -import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/chat_page/sidebar.dart"; import "package:nexus/widgets/chat_page/room_chat.dart"; -import "package:nexus/widgets/loading.dart"; class ChatPage extends ConsumerWidget { const ChatPage({super.key}); @@ -14,33 +11,22 @@ class ChatPage extends ConsumerWidget { builder: (context, constraints) { final isDesktop = constraints.maxWidth > 650; final showMembersByDefault = constraints.maxWidth > 1000; - final initComplete = ref.watch(InitCompleteController.provider); 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, - ), - ), - ], - ), - ) - : Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [Loading(), Text("Syncing...")], + body: Builder( + builder: (context) => Row( + children: [ + if (isDesktop) Sidebar(isDesktop: isDesktop), + Expanded( + child: RoomChat( + isDesktop: isDesktop, + showMembersByDefault: showMembersByDefault, ), ), - drawer: isDesktop || !initComplete - ? null - : Sidebar(isDesktop: isDesktop), + ], + ), + ), + drawer: isDesktop ? null : Sidebar(isDesktop: isDesktop), ); }, ); diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatar_or_hash.dart index 28662e2..147c249 100644 --- a/lib/widgets/avatar_or_hash.dart +++ b/lib/widgets/avatar_or_hash.dart @@ -50,7 +50,7 @@ class AvatarOrHash extends ConsumerWidget { ref.watch(CrossCacheController.provider), headers: ref.headers, ), - fit: BoxFit.cover, + fit: BoxFit.contain, errorBuilder: (_, _, _) => box, ), ), diff --git a/lib/widgets/chat_page/composer/chat_box.dart b/lib/widgets/chat_page/composer/chat_box.dart index 478974e..a688fa7 100644 --- a/lib/widgets/chat_page/composer/chat_box.dart +++ b/lib/widgets/chat_page/composer/chat_box.dart @@ -1,33 +1,25 @@ -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_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/controllers/room_chat_controller.dart"; import "package:nexus/models/relation_type.dart"; +import "package:nexus/models/room.dart"; import "package:nexus/widgets/chat_page/composer/mention_overlay.dart"; import "package:nexus/widgets/chat_page/composer/relation_preview.dart"; -import "package:nexus/widgets/chat_page/emoji_picker_button.dart"; class ChatBox extends HookConsumerWidget { final Message? relatedMessage; final RelationType relationType; final VoidCallback onDismiss; - final FocusNode? node; - final Future Function( - String text, { - required bool shouldMention, - required IList tags, - }) - onSend; + final Room room; const ChatBox({ required this.relatedMessage, required this.relationType, required this.onDismiss, - required this.onSend, - this.node, + required this.room, super.key, }); @@ -46,28 +38,37 @@ class ChatBox extends HookConsumerWidget { } void send() { - if (controller.value.text.isEmpty) return; - onSend( - controller.value.formattedText, - shouldMention: shouldMention.value, - tags: controller.value.tags.toIList(), - ); - + if (controller.value.text.trim().isEmpty || room.metadata == null) return; + ref + .watch(RoomChatController.provider(room.metadata!.id).notifier) + .send( + controller.value.formattedText, + shouldMention: shouldMention.value, + relation: relatedMessage, + relationType: relationType, + tags: controller.value.tags, + ); onDismiss(); controller.value.text = ""; } + final node = useFocusNode( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { + onDismiss(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + }, + ); + final style = TextStyle( color: theme.colorScheme.primary, fontWeight: FontWeight.bold, ); - final canSendMessages = ref.watch( - PowerLevelController.provider( - PowerLevelConfig(eventType: "m.room.message"), - ), - ); - return Positioned( bottom: 0, left: 0, @@ -80,6 +81,7 @@ class ChatBox extends HookConsumerWidget { children: [ RelationPreview( relatedMessage, + room: room, shouldMention: shouldMention.value, toggleShouldMention: () => shouldMention.value = !shouldMention.value, @@ -92,14 +94,8 @@ class ChatBox extends HookConsumerWidget { child: Row( spacing: 8, children: [ - EmojiPickerButton( - context: context, - onSelection: (_) => node?.requestFocus(), - controller: controller.value, - ), PopupMenuButton( tooltip: "Add media", - enabled: canSendMessages, itemBuilder: (context) => [ PopupMenuItem( child: ListTile( @@ -121,16 +117,18 @@ class ChatBox extends HookConsumerWidget { ), ], icon: Icon(Icons.add), + // enabled: room.canSendDefaultMessages, TODO: Permissions check ), Expanded( child: FlutterTagger( triggerStrategy: TriggerStrategy.eager, overlay: MentionOverlay( + room, query: query.value, triggerCharacter: triggerCharacter.value, addTag: ({required id, required name}) { controller.value.addTag(id: id, name: name); - node?.requestFocus(); + node.requestFocus(); }, ), controller: controller.value, @@ -140,28 +138,28 @@ class ChatBox extends HookConsumerWidget { }, triggerCharacterAndStyles: {"@": style, "#": style}, builder: (context, key) => TextFormField( - enabled: canSendMessages, + // enabled: room.canSendDefaultMessages, maxLines: 12, minLines: 1, - autofocus: true, decoration: InputDecoration( - hintText: canSendMessages + hintText: + true // TODO: room.canSendDefaultMessages ? "Your message here..." : "You don't have permission to send messages in this room...", border: InputBorder.none, ), controller: controller.value, key: key, + // TODO: Setting for send on enter on / off onFieldSubmitted: (_) => send(), - // Don't defocus on submit - onEditingComplete: () {}, textInputAction: TextInputAction.done, focusNode: node, ), ), ), IconButton( - onPressed: !canSendMessages ? null : send, + onPressed: send, + // onPressed: room.canSendDefaultMessages ? send : null, icon: Icon(Icons.send), tooltip: "Send message", ), diff --git a/lib/widgets/chat_page/composer/mention_overlay.dart b/lib/widgets/chat_page/composer/mention_overlay.dart index b650421..d95253d 100644 --- a/lib/widgets/chat_page/composer/mention_overlay.dart +++ b/lib/widgets/chat_page/composer/mention_overlay.dart @@ -1,18 +1,19 @@ 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_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/controllers/via_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/membership_status.dart"; +import "package:nexus/models/room.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/loading.dart"; class MentionOverlay extends ConsumerWidget { final String? triggerCharacter; final String query; + final Room room; final void Function({required String id, required String name}) addTag; - const MentionOverlay({ + const MentionOverlay( + this.room, { required this.query, required this.addTag, required this.triggerCharacter, @@ -33,9 +34,7 @@ class MentionOverlay extends ConsumerWidget { child: switch (triggerCharacter) { "@" => ref - .watch( - MembersByTypeController.provider(MembershipStatus.join), - ) + .watch(MembersController.provider(room)) .betterWhen( data: (members) => ListView( children: @@ -63,7 +62,7 @@ class MentionOverlay extends ConsumerWidget { title: Text(member.displayName), subtitle: Text(member.userId), onTap: () => addTag( - id: "[@${member.displayName}](matrix:u/${member.userId.substring(1)})", + id: "[@${member.displayName}](https://matrix.to/#/${member.userId})", name: member.userId .substring(1) .split(":") @@ -79,43 +78,33 @@ class MentionOverlay extends ConsumerWidget { (query.isEmpty ? rooms.values : rooms.values.where( - (room) => - (room.metadata?.name ?? room.metadata!.id) - .toLowerCase() - .contains(query.toLowerCase()), + (room) => (room.metadata?.name ?? "Unnamed Room") + .toLowerCase() + .contains(query.toLowerCase()), )) - .map((room) { - final name = - room.metadata?.name ?? - room.metadata!.canonicalAlias ?? - room.metadata!.id; - return ListTile( + .map( + (room) => ListTile( leading: AvatarOrHash( room.metadata?.avatar, - name, + room.metadata?.name ?? "Unnamed Room", fallback: Icon(Icons.numbers), ), - title: Text(name), + title: Text(room.metadata?.name ?? "Unnamed Room"), subtitle: room.metadata?.topic == null ? null : Text(room.metadata!.topic!, maxLines: 1), - onTap: () { - final vias = ref.watch( - ViaController.provider(room), - ); - addTag( - id: "[#$name](matrix:roomid/${room.metadata?.id.substring(1)}$vias)", - name: - (room.metadata?.canonicalAlias ?? - room.metadata?.id) - ?.substring(1) - .split(":") - .first ?? - "", - ); - }, - ); - }) + onTap: () => addTag( + id: "[#${room.metadata?.name ?? "Unnamed Room"}](https://matrix.to/#/${room.metadata?.id})", + name: + (room.metadata?.canonicalAlias ?? + room.metadata?.id) + ?.substring(1) + .split(":") + .first ?? + "", + ), + ), + ) .toList(), ), diff --git a/lib/widgets/chat_page/composer/relation_preview.dart b/lib/widgets/chat_page/composer/relation_preview.dart index c90b07b..7fded20 100644 --- a/lib/widgets/chat_page/composer/relation_preview.dart +++ b/lib/widgets/chat_page/composer/relation_preview.dart @@ -2,6 +2,7 @@ import "package:flutter/material.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/models/relation_type.dart"; +import "package:nexus/models/room.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; @@ -11,9 +12,11 @@ class RelationPreview extends ConsumerWidget { final VoidCallback onDismiss; final bool shouldMention; final VoidCallback toggleShouldMention; + final Room room; const RelationPreview( this.relatedMessage, { + required this.room, required this.relationType, required this.onDismiss, required this.shouldMention, @@ -32,38 +35,27 @@ class RelationPreview extends ConsumerWidget { child: Row( spacing: 8, children: [ + SizedBox(width: 4), if (relationType == RelationType.edit) Text( "Editing message:", style: TextStyle(fontWeight: FontWeight.bold), ), - - MessageAvatar(relatedMessage!), - + MessageAvatar(relatedMessage!, room), + MessageDisplayname( + relatedMessage!, + room, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), 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: Text( + relatedMessage?.metadata?["body"] ?? + relatedMessage?.metadata?["eventType"], + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium, + maxLines: 1, ), ), @@ -78,12 +70,11 @@ class RelationPreview extends ConsumerWidget { ), ), ), - IconButton( tooltip: "Cancel ${relationType == RelationType.edit ? "edit" : "reply"}", onPressed: onDismiss, - icon: const Icon(Icons.close), + icon: Icon(Icons.close), iconSize: 20, ), ], diff --git a/lib/widgets/chat_page/emoji_picker_button.dart b/lib/widgets/chat_page/emoji_picker_button.dart deleted file mode 100644 index 0c43c48..0000000 --- a/lib/widgets/chat_page/emoji_picker_button.dart +++ /dev/null @@ -1,41 +0,0 @@ -import "package:emoji_text_field/emoji_text_field.dart"; -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; - -class EmojiPickerButton extends HookWidget { - final TextEditingController? controller; - final void Function(String emoji)? onSelection; - final VoidCallback? onPressed; - final BuildContext context; - const EmojiPickerButton({ - this.controller, - this.onPressed, - this.onSelection, - required this.context, - super.key, - }); - - @override - Widget build(_) => IconButton( - onPressed: () { - onPressed?.call(); - final controller = this.controller ?? TextEditingController(); - showModalBottomSheet( - context: context, - builder: (context) => EmojiKeyboardView( - config: EmojiViewConfig( - showRecentTab: false, - backgroundColor: Theme.of(context).colorScheme.surfaceContainer, - height: 600, - ), - textController: controller - ..addListener(() async { - Navigator.of(context).pop(); - onSelection?.call(controller.text); - }), - ), - ); - }, - icon: Icon(Icons.emoji_emotions), - ); -} diff --git a/lib/widgets/chat_page/html/html.dart b/lib/widgets/chat_page/html/html.dart index fb533ad..dcc1d49 100644 --- a/lib/widgets/chat_page/html/html.dart +++ b/lib/widgets/chat_page/html/html.dart @@ -1,15 +1,12 @@ -import "package:cross_cache/cross_cache.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.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/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"; @@ -33,29 +30,20 @@ class Html extends ConsumerWidget { return InlineCustomWidget(child: SpoilerText(text: element.text)); } - final height = - int.tryParse(element.attributes["height"] ?? "") ?? - (element.attributes.keys.contains("data-mx-emoticon") ? 32 : null) ?? - 300; + final height = int.tryParse(element.attributes["height"] ?? "") ?? 300; final width = int.tryParse(element.attributes["width"] ?? ""); - final src = Uri.tryParse(element.attributes["src"] ?? "") - ?.mxcToHttps( - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - "", - ) - .toString(); return switch (element.localName) { "code" => element.parent?.localName == "pre" - ? CodeBlock( - element.text, - lang: element.className.replaceAll("language-", ""), - ) + ? element.outerHtml.contains("
") + ? Html( + """
${element.outerHtml.replaceAll("
", "\n")}
""", + ) + : CodeBlock( + element.text, + lang: element.className.replaceAll("language-", ""), + ) : null, "blockquote" => Quoted(Html(element.innerHtml)), @@ -63,40 +51,39 @@ class Html extends ConsumerWidget { "a" => element.attributes["href"]?.mention == null ? null - : InlineCustomWidget( - child: MentionChip(element.attributes["href"]!), - ), + : InlineCustomWidget(child: MentionChip(element.text)), "img" => - src == null + element.attributes["src"] == null ? SizedBox.shrink() : InlineCustomWidget( alignment: PlaceholderAlignment.middle, - child: ExpandableImage( - src, - child: Image( - image: CachedNetworkImage( - src, - ref.watch(CrossCacheController.provider), - headers: ref.headers, + child: Image.network( + Uri.parse(element.attributes["src"]!) + .mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value?.homeserverUrl, + ), + ) ?? + "", + ) + .toString(), + headers: ref.headers, + errorBuilder: (_, error, _) => Text( + "Image Failed to Load", + style: TextStyle( + color: Theme.of(context).colorScheme.error, ), - errorBuilder: (_, error, _) => Text( - "Image Failed to Load", - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - height: height.toDouble(), - width: width?.toDouble(), - loadingBuilder: (_, child, loadingProgress) => - loadingProgress == null - ? child - : CircularProgressIndicator(), ), + height: height.toDouble(), + width: width?.toDouble(), + loadingBuilder: (_, child, loadingProgress) => + loadingProgress == null + ? child + : CircularProgressIndicator(), ), ), - - // Allowed elements list ("del" || "h1" || "h2" || diff --git a/lib/widgets/chat_page/html/mention_chip.dart b/lib/widgets/chat_page/html/mention_chip.dart index 575ad03..c2b832d 100644 --- a/lib/widgets/chat_page/html/mention_chip.dart +++ b/lib/widgets/chat_page/html/mention_chip.dart @@ -1,44 +1,25 @@ 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}); +class MentionChip extends StatelessWidget { + final String label; + const MentionChip(this.label, {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, - ), + Widget build(BuildContext context) => ActionChip( + label: Text( + label.mention ?? label, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimary, ), - ); - } + ), + backgroundColor: Theme.of(context).colorScheme.primary, + onPressed: () => showDialog( + context: context, + builder: (_) => Dialog( + child: Text("TODO: Open room or join room dialog, or user popover"), + ), + ), + ); } diff --git a/lib/widgets/chat_page/join_dialog.dart b/lib/widgets/chat_page/join_dialog.dart deleted file mode 100644 index e718200..0000000 --- a/lib/widgets/chat_page/join_dialog.dart +++ /dev/null @@ -1,137 +0,0 @@ -import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/spaces_controller.dart"; -import "package:nexus/helpers/extensions/link_to_mention.dart"; -import "package:nexus/models/requests/join_room_request.dart"; -import "package:nexus/widgets/form_text_input.dart"; - -class JoinDialog extends HookWidget { - final WidgetRef ref; - const JoinDialog(this.ref, {super.key}); - - @override - Widget build(BuildContext context) { - final roomAlias = useTextEditingController(); - return AlertDialog( - title: Text("Join a Room"), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Enter the room alias, Matrix URI, or Matrix.to link."), - SizedBox(height: 12), - FormTextInput( - required: false, - capitalize: true, - controller: roomAlias, - title: "#room:server", - ), - ], - ), - actions: [ - TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - - if (context.mounted) { - final roomIdOrAlias = roomAlias.text.mention ?? roomAlias.text; - - final scaffoldMessenger = ScaffoldMessenger.of(context); - - final snackbar = scaffoldMessenger.showSnackBar( - SnackBar( - content: Text("Joining room $roomIdOrAlias."), - duration: Duration(days: 999), - ), - ); - - try { - final id = await ref - .watch(ClientController.provider.notifier) - .joinRoom( - JoinRoomRequest( - roomIdOrAlias: roomIdOrAlias, - via: IList( - Uri.tryParse( - roomAlias.text.replaceAll("/#", ""), - )?.queryParametersAll["via"] ?? - [], - ), - ), - ); - - snackbar.close(); - - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text("Room $roomIdOrAlias successfully joined."), - action: SnackBarAction( - label: "Open", - onPressed: () async { - final spaces = ref.watch(SpacesController.provider); - final space = spaces.firstWhereOrNull( - (space) => space.id == id, - ); - - await ref - .watch( - KeyController.provider( - KeyController.spaceKey, - ).notifier, - ) - .set( - space?.id ?? - spaces - .firstWhere( - (space) => space.children.any( - (child) => child.metadata?.id == id, - ), - ) - .id, - ); - - if (space == null) { - await ref - .watch( - KeyController.provider( - KeyController.roomKey, - ).notifier, - ) - .set(id); - } - }, - ), - ), - ); - } catch (error) { - snackbar.close(); - if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - backgroundColor: Theme.of( - context, - ).colorScheme.errorContainer, - content: Text( - error.toString(), - style: TextStyle( - color: Theme.of(context).colorScheme.onErrorContainer, - ), - ), - ), - ); - } - } - } - }, - child: Text("Join"), - ), - ], - ); - } -} diff --git a/lib/widgets/chat_page/lazy_loading/message_avatar.dart b/lib/widgets/chat_page/lazy_loading/message_avatar.dart index dc8dfef..71fcf84 100644 --- a/lib/widgets/chat_page/lazy_loading/message_avatar.dart +++ b/lib/widgets/chat_page/lazy_loading/message_avatar.dart @@ -1,30 +1,28 @@ -import "package:flutter/material.dart"; +import "package:flutter/widgets.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/show_user_popover.dart"; +import "package:nexus/models/configs/author_config.dart"; +import "package:nexus/models/room.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; class MessageAvatar extends ConsumerWidget { final Message message; + final Room room; final double height; - const MessageAvatar(this.message, {this.height = 16, super.key}); + const MessageAvatar(this.message, this.room, {this.height = 16, super.key}); @override Widget build(BuildContext context, WidgetRef ref) => ref - .watch(AuthorController.provider(message)) + .watch( + AuthorController.provider(AuthorConfig(room: room, message: message)), + ) .betterWhen( - data: (membership) => InkWell( - onTapUp: (details) => context.showUserPopover( - membership, - globalPosition: details.globalPosition, - ), - child: AvatarOrHash( - membership.avatarUrl, - membership.displayName, - height: height, - ), + data: (membership) => AvatarOrHash( + membership.avatarUrl, + membership.displayName, + height: height, ), loading: () => AvatarOrHash(null, message.authorId.substring(1), height: height), diff --git a/lib/widgets/chat_page/lazy_loading/message_displayname.dart b/lib/widgets/chat_page/lazy_loading/message_displayname.dart index 88d2fa6..7c10df3 100644 --- a/lib/widgets/chat_page/lazy_loading/message_displayname.dart +++ b/lib/widgets/chat_page/lazy_loading/message_displayname.dart @@ -1,37 +1,27 @@ -import "package:flutter/material.dart"; +import "package:flutter/widgets.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/show_user_popover.dart"; +import "package:nexus/models/configs/author_config.dart"; +import "package:nexus/models/room.dart"; class MessageDisplayname extends ConsumerWidget { final Message message; + final Room room; final TextStyle? style; - final bool clickable; - const MessageDisplayname( - this.message, { - this.clickable = true, - this.style, - super.key, - }); + const MessageDisplayname(this.message, this.room, {this.style, super.key}); @override Widget build(BuildContext context, WidgetRef ref) => ref - .watch(AuthorController.provider(message)) + .watch( + AuthorController.provider(AuthorConfig(room: room, message: message)), + ) .betterWhen( - data: (membership) => InkWell( - onTapUp: clickable - ? (details) => context.showUserPopover( - membership, - globalPosition: details.globalPosition, - ) - : null, - child: Text( - "${membership.displayName}${message.metadata?["pmp"] == null ? "" : " (via ${message.authorId})"}", - style: style, - overflow: TextOverflow.ellipsis, - ), + data: (membership) => Text( + "${membership.displayName} ${message.metadata?["pmp"] == null ? "" : "(via ${message.authorId})"}", + style: style, + overflow: TextOverflow.ellipsis, ), loading: () => Text(""), ); diff --git a/lib/widgets/chat_page/member_list.dart b/lib/widgets/chat_page/member_list.dart index 8be1ddd..8cdbbb9 100644 --- a/lib/widgets/chat_page/member_list.dart +++ b/lib/widgets/chat_page/member_list.dart @@ -1,31 +1,27 @@ import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/members_by_type_controller.dart"; +import "package:nexus/controllers/members_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/models/room.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; -class MemberList extends HookConsumerWidget { - const MemberList({super.key}); +class MemberList extends ConsumerWidget { + final Room room; + const MemberList(this.room, {super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final status = useState(MembershipStatus.join); - final membersProvider = ref.watch( - MembersByTypeController.provider(status.value), - ); - + final membersProvider = ref.watch(MembersController.provider(room)); return Drawer( shape: Border(), child: Column( - spacing: 8, children: [ AppBar( scrolledUnderElevation: 0, leading: Icon(Icons.people), - title: Text("Members"), + title: Text( + "Members ${membersProvider.when(data: (members) => "${members.length}", error: (_, _) => "", loading: () => "")}", + ), actionsPadding: EdgeInsets.only(right: 4), actions: [ if (Scaffold.of(context).hasEndDrawer) @@ -36,50 +32,28 @@ class MemberList extends HookConsumerWidget { ), ], ), - 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, + (member) => ListTile( + onTap: () => showDialog( + context: context, + builder: (context) => + Dialog(child: Text("TODO: Open member popover")), ), - child: ListTile( - leading: AvatarOrHash( - member.avatarUrl, - member.displayName, - ), - title: Text( - member.displayName, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - member.userId, - overflow: TextOverflow.ellipsis, - ), + leading: AvatarOrHash( + member.avatarUrl, + member.displayName, + ), + title: Text( + member.displayName, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + member.userId, + overflow: TextOverflow.ellipsis, ), ), ) diff --git a/lib/widgets/chat_page/reply_widget.dart b/lib/widgets/chat_page/reply_widget.dart index b999be4..b9fa2e1 100644 --- a/lib/widgets/chat_page/reply_widget.dart +++ b/lib/widgets/chat_page/reply_widget.dart @@ -3,10 +3,10 @@ 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/models/room.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"; @@ -16,10 +16,12 @@ typedef OnTapReply = void Function(Message message)?; class ReplyWidget extends ConsumerWidget { final Message message; final bool alwaysShow; + final Room room; final MessageGroupStatus? groupStatus; final OnTapReply onTapReply; const ReplyWidget( this.message, { + required this.room, required this.groupStatus, this.onTapReply, this.alwaysShow = false, @@ -27,75 +29,73 @@ class ReplyWidget extends ConsumerWidget { }); @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!, - ), + Widget build(BuildContext context, WidgetRef ref) => + message.replyToMessageId == 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, - ), - ), - ], - ), - ); - }, - ), ), - ), - ); - } + ) + .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, room), + Flexible( + child: MessageDisplayname( + replyMessage, + room, + 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_appbar.dart b/lib/widgets/chat_page/room_appbar.dart index 62e282d..03cd994 100644 --- a/lib/widgets/chat_page/room_appbar.dart +++ b/lib/widgets/chat_page/room_appbar.dart @@ -1,20 +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/models/room.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"; -class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { +class RoomAppbar extends StatelessWidget implements PreferredSizeWidget { final bool isDesktop; - final void Function(BuildContext context)? onOpenMemberList; + final Room room; + final void Function(BuildContext context) onOpenMemberList; final void Function(BuildContext context) onOpenDrawer; - const RoomAppbar({ + const RoomAppbar( + this.room, { required this.isDesktop, + required this.onOpenMemberList, required this.onOpenDrawer, - this.onOpenMemberList, super.key, }); @@ -22,57 +23,50 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { Size get preferredSize => AppBar().preferredSize; @override - Widget build(BuildContext context, WidgetRef ref) { - final room = ref.watch(SelectedRoomController.provider); - return Appbar( - leading: isDesktop - ? room == null - ? null - : ExpandableImage( - room.metadata?.avatar?.toString(), - child: AvatarOrHash( - room.metadata?.avatar, - room.metadata?.name ?? "Unnamed Rooms", - height: 24, - fallback: Icon(Icons.numbers), - ), - ) - : DrawerButton(onPressed: () => onOpenDrawer(context)), - scrolledUnderElevation: 0, - title: room == null - ? null - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - room.metadata?.name ?? "Unnamed Room", - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - if (room.metadata?.topic?.isNotEmpty == true) - Text( - room.metadata!.topic!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], + Widget build(BuildContext context) => Appbar( + leading: isDesktop + ? ExpandableImage( + room.metadata?.avatar?.toString(), + child: AvatarOrHash( + room.metadata?.avatar, + room.metadata?.name ?? "Unnamed Rooms", + height: 24, + fallback: Icon(Icons.numbers), ), - actions: [ - IconButton( - onPressed: null, - icon: Icon(Icons.push_pin), - tooltip: "Open pinned messages", + ) + : DrawerButton(onPressed: () => onOpenDrawer(context)), + scrolledUnderElevation: 0, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + room.metadata?.name ?? "Unnamed Room", + overflow: TextOverflow.ellipsis, + maxLines: 1, ), - IconButton( - onPressed: () => onOpenMemberList?.call(context), - tooltip: "Open member list", - icon: Icon(Icons.people), - ), - if (room != null) RoomMenu(room), - ].toIList(), - ); - } + if (room.metadata?.topic?.isNotEmpty == true) + Text( + room.metadata!.topic!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + actions: [ + IconButton( + onPressed: null, + icon: Icon(Icons.push_pin), + tooltip: "Open pinned messages", + ), + IconButton( + onPressed: () => onOpenMemberList(context), + tooltip: "Open member list", + icon: Icon(Icons.people), + ), + RoomMenu(room), + ].toIList(), + ); } diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 5166d87..cfbd1a8 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -1,26 +1,19 @@ -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"; @@ -28,7 +21,7 @@ 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"; +// import "package:dynamic_polls/dynamic_polls.dart"; class RoomChat extends HookConsumerWidget { final bool isDesktop; @@ -42,128 +35,46 @@ class RoomChat extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final client = ref.watch(ClientController.provider.notifier); - final relatedMessage = useState(null); + final replyToMessage = useState(null); final memberListOpened = useState(showMembersByDefault); final relationType = useState(RelationType.reply); + final room = ref.watch(SelectedRoomController.provider); 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, - ), + if (room == null || userId == null || room.metadata?.id == null) { + return Center( + child: Text( + "Nothing to see here...", + style: theme.textTheme.headlineMedium, ), ); } - final controllerProvider = RoomChatController.provider(roomId); + final controllerProvider = RoomChatController.provider(room.metadata!.id); 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 [ - 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), - ), - ], - ), - ), PopupMenuItem( onTap: () { - relatedMessage.value = message; + replyToMessage.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; + replyToMessage.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"), - ), - )) + if (isSentByMe) // TODO: Or if user has permission to redact others' messages PopupMenuItem( onTap: () => showDialog( context: context, @@ -195,13 +106,11 @@ class RoomChat extends HookConsumerWidget { ), TextButton( onPressed: () async { + notifier.deleteMessage( + message, + reason: deleteReasonController.text, + ); Navigator.of(context).pop(); - await notifier - .deleteMessage( - message, - reason: deleteReasonController.text, - ) - .onError(showError); }, child: Text("Delete"), ), @@ -210,10 +119,7 @@ class RoomChat extends HookConsumerWidget { }, ), ), - child: ListTile( - leading: Icon(Icons.delete, color: danger), - title: Text("Delete", style: TextStyle(color: danger)), - ), + child: ListTile(leading: Icon(Icons.delete), title: Text("Delete")), ), PopupMenuItem( onTap: () => showDialog( @@ -247,9 +153,10 @@ class RoomChat extends HookConsumerWidget { ), TextButton( onPressed: () { + if (room.metadata == null) return; client.reportEvent( ReportRequest( - roomId: roomId, + roomId: room.metadata!.id, eventId: message.id, reason: reasonController.text.isEmpty ? null @@ -282,6 +189,7 @@ class RoomChat extends HookConsumerWidget { return Scaffold( appBar: RoomAppbar( + room, isDesktop: isDesktop, onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), onOpenMemberList: (thisContext) { @@ -329,42 +237,18 @@ class RoomChat extends HookConsumerWidget { chatAnimatedListBuilder: (_, itemBuilder) => ChatAnimatedList( itemBuilder: itemBuilder, - onEndReached: - ref.watch( - SelectedRoomController.provider.select( - (room) => room?.hasMore == true, - ), - ) + onEndReached: room.hasMore ? notifier.loadOlder : null, - onStartReached: () async { - final room = ref.watch( - SelectedRoomController.provider, - ); - return room == null - ? null - : await client.markRead(room); - }, + onStartReached: () => 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, - ), relationType: relationType.value, - relatedMessage: relatedMessage.value, - onDismiss: () => relatedMessage.value = null, + relatedMessage: replyToMessage.value, + onDismiss: () => replyToMessage.value = null, + room: room, ), textMessageBuilder: @@ -375,6 +259,7 @@ class RoomChat extends HookConsumerWidget { required bool isSentByMe, MessageGroupStatus? groupStatus, }) => TextMessageWrapper( + room: room, message, content: message.text, groupStatus: groupStatus, @@ -392,6 +277,7 @@ class RoomChat extends HookConsumerWidget { MessageGroupStatus? groupStatus, }) => TextMessageWrapper( message, + room: room, content: message.text, groupStatus: groupStatus, onTapReply: notifier.scrollToMessage, @@ -423,6 +309,7 @@ class RoomChat extends HookConsumerWidget { ), child: FlyerChatFileMessage( topWidget: ReplyWidget( + room: room, message, onTapReply: notifier.scrollToMessage, groupStatus: groupStatus, @@ -432,6 +319,7 @@ class RoomChat extends HookConsumerWidget { ), ), groupStatus, + room, ), systemMessageBuilder: @@ -460,7 +348,7 @@ class RoomChat extends HookConsumerWidget { ), ), ), - resolveUser: (_) async => null, + resolveUser: notifier.resolveUser, chatController: controller, ), ), @@ -470,11 +358,11 @@ class RoomChat extends HookConsumerWidget { ), if (memberListOpened.value == true && showMembersByDefault) - MemberList(), + MemberList(room), ], ), - endDrawer: showMembersByDefault ? null : MemberList(), + endDrawer: showMembersByDefault ? null : MemberList(room), ); } } diff --git a/lib/widgets/chat_page/room_menu.dart b/lib/widgets/chat_page/room_menu.dart index 4405707..2687bc8 100644 --- a/lib/widgets/chat_page/room_menu.dart +++ b/lib/widgets/chat_page/room_menu.dart @@ -1,9 +1,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; -import "package:flutter/services.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/via_controller.dart"; import "package:nexus/models/room.dart"; class RoomMenu extends ConsumerWidget { @@ -18,6 +16,13 @@ class RoomMenu extends ConsumerWidget { return PopupMenuButton( itemBuilder: (_) => [ + // PopupMenuItem( + // onTap: () async { + // final link = await room.matrixToInviteLink(); + // await Clipboard.setData(ClipboardData(text: link.toString())); + // }, + // child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), + // ), PopupMenuItem( onTap: () async { await client.markRead(room); @@ -28,18 +33,6 @@ class RoomMenu extends ConsumerWidget { title: Text("Mark as Read"), ), ), - PopupMenuItem( - onTap: () async { - final vias = ref.watch(ViaController.provider(room)); - - await Clipboard.setData( - ClipboardData( - text: "matrix:roomid/${room.metadata?.id.substring(1)}$vias)", - ), - ); - }, - child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), - ), PopupMenuItem( onTap: () => showDialog( context: context, diff --git a/lib/widgets/chat_page/sidebar.dart b/lib/widgets/chat_page/sidebar.dart index f79c38f..771a7ae 100644 --- a/lib/widgets/chat_page/sidebar.dart +++ b/lib/widgets/chat_page/sidebar.dart @@ -1,11 +1,15 @@ import "package:flutter/material.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client_controller.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/helpers/extensions/join_room_with_snackbars.dart"; +import "package:nexus/pages/settings_page.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/form_text_input.dart"; class Sidebar extends HookConsumerWidget { final bool isDesktop; @@ -88,7 +92,53 @@ class Sidebar extends HookConsumerWidget { PopupMenuItem( onTap: () => showDialog( context: context, - builder: (_) => JoinDialog(ref), + builder: (alertContext) => HookBuilder( + builder: (_) { + final roomAlias = useTextEditingController(); + return AlertDialog( + title: Text("Join a Room"), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Enter the room alias, ID, or a Matrix.to link.", + ), + SizedBox(height: 12), + FormTextInput( + required: false, + capitalize: true, + controller: roomAlias, + title: "#room:server", + ), + ], + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () async { + Navigator.of(alertContext).pop(); + + final client = ref.watch( + ClientController.provider.notifier, + ); + if (context.mounted) { + client.joinRoomWithSnackBars( + context, + roomAlias.text, + ref, + ); + } + }, + child: Text("Join"), + ), + ], + ); + }, + ), ), child: ListTile( title: Text("Join an existing room (or space)"), @@ -96,7 +146,7 @@ class Sidebar extends HookConsumerWidget { ), ), PopupMenuItem( - onTap: null, + onTap: () {}, child: ListTile( title: Text("Create a new room"), leading: Icon(Icons.add), @@ -107,15 +157,17 @@ class Sidebar extends HookConsumerWidget { ), IconButton( tooltip: "Explore other rooms", - onPressed: null, + onPressed: () => showDialog( + context: context, + builder: (context) => AlertDialog(title: Text("To-do")), + ), icon: Icon(Icons.explore), ), IconButton( tooltip: "Open settings", - onPressed: null, - // () => Navigator.of( - // context, - // ).push(MaterialPageRoute(builder: (_) => SettingsPage())), + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => SettingsPage())), icon: Icon(Icons.settings), ), ], diff --git a/lib/widgets/chat_page/user_popover.dart b/lib/widgets/chat_page/user_popover.dart deleted file mode 100644 index a9a4799..0000000 --- a/lib/widgets/chat_page/user_popover.dart +++ /dev/null @@ -1,214 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:intl/intl.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/profile_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/configs/power_level_config.dart"; -import "package:nexus/models/membership.dart"; -import "package:nexus/models/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/form_text_input.dart"; - -class UserPopover extends ConsumerWidget { - final Membership member; - const UserPopover(this.member, {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, - builder: (context) => HookBuilder( - builder: (context) { - final actionReasonController = useTextEditingController(); - return AlertDialog( - title: Text( - "${toBeginningOfSentenceCase(action.name)} ${member.userId}", - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Are you sure you want to ${action.name} ${member.userId}?", - ), - SizedBox(height: 12), - FormTextInput( - required: false, - capitalize: true, - controller: actionReasonController, - title: "Reason for ${action.name} (optional)", - ), - ], - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - client - .setMembership( - SetMembershipRequest( - userId: member.userId, - roomId: roomId!, - action: action, - reason: actionReasonController.text, - ), - ) - .onError(showError); - }, - child: Text(toBeginningOfSentenceCase(action.name)), - ), - ], - ); - }, - ), - ); - - return Column( - spacing: 16, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Wrap( - alignment: WrapAlignment.center, - spacing: 16, - runSpacing: 8, - children: [ - ExpandableImage( - member.avatarUrl?.toString(), - child: AvatarOrHash( - member.avatarUrl, - member.displayName, - height: 80, - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SelectableText( - member.displayName, - style: textTheme.headlineSmall, - ), - SelectableText(member.userId, style: textTheme.titleSmall), - SizedBox(height: 4), - ref - .watch(ProfileController.provider(member.userId)) - .betterWhen( - loading: SizedBox.shrink, - data: (profile) => Wrap( - spacing: 4, - children: [ - for (final pronoun in profile.pronouns.where( - (pronoun) => pronoun.language == "en", - )) - Chip( - label: Text(pronoun.summary), - labelStyle: TextStyle( - color: theme.colorScheme.onPrimary, - ), - color: WidgetStatePropertyAll( - theme.colorScheme.primary, - ), - ), - if (profile.timezone != null) - Chip( - label: Text(profile.timezone!), - labelStyle: TextStyle( - color: theme.colorScheme.onPrimary, - ), - color: WidgetStatePropertyAll( - theme.colorScheme.primary, - ), - ), - ], - ), - ), - ], - ), - ], - ), - if (member.userId != - ref.watch(ClientStateController.provider)?.userId && - roomId != null) - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FilledButton.icon(onPressed: null, label: Text("Message")), - - if (ref.watch( - PowerLevelController.provider( - PowerLevelConfig( - eventType: "m.room.member", - action: MembershipAction.kick, - isStateEvent: true, - targetUser: member.userId, - ), - ), - ) && - member.status == MembershipStatus.join || - member.status == MembershipStatus.invite) - FilledButton.icon( - onPressed: () => showMembershipDialog(MembershipAction.kick), - label: Text("Kick"), - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll( - theme.colorScheme.error, - ), - foregroundColor: WidgetStatePropertyAll( - theme.colorScheme.onError, - ), - ), - ), - if (ref.watch( - PowerLevelController.provider( - PowerLevelConfig( - eventType: "m.room.member", - action: MembershipAction.ban, - isStateEvent: true, - targetUser: member.userId, - ), - ), - )) - ElevatedButton.icon( - onPressed: () => showMembershipDialog( - member.status == MembershipStatus.ban - ? MembershipAction.unban - : MembershipAction.ban, - ), - label: Text( - member.status == MembershipStatus.ban ? "Unban" : "Ban", - ), - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll( - theme.colorScheme.errorContainer, - ), - foregroundColor: WidgetStatePropertyAll( - theme.colorScheme.onErrorContainer, - ), - ), - ), - ], - ), - ], - ); - } -} diff --git a/lib/widgets/chat_page/wrappers/message_wrapper.dart b/lib/widgets/chat_page/wrappers/message_wrapper.dart index 9c70c27..1be6c2b 100644 --- a/lib/widgets/chat_page/wrappers/message_wrapper.dart +++ b/lib/widgets/chat_page/wrappers/message_wrapper.dart @@ -1,83 +1,59 @@ import "package:flutter/material.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:nexus/models/room.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 Room room; final MessageGroupStatus? groupStatus; - const MessageWrapper(this.message, this.child, this.groupStatus, {super.key}); + const MessageWrapper( + this.message, + this.child, + this.groupStatus, + this.room, { + 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, - ), - ), - ), - ], + Widget build(BuildContext context) => 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, room, height: 40) + : SizedBox(width: 40), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + if (groupStatus?.isFirst != false) + MessageDisplayname( + message, + room, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, ), - child, - if (error != null && error != "not sent") - Text( - error, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ReactionRow(message), - ], - ), + ), + child, + ], ), - ], - ), + ), + ], ), - ); - } + ), + ); } 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 index 8d7a625..41bc01e 100644 --- a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart +++ b/lib/widgets/chat_page/wrappers/text_message_wrapper.dart @@ -1,21 +1,15 @@ -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/models/room.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 { +class TextMessageWrapper extends StatelessWidget { final Message message; final String? content; + final Room room; final MessageGroupStatus? groupStatus; final Future Function(Message oldMessage, Message newMessage) updateMessage; @@ -27,6 +21,7 @@ class TextMessageWrapper extends ConsumerWidget { this.message, { this.content, this.onTapReply, + required this.room, required this.updateMessage, required this.groupStatus, required this.isSentByMe, @@ -35,17 +30,11 @@ class TextMessageWrapper extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { 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( @@ -54,9 +43,7 @@ class TextMessageWrapper extends ConsumerWidget { padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), decoration: BoxDecoration( color: isSentByMe - ? (message.id.startsWith("~") - ? colorScheme.onPrimary - : colorScheme.primaryContainer) + ? colorScheme.primaryContainer : colorScheme.surfaceContainer, ), child: Column( @@ -64,84 +51,65 @@ class TextMessageWrapper extends ConsumerWidget { children: [ ReplyWidget( message, + room: room, 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)!; - } + Html( + textStyle: message.metadata?["big"] == true + ? TextStyle(fontSize: 32) + : null, + content! + .replaceAllMapped( + RegExp( + "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", + caseSensitive: false, + ), + (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"; - }, - ), + // 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, - ), - ), + .replaceAll("\n", "
"), + ), 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 (textMessage != null) + LinkPreview( + text: textMessage.text, + backgroundColor: isSentByMe + ? colorScheme.inversePrimary + : colorScheme.surfaceContainerLow, + outsidePadding: EdgeInsets.only(top: 4), + insidePadding: EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + linkPreviewData: message.metadata?["linkPreviewData"], + onLinkPreviewDataFetched: (linkPreviewData) => updateMessage( + message, + message.copyWith( + metadata: { + ...(message.metadata ?? {}), + "linkPreviewData": linkPreviewData, + }, ), + ), + ), if (extra != null) extra!, ], ), ), ), groupStatus, + room, ); } } diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index 26f2a17..ae1ddeb 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -26,7 +26,6 @@ flutter.buildFlutterApplication { dynamic_system_colors = "sha256-es6rjMK1drkqZBKYUP77yw/q5+0uLwWOEDOXRawy3Dc="; flutter_chat_ui = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk="; flutter_link_previewer = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk="; - emoji_text_field = "sha256-F0QbIHP3wpKoL6QbJ20Oun0SsOdwnXe84IqsK2ad85w="; }; postInstall = '' diff --git a/pubspec.lock b/pubspec.lock index ef7fcd9..de807aa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -354,15 +354,6 @@ packages: url: "https://github.com/hasali19/flutter_dynamic_system_colors" source: git version: "1.8.0" - emoji_text_field: - dependency: "direct main" - description: - path: "." - ref: HEAD - resolved-ref: "0e90703a6e876939be70bd1816c49cf14474de61" - url: "https://github.com/Henry-Hiles/emoji_text_field" - source: git - version: "1.0.0" encrypt: dependency: transitive description: @@ -475,10 +466,11 @@ packages: flutter_chat_ui: dependency: "direct main" description: - name: flutter_chat_ui - sha256: cfbaac38f429beb33d9cc1ca920ae7ccbadbed282c99335d590d61306d3a3d0f - url: "https://pub.dev" - source: hosted + path: "packages/flutter_chat_ui" + ref: HEAD + resolved-ref: "03be67c8c81c8f637672ee03dd8f082d2c223627" + url: "https://github.com/Henry-Hiles/flutter_chat_ui" + source: git version: "2.11.1" flutter_hooks: dependency: "direct main" @@ -499,19 +491,12 @@ packages: flutter_link_previewer: dependency: "direct main" description: - name: flutter_link_previewer - sha256: "346f345064e65bc8bf739bccf19d6d6ca50f8183ffc52e452afa58c06ee2cbf7" - url: "https://pub.dev" - source: hosted + path: "packages/flutter_link_previewer" + ref: HEAD + resolved-ref: "03be67c8c81c8f637672ee03dd8f082d2c223627" + url: "https://github.com/Henry-Hiles/flutter_chat_ui" + source: git version: "4.2.0" - flutter_linkify: - dependency: "direct main" - description: - name: flutter_linkify - sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073" - url: "https://pub.dev" - source: hosted - version: "6.0.0" flutter_lints: dependency: "direct dev" description: @@ -672,7 +657,7 @@ packages: source: hosted version: "0.15.6" http: - dependency: "direct main" + dependency: transitive description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -839,14 +824,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" - linkify: - dependency: transitive - description: - name: linkify - sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" - url: "https://pub.dev" - source: hosted - version: "5.0.0" lints: dependency: transitive description: @@ -1380,14 +1357,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.0+1" - timeago: - dependency: "direct main" - description: - name: timeago - sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e - url: "https://pub.dev" - source: hosted - version: "3.7.1" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7ecefa1..80c3687 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,8 +40,14 @@ dependencies: flyer_chat_image_message: ^2.2.2 flyer_chat_system_message: ^2.1.13 flyer_chat_file_message: ^2.3.1 - flutter_chat_ui: ^2.11.1 - flutter_link_previewer: ^4.2.0 + flutter_chat_ui: + git: + url: https://github.com/Henry-Hiles/flutter_chat_ui + path: packages/flutter_chat_ui + flutter_link_previewer: + git: + url: https://github.com/Henry-Hiles/flutter_chat_ui + path: packages/flutter_link_previewer color_hash: ^1.0.1 flutter_widget_from_html_core: ^0.17.0 flutter_svg: ^2.2.2 @@ -55,12 +61,6 @@ dependencies: hooks: ^1.0.0 code_assets: ^1.0.0 ffigen: ^20.1.1 - timeago: ^3.7.1 - http: ^1.6.0 - flutter_linkify: ^6.0.0 - emoji_text_field: - git: - url: https://github.com/Henry-Hiles/emoji_text_field dev_dependencies: build_runner: ^2.4.11 @@ -76,7 +76,7 @@ flutter_launcher_icons: android: true image_path: assets/icon.png adaptive_icon_background: assets/background.png - adaptive_icon_foreground: assets/smallerForeground.png + adaptive_icon_foreground: assets/foreground.png remove_alpha_ios: true windows: generate: true \ No newline at end of file