diff --git a/.gitmodules b/.gitmodules index 17d64ba..145276a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "gomuks"] path = gomuks - url = https://github.com/zachatrocity/gomuks - branch = init-root-dir + url = https://github.com/gomuks/gomuks + branch = main diff --git a/.vscode/settings.json b/.vscode/settings.json index 25ea52b..da80f4b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,8 +2,11 @@ "cSpell.words": [ "Appbar", "Displayname", + "fluttertagger", + "Gomuks", "Homeserver", - "prefs", - "vodozemac" + "localpart", + "muks", + "prefs" ] } diff --git a/README.md b/README.md index 7af23a0..0fe2a1b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Nexus Client > [!WARNING] -> Nexus Client is still heavily in development, and is not ready for use! +> Nexus Client is still in development, and doesn't support everything needed for daily 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 - - [x] Windows + - [ ] Windows (WIP) - [ ] MacOS - - [ ] Android + - [x] 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 - - [ ] Parse vias + - [x] Parse vias - [x] Using a text/uri/link - [x] Plain text - [x] `matrix:` Uri @@ -63,10 +63,11 @@ 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] Recieving + - [x] Receiving - [x] Plain text - [x] Per message profiles - [x] HTML + - [x] URL Previews - [x] Replies - [x] Viewing - [ ] Jump to original message @@ -79,28 +80,34 @@ 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: Waiting on https://github.com/SwanFlutter/dynamic_polls/issues/1 + - [ ] Polls - [x] Mentions - [x] Users + - [x] Clickable - [x] Rooms - - [ ] Plain text (not sure if I want to add this or not, I probably won't unless there's interest) + - [x] Clickable - [x] Matrix URIs - [x] Matrix.to links - - [ ] Do some fancy fetching to get nice names - - [ ] Make clickable + - [x] Events + - [ ] Render more nicely + - [ ] Clickable - [x] Custom emojis/stickers - [x] History loading - [x] Backwards - [ ] Forwards - [x] Editing - [x] Deleting -- [ ] Reactions: Waiting on https://github.com/flyerhq/flutter_chat_ui/pull/838 or me doing a custom impl +- [x] Reactions - [ ] Pins - [ ] Displaying - [ ] Creating - [ ] Threads -- [ ] Profile popouts -- [ ] Copy link to [room, space] +- [x] Profile popouts + - [x] Working actions +- [x] Copy link to: + - [x] Room + - [x] Space + - [x] Message - [ ] Reporting - [x] Events - [ ] Rooms @@ -108,6 +115,7 @@ 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 @@ -115,7 +123,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [ ] Devices - [ ] Viewing devices - [ ] Verifying devices - - [ ] URL preview: Server / Client / None + - [ ] URL preview: Server / Sending Client (Beeper spec) / None - [ ] Account changes - [ ] Display name - [ ] Profile picture @@ -125,7 +133,34 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [ ] About - [x] Log Out -## Build Instructions +## 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 First, clone and open the repo: @@ -134,17 +169,6 @@ 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: @@ -156,7 +180,7 @@ flutter pub get Generate Gomuks bindings: ```sh -scripts/generate.sh +dart scripts/generate.dart ``` 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 new file mode 100644 index 0000000..791aed8 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png 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 57feff2..86ebaa5 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 new file mode 100644 index 0000000..b00666d Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png 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 8bb5bb4..3c64f70 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 new file mode 100644 index 0000000..bad307d Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png 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 df784a1..c2b441b 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 new file mode 100644 index 0000000..b3e4f12 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png 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 d732425..e472dab 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 new file mode 100644 index 0000000..0aac7b2 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png 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 963a077..64a5154 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 c79c58a..d047760 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 72b0f72..70b7343 100644 --- a/lib/controllers/author_controller.dart +++ b/lib/controllers/author_controller.dart @@ -1,31 +1,28 @@ 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/members_controller.dart"; -import "package:nexus/models/configs/author_config.dart"; +import "package:nexus/controllers/user_controller.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/models/membership.dart"; +import "package:nexus/models/membership_status.dart"; class AuthorController extends AsyncNotifier { - final AuthorConfig config; - AuthorController(this.config); + final Message message; + AuthorController(this.message); @override Future build() async { final member = await ref.watch( - MembersController.provider(config.room).selectAsync( - (value) => value.firstWhereOrNull( - (membership) => membership.userId == config.message.authorId, - ), - ), + UserController.provider(message.authorId).future, ); - final pmp = config.message.metadata?["pmp"] == null + final pmp = message.metadata?["pmp"] == null ? null : Membership.fromContent( - IMap(config.message.metadata?["pmp"]), - config.message.authorId, + IMap(message.metadata?["pmp"]), + message.authorId, ref.watch( ClientStateController.provider.select( (value) => value?.homeserverUrl, @@ -35,17 +32,16 @@ class AuthorController extends AsyncNotifier { ); return Membership( + status: member?.status ?? MembershipStatus.leave, avatarUrl: pmp?.avatarUrl ?? member?.avatarUrl, displayName: - pmp?.displayName ?? - member?.displayName ?? - config.message.authorId.substring(1).split(":").first, - userId: config.message.authorId, + pmp?.displayName ?? member?.displayName ?? message.authorId.localpart, + userId: message.authorId, ); } - static final provider = AsyncNotifierProvider.family - .autoDispose( + static final provider = + AsyncNotifierProvider.family( AuthorController.new, ); } diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index 37dde3d..cc68871 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -9,11 +9,13 @@ 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"; @@ -26,7 +28,9 @@ 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"; @@ -74,6 +78,17 @@ 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; @@ -113,6 +128,7 @@ class ClientController extends AsyncNotifier { debugPrint("Finished handling $muksEventType..."); } catch (error, stackTrace) { debugger(); + showError(error, stackTrace); debugPrintStack(stackTrace: stackTrace, label: error.toString()); } }); @@ -150,8 +166,11 @@ class ClientController extends AsyncNotifier { Future redactEvent(RedactEventRequest report) => _sendCommand("redact_event", report.toJson()); - Future sendMessage(SendMessageRequest request) => - _sendCommand("send_message", request.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 verify(String recoveryKey) async { try { @@ -183,9 +202,13 @@ class ClientController extends AsyncNotifier { // })); Future> getRoomState(GetRoomStateRequest request) async { - final response = - (await _sendCommand("get_room_state", request.toJson())) as List; - return response.map((event) => Event.fromJson(event)).toIList(); + 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(); } Future?> getRelatedEvents( @@ -212,8 +235,11 @@ class ClientController extends AsyncNotifier { Future getProfile(String userId) async => Profile.fromJson(await _sendCommand("get_profile", {"user_id": userId})); - Future reportEvent(ReportRequest report) => - _sendCommand("report_event", report.toJson()); + Future reportEvent(ReportRequest request) => + _sendCommand("report_event", request.toJson()); + + Future setMembership(SetMembershipRequest request) => + _sendCommand("set_membership", request.toJson()); Future markRead(Room room) async { final event = room.events.firstWhereOrNull( @@ -240,7 +266,7 @@ class ClientController extends AsyncNotifier { Future discoverHomeserver(Uri homeserver) async { try { final response = await _sendCommand("discover_homeserver", { - "user_id": "@fakeuser:${homeserver.host}", + "user_id": "@fake-user:${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 new file mode 100644 index 0000000..cdc8d07 --- /dev/null +++ b/lib/controllers/members_by_type_controller.dart @@ -0,0 +1,25 @@ +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 8d79f71..39666d4 100644 --- a/lib/controllers/members_controller.dart +++ b/lib/controllers/members_controller.dart @@ -2,30 +2,34 @@ 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 { - if (room.metadata == null) return const IList.empty(); + 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(); final state = await ref .watch(ClientController.provider.notifier) .getRoomState( GetRoomStateRequest( - roomId: room.metadata!.id, - fetchMembers: room.metadata!.hasMemberList == false, + roomId: data.$1, + fetchMembers: data.$2 == false, includeMembers: true, ), ); return state.nonNulls - .where((member) => member.content["membership"] == "join") + .where((state) => state.type == "m.room.member") .map( (membership) => Membership.fromContent( membership.content, @@ -42,7 +46,7 @@ class MembersController extends AsyncNotifier> { } static final provider = - AsyncNotifierProvider.family, Room>( + AsyncNotifierProvider>( MembersController.new, ); } diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart index d84aabb..c65d18d 100644 --- a/lib/controllers/message_controller.dart +++ b/lib/controllers/message_controller.dart @@ -1,9 +1,12 @@ 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; @@ -12,11 +15,11 @@ class MessageController extends AsyncNotifier { @override Future build() async { try { - if (config.event.relationType == "m.replace" && !config.includeEdits) { + final isEdit = config.event.relationType == "m.replace"; + if ((isEdit && !config.includeEdits) || config.room.metadata == null) { return null; } - if (!ref.mounted) return null; final event = config.event.lastEditRowId == null ? config.event : config.room.events.firstWhereOrNull( @@ -24,11 +27,11 @@ class MessageController extends AsyncNotifier { ) ?? config.event; - if (!ref.mounted) return null; - - final content = (event.decrypted ?? event.content); + final decrypted = (event.decrypted ?? event.content); final type = (config.event.decryptedType ?? config.event.type); - final newContent = content["m.new_content"] as Map?; + final content = decrypted["m.new_content"] == null + ? decrypted + : IMap(decrypted["m.new_content"]); final homeserver = ref .read(ClientStateController.provider) @@ -39,22 +42,19 @@ class MessageController extends AsyncNotifier { final metadata = { "body": config.event.redactedBy == null - ? (newContent?["body"] ?? content["body"] ?? "") + ? (content["body"] ?? "") : "Deleted Message", "flashing": false, "timelineId": event.timelineRowId, "big": event.localContent?.bigEmoji == true, "eventType": type, - "pmp": event.content["com.beeper.per_message_profile"], - "editSource": - event.localContent?.editSource ?? - newContent?["body"] ?? - content["body"], + "pmp": content["com.beeper.per_message_profile"], + "error": event.sendError, + "format": content["format"] ?? content["format"], + "editSource": event.localContent?.editSource ?? content["body"], "txnId": config.event.transactionId, }; - if (!ref.mounted) return null; - final editedAt = event.relationType == "m.replace" ? event.timestamp : null; @@ -65,32 +65,60 @@ 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: - newContent?["formatted_body"] ?? - newContent?["body"] ?? - content["formatted_body"] ?? - content["body"] ?? - "", + text: content["formatted_body"] ?? content["body"] ?? "", replyToMessageId: replyId, deliveredAt: config.event.timestamp, editedAt: editedAt, ) as TextMessage; + Message toSystemMessage(String content) => Message.system( + metadata: {...metadata, "body": content}, + id: config.event.eventId, + reactions: reactions, + authorId: event.authorId, + deliveredAt: config.event.timestamp, + text: content, + ); + return switch (type) { "m.room.encrypted" => asText.copyWith( text: "Unable to decrypt message.", @@ -110,6 +138,7 @@ class MessageController extends AsyncNotifier { null || "m.image" => Message.image( id: config.event.eventId, authorId: event.authorId, + reactions: reactions, source: source, replyToMessageId: replyId, metadata: metadata, @@ -122,6 +151,7 @@ class MessageController extends AsyncNotifier { size: content["info"]["size"], metadata: metadata, id: config.event.eventId, + reactions: reactions, authorId: event.authorId, source: source, replyToMessageId: replyId, @@ -132,33 +162,21 @@ class MessageController extends AsyncNotifier { "m.room.member" => content["membership"] == event.unsigned["prev_content"]?["membership"] ? null - : 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.", + : toSystemMessage( + "${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) { + "invite" => "was invited to", + "join" => "joined", + "leave" => event.authorId == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), + "ban" => "was banned from", + "knock" => "asked to join", + _ => "did something relating to", + }} the room. ${content["reason"] ?? ""}", ), + "m.room.server_acl" => toSystemMessage( + "${event.authorId} updated the server ban list.", + ), + "m.room.redaction" => config.alwaysReturn ? asText.copyWith( @@ -177,6 +195,7 @@ 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 new file mode 100644 index 0000000..41b5f19 --- /dev/null +++ b/lib/controllers/power_level_controller.dart @@ -0,0 +1,70 @@ +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 new file mode 100644 index 0000000..120d4e4 --- /dev/null +++ b/lib/controllers/profile_controller.dart @@ -0,0 +1,17 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/models/profile.dart"; + +class ProfileController extends AsyncNotifier { + 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 d737154..fa32bf8 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -2,7 +2,6 @@ import "dart:async"; import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_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"; @@ -10,12 +9,15 @@ 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"; @@ -28,7 +30,6 @@ 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), ); @@ -78,16 +79,68 @@ 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 message = controller.messages.firstWhereOrNull( - (message) => message.id == event.content["redacts"], + final redactsId = event.content["redacts"]; + final originalMessage = controller.messages.firstWhereOrNull( + (message) => message.id == redactsId, ); - if (message == null || !ref.mounted) return; + if (!ref.mounted) return; - await controller.removeMessage(message); + if (originalMessage != null) { + return await controller.removeMessage(originalMessage); + } + + final redacts = ref + .read(SelectedRoomController.provider) + ?.events + .firstWhere((event) => event.eventId == redactsId); + + if (redacts?.type == "m.reaction") { + final message = controller.messages.firstWhereOrNull( + (message) => + message.id == redacts!.content["m.relates_to"]?["event_id"], + ); + final key = redacts!.content["m.relates_to"]?["key"]; + if (message == null || key == null || !ref.mounted) return; + + return await controller.updateMessage( + message, + message.copyWith( + reactions: IMap(message.reactions) + .update( + key, + (reactors) => + IList(reactors).remove(redacts.authorId).unlock, + ) + .where((_, value) => value.isNotEmpty) + .unlock, + ), + ); + } } else { final message = await ref.watch( MessageController.provider( @@ -116,12 +169,8 @@ class RoomChatController extends AsyncNotifier { ), ); } - if (message != null && - !controller.messages.any( - (oldMessage) => oldMessage.id == message.id, - ) && - ref.mounted) { - await controller.insertMessage(message); + if (message != null && ref.mounted) { + await insertMessage(message); } } } @@ -130,9 +179,9 @@ class RoomChatController extends AsyncNotifier { ref.onDispose(controller.dispose); - // 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); + // While there are under 20 messages, try up to load more messages until theres no more or we have 20 messages. + for (var more = true; more == true && controller.messages.length < 20;) { + more = await loadOlder(controller); } return controller; @@ -152,21 +201,13 @@ class RoomChatController extends AsyncNotifier { : controller.updateMessage(oldMessage, message); } - 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 deleteMessage(Message message, {String? reason}) => 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( @@ -198,38 +239,40 @@ class RoomChatController extends AsyncNotifier { ), }), const ISet.empty(), + addToNewEvents: false, ); final room = ref.read(RoomsController.provider)[roomId]; - if (room == null) return; + if (room != null) { + final messages = await ref.watch( + MessagesController.provider( + MessagesConfig(room: room, events: response.events.reversed), + ).future, + ); - 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, - ); + 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; } Future send( - String message, { + String text, { bool shouldMention = true, - required Iterable tags, + required IList tags, required RelationType relationType, Message? relation, }) async { - var taggedMessage = message; + var taggedMessage = text; for (final tag in tags) { final escaped = RegExp.escape(tag.id); @@ -242,7 +285,8 @@ class RoomChatController extends AsyncNotifier { } final client = ref.watch(ClientController.provider.notifier); - client.sendMessage( + final room = ref.read(RoomsController.provider)[roomId]; + final event = await client.sendMessage( SendMessageRequest( roomId: roomId, mentions: Mentions( @@ -260,21 +304,15 @@ 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, + ); - 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(), - ); + if (message != null) insertMessage(message); } Future scrollToMessage(Message message) async { @@ -292,6 +330,59 @@ 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 27eb18e..7013de0 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -11,7 +11,11 @@ class RoomsController extends Notifier> { @override IMap build() => const IMap.empty(); - void update(IMap rooms, ISet leftRooms) { + void update( + IMap rooms, + ISet leftRooms, { + bool addToNewEvents = true, + }) { final homeserver = ref.watch( ClientStateController.provider.select( @@ -29,18 +33,20 @@ class RoomsController extends Notifier> { (item) => item.eventId, ); - ref - .watch(NewEventsController.provider(roomId).notifier) - .add( - incoming.timeline - .map( - (timelineTuple) => events?.firstWhereOrNull( - (event) => timelineTuple.eventRowId == event.rowId, - ), - ) - .nonNulls - .toIList(), - ); + if (addToNewEvents) { + ref + .watch(NewEventsController.provider(roomId).notifier) + .add( + incoming.timeline + .map( + (timelineTuple) => events?.firstWhereOrNull( + (event) => timelineTuple.eventRowId == event.rowId, + ), + ) + .nonNulls + .toIList(), + ); + } return acc.add( roomId, diff --git a/lib/controllers/sync_status_controller.dart b/lib/controllers/sync_status_controller.dart index fe65732..8475d9d 100644 --- a/lib/controllers/sync_status_controller.dart +++ b/lib/controllers/sync_status_controller.dart @@ -1,11 +1,17 @@ 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) => state = newStatus; + void set(SyncStatus newStatus) { + if (newStatus.type == SyncStatusType.permanentlyFailed) { + showError(newStatus.error ?? "Syncing failed"); + } + state = newStatus; + } static final provider = NotifierProvider( SyncStatusController.new, diff --git a/lib/controllers/url_preview_controller.dart b/lib/controllers/url_preview_controller.dart new file mode 100644 index 0000000..c2161d5 --- /dev/null +++ b/lib/controllers/url_preview_controller.dart @@ -0,0 +1,60 @@ +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 new file mode 100644 index 0000000..e7ca973 --- /dev/null +++ b/lib/controllers/user_controller.dart @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..b423947 --- /dev/null +++ b/lib/controllers/via_controller.dart @@ -0,0 +1,54 @@ +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 new file mode 100644 index 0000000..445351f --- /dev/null +++ b/lib/helpers/extensions/get_localpart.dart @@ -0,0 +1,3 @@ +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 deleted file mode 100644 index eaa0659..0000000 --- a/lib/helpers/extensions/join_room_with_snackbars.dart +++ /dev/null @@ -1,91 +0,0 @@ -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 b0e62aa..f4868d3 100644 --- a/lib/helpers/extensions/link_to_mention.dart +++ b/lib/helpers/extensions/link_to_mention.dart @@ -30,7 +30,8 @@ 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 aff5d52..df68a05 100644 --- a/lib/helpers/extensions/scheme_to_theme.dart +++ b/lib/helpers/extensions/scheme_to_theme.dart @@ -7,8 +7,13 @@ extension SchemeToTheme on ColorScheme { titleSpacing: 0, backgroundColor: surfaceContainerLow, ), + menuTheme: MenuThemeData( + style: MenuStyle( + backgroundColor: WidgetStatePropertyAll(primaryContainer), + ), + ), textTheme: ThemeData( - fontFamilyFallback: ["sans"], + fontFamilyFallback: ["sans", "emoji"], 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 f4762c3..7d8cab6 100644 --- a/lib/helpers/extensions/show_context_menu.dart +++ b/lib/helpers/extensions/show_context_menu.dart @@ -9,6 +9,7 @@ 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 new file mode 100644 index 0000000..1698879 --- /dev/null +++ b/lib/helpers/extensions/show_user_popover.dart @@ -0,0 +1,18 @@ +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 192ca29..846f075 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,7 +5,6 @@ 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"; @@ -127,9 +126,7 @@ class App extends StatelessWidget { } else if (!clientState.isVerified) { return VerifyPage(); } else { - return ref.watch(InitCompleteController.provider) - ? ChatPage() - : Loading(); + return ChatPage(); } }, ), diff --git a/lib/models/configs/author_config.dart b/lib/models/configs/author_config.dart deleted file mode 100644 index af63c63..0000000 --- a/lib/models/configs/author_config.dart +++ /dev/null @@ -1,14 +0,0 @@ -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 9020f78..66a437c 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.eventId == event.eventId; + other.event == event; @override - int get hashCode => Object.hash(runtimeType, event.eventId); + int get hashCode => Object.hash(runtimeType, event); 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 new file mode 100644 index 0000000..31cc08c --- /dev/null +++ b/lib/models/configs/power_level_config.dart @@ -0,0 +1,17 @@ +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 4e2bf4c..ce0cc42 100644 --- a/lib/models/membership.dart +++ b/lib/models/membership.dart @@ -1,12 +1,14 @@ 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, @@ -17,6 +19,10 @@ 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 new file mode 100644 index 0000000..bc85e22 --- /dev/null +++ b/lib/models/membership_status.dart @@ -0,0 +1,4 @@ +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 d92b4f6..584f27b 100644 --- a/lib/models/profile.dart +++ b/lib/models/profile.dart @@ -3,15 +3,22 @@ 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(name: "us.cloke.msc4175.tz") String? timezone, + + @JsonKey(readValue: readTimezone) String? timezone, @Default(IList.empty()) - @JsonKey(name: "io.fsky.nyx.pronouns") + @JsonKey(readValue: readPronouns) IList pronouns, }) = _Profile; diff --git a/lib/models/requests/get_room_state_request.dart b/lib/models/requests/get_room_state_request.dart index de66b72..8ee05f0 100644 --- a/lib/models/requests/get_room_state_request.dart +++ b/lib/models/requests/get_room_state_request.dart @@ -6,6 +6,7 @@ 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 new file mode 100644 index 0000000..d852164 --- /dev/null +++ b/lib/models/requests/membership_action.dart @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..da5de32 --- /dev/null +++ b/lib/models/requests/send_event_request.dart @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..dd0e1f2 --- /dev/null +++ b/lib/models/requests/set_membership_request.dart @@ -0,0 +1,19 @@ +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 42c5f2a..7848fbe 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.snake) +@JsonEnum(fieldRename: FieldRename.kebab) enum SyncStatusType { ok, waiting, erroring, permanentlyFailed } diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index 7aa8156..671891c 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -1,7 +1,10 @@ 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}); @@ -11,22 +14,33 @@ 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( - body: Builder( - builder: (context) => Row( - children: [ - if (isDesktop) Sidebar(isDesktop: isDesktop), - Expanded( - child: RoomChat( - isDesktop: isDesktop, - showMembersByDefault: showMembersByDefault, + 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...")], ), ), - ], - ), - ), - drawer: isDesktop ? null : Sidebar(isDesktop: isDesktop), + drawer: isDesktop || !initComplete + ? null + : Sidebar(isDesktop: isDesktop), ); }, ); diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatar_or_hash.dart index 147c249..28662e2 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.contain, + fit: BoxFit.cover, errorBuilder: (_, _, _) => box, ), ), diff --git a/lib/widgets/chat_page/composer/chat_box.dart b/lib/widgets/chat_page/composer/chat_box.dart index a688fa7..478974e 100644 --- a/lib/widgets/chat_page/composer/chat_box.dart +++ b/lib/widgets/chat_page/composer/chat_box.dart @@ -1,25 +1,33 @@ +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/room_chat_controller.dart"; +import "package:nexus/controllers/power_level_controller.dart"; +import "package:nexus/models/configs/power_level_config.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 Room room; + final FocusNode? node; + final Future Function( + String text, { + required bool shouldMention, + required IList tags, + }) + onSend; const ChatBox({ required this.relatedMessage, required this.relationType, required this.onDismiss, - required this.room, + required this.onSend, + this.node, super.key, }); @@ -38,37 +46,28 @@ class ChatBox extends HookConsumerWidget { } void send() { - 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, - ); + if (controller.value.text.isEmpty) return; + onSend( + controller.value.formattedText, + shouldMention: shouldMention.value, + tags: controller.value.tags.toIList(), + ); + 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, @@ -81,7 +80,6 @@ class ChatBox extends HookConsumerWidget { children: [ RelationPreview( relatedMessage, - room: room, shouldMention: shouldMention.value, toggleShouldMention: () => shouldMention.value = !shouldMention.value, @@ -94,8 +92,14 @@ 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( @@ -117,18 +121,16 @@ 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, @@ -138,28 +140,28 @@ class ChatBox extends HookConsumerWidget { }, triggerCharacterAndStyles: {"@": style, "#": style}, builder: (context, key) => TextFormField( - // enabled: room.canSendDefaultMessages, + enabled: canSendMessages, maxLines: 12, minLines: 1, + autofocus: true, decoration: InputDecoration( - hintText: - true // TODO: room.canSendDefaultMessages + hintText: canSendMessages ? "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: send, - // onPressed: room.canSendDefaultMessages ? send : null, + onPressed: !canSendMessages ? null : send, 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 d95253d..b650421 100644 --- a/lib/widgets/chat_page/composer/mention_overlay.dart +++ b/lib/widgets/chat_page/composer/mention_overlay.dart @@ -1,19 +1,18 @@ import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/members_controller.dart"; +import "package:nexus/controllers/members_by_type_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; +import "package:nexus/controllers/via_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/room.dart"; +import "package:nexus/models/membership_status.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( - this.room, { + const MentionOverlay({ required this.query, required this.addTag, required this.triggerCharacter, @@ -34,7 +33,9 @@ class MentionOverlay extends ConsumerWidget { child: switch (triggerCharacter) { "@" => ref - .watch(MembersController.provider(room)) + .watch( + MembersByTypeController.provider(MembershipStatus.join), + ) .betterWhen( data: (members) => ListView( children: @@ -62,7 +63,7 @@ class MentionOverlay extends ConsumerWidget { title: Text(member.displayName), subtitle: Text(member.userId), onTap: () => addTag( - id: "[@${member.displayName}](https://matrix.to/#/${member.userId})", + id: "[@${member.displayName}](matrix:u/${member.userId.substring(1)})", name: member.userId .substring(1) .split(":") @@ -78,33 +79,43 @@ class MentionOverlay extends ConsumerWidget { (query.isEmpty ? rooms.values : rooms.values.where( - (room) => (room.metadata?.name ?? "Unnamed Room") - .toLowerCase() - .contains(query.toLowerCase()), + (room) => + (room.metadata?.name ?? room.metadata!.id) + .toLowerCase() + .contains(query.toLowerCase()), )) - .map( - (room) => ListTile( + .map((room) { + final name = + room.metadata?.name ?? + room.metadata!.canonicalAlias ?? + room.metadata!.id; + return ListTile( leading: AvatarOrHash( room.metadata?.avatar, - room.metadata?.name ?? "Unnamed Room", + name, fallback: Icon(Icons.numbers), ), - title: Text(room.metadata?.name ?? "Unnamed Room"), + title: Text(name), subtitle: room.metadata?.topic == null ? null : Text(room.metadata!.topic!, maxLines: 1), - 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 ?? - "", - ), - ), - ) + 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 ?? + "", + ); + }, + ); + }) .toList(), ), diff --git a/lib/widgets/chat_page/composer/relation_preview.dart b/lib/widgets/chat_page/composer/relation_preview.dart index 7fded20..c90b07b 100644 --- a/lib/widgets/chat_page/composer/relation_preview.dart +++ b/lib/widgets/chat_page/composer/relation_preview.dart @@ -2,7 +2,6 @@ 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"; @@ -12,11 +11,9 @@ 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, @@ -35,27 +32,38 @@ 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!, room), - MessageDisplayname( - relatedMessage!, - room, - style: theme.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), + + MessageAvatar(relatedMessage!), + Expanded( - child: Text( - relatedMessage?.metadata?["body"] ?? - relatedMessage?.metadata?["eventType"], - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelMedium, - maxLines: 1, + 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, + ), + ), + ], ), ), @@ -70,11 +78,12 @@ class RelationPreview extends ConsumerWidget { ), ), ), + IconButton( tooltip: "Cancel ${relationType == RelationType.edit ? "edit" : "reply"}", onPressed: onDismiss, - icon: Icon(Icons.close), + icon: const 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 new file mode 100644 index 0000000..0c43c48 --- /dev/null +++ b/lib/widgets/chat_page/emoji_picker_button.dart @@ -0,0 +1,41 @@ +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 dcc1d49..fb533ad 100644 --- a/lib/widgets/chat_page/html/html.dart +++ b/lib/widgets/chat_page/html/html.dart @@ -1,12 +1,15 @@ +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"; @@ -30,20 +33,29 @@ class Html extends ConsumerWidget { return InlineCustomWidget(child: SpoilerText(text: element.text)); } - final height = int.tryParse(element.attributes["height"] ?? "") ?? 300; + final height = + int.tryParse(element.attributes["height"] ?? "") ?? + (element.attributes.keys.contains("data-mx-emoticon") ? 32 : null) ?? + 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" - ? element.outerHtml.contains("
") - ? Html( - """
${element.outerHtml.replaceAll("
", "\n")}
""", - ) - : CodeBlock( - element.text, - lang: element.className.replaceAll("language-", ""), - ) + ? CodeBlock( + element.text, + lang: element.className.replaceAll("language-", ""), + ) : null, "blockquote" => Quoted(Html(element.innerHtml)), @@ -51,39 +63,40 @@ class Html extends ConsumerWidget { "a" => element.attributes["href"]?.mention == null ? null - : InlineCustomWidget(child: MentionChip(element.text)), + : InlineCustomWidget( + child: MentionChip(element.attributes["href"]!), + ), "img" => - element.attributes["src"] == null + src == null ? SizedBox.shrink() : InlineCustomWidget( alignment: PlaceholderAlignment.middle, - 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, + child: ExpandableImage( + src, + child: Image( + image: CachedNetworkImage( + src, + ref.watch(CrossCacheController.provider), + headers: ref.headers, ), + 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 c2b832d..575ad03 100644 --- a/lib/widgets/chat_page/html/mention_chip.dart +++ b/lib/widgets/chat_page/html/mention_chip.dart @@ -1,25 +1,44 @@ 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 StatelessWidget { - final String label; - const MentionChip(this.label, {super.key}); +class MentionChip extends ConsumerWidget { + final String content; + const MentionChip(this.content, {super.key}); @override - Widget build(BuildContext context) => ActionChip( - label: Text( - label.mention ?? label, - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onPrimary, + 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, + ), ), - ), - 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 new file mode 100644 index 0000000..e718200 --- /dev/null +++ b/lib/widgets/chat_page/join_dialog.dart @@ -0,0 +1,137 @@ +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 71fcf84..dc8dfef 100644 --- a/lib/widgets/chat_page/lazy_loading/message_avatar.dart +++ b/lib/widgets/chat_page/lazy_loading/message_avatar.dart @@ -1,28 +1,30 @@ -import "package:flutter/widgets.dart"; +import "package:flutter/material.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/author_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/configs/author_config.dart"; -import "package:nexus/models/room.dart"; +import "package:nexus/helpers/extensions/show_user_popover.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.room, {this.height = 16, super.key}); + const MessageAvatar(this.message, {this.height = 16, super.key}); @override Widget build(BuildContext context, WidgetRef ref) => ref - .watch( - AuthorController.provider(AuthorConfig(room: room, message: message)), - ) + .watch(AuthorController.provider(message)) .betterWhen( - data: (membership) => AvatarOrHash( - membership.avatarUrl, - membership.displayName, - height: height, + data: (membership) => InkWell( + onTapUp: (details) => context.showUserPopover( + membership, + globalPosition: details.globalPosition, + ), + child: 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 7c10df3..88d2fa6 100644 --- a/lib/widgets/chat_page/lazy_loading/message_displayname.dart +++ b/lib/widgets/chat_page/lazy_loading/message_displayname.dart @@ -1,27 +1,37 @@ -import "package:flutter/widgets.dart"; +import "package:flutter/material.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/author_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/configs/author_config.dart"; -import "package:nexus/models/room.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; class MessageDisplayname extends ConsumerWidget { final Message message; - final Room room; final TextStyle? style; - const MessageDisplayname(this.message, this.room, {this.style, super.key}); + final bool clickable; + const MessageDisplayname( + this.message, { + this.clickable = true, + this.style, + super.key, + }); @override Widget build(BuildContext context, WidgetRef ref) => ref - .watch( - AuthorController.provider(AuthorConfig(room: room, message: message)), - ) + .watch(AuthorController.provider(message)) .betterWhen( - data: (membership) => Text( - "${membership.displayName} ${message.metadata?["pmp"] == null ? "" : "(via ${message.authorId})"}", - style: style, - overflow: TextOverflow.ellipsis, + 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, + ), ), loading: () => Text(""), ); diff --git a/lib/widgets/chat_page/member_list.dart b/lib/widgets/chat_page/member_list.dart index 8cdbbb9..8be1ddd 100644 --- a/lib/widgets/chat_page/member_list.dart +++ b/lib/widgets/chat_page/member_list.dart @@ -1,27 +1,31 @@ import "package:flutter/material.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/members_controller.dart"; +import "package:nexus/controllers/members_by_type_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/room.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/membership_status.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; -class MemberList extends ConsumerWidget { - final Room room; - const MemberList(this.room, {super.key}); +class MemberList extends HookConsumerWidget { + const MemberList({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final membersProvider = ref.watch(MembersController.provider(room)); + final status = useState(MembershipStatus.join); + final membersProvider = ref.watch( + MembersByTypeController.provider(status.value), + ); + return Drawer( shape: Border(), child: Column( + spacing: 8, children: [ AppBar( scrolledUnderElevation: 0, leading: Icon(Icons.people), - title: Text( - "Members ${membersProvider.when(data: (members) => "${members.length}", error: (_, _) => "", loading: () => "")}", - ), + title: Text("Members"), actionsPadding: EdgeInsets.only(right: 4), actions: [ if (Scaffold.of(context).hasEndDrawer) @@ -32,28 +36,50 @@ class MemberList extends ConsumerWidget { ), ], ), + 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) => ListTile( - onTap: () => showDialog( - context: context, - builder: (context) => - Dialog(child: Text("TODO: Open member popover")), + (member) => InkWell( + onTapUp: (details) => context.showUserPopover( + member, + globalPosition: details.globalPosition, ), - leading: AvatarOrHash( - member.avatarUrl, - member.displayName, - ), - title: Text( - member.displayName, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - member.userId, - overflow: TextOverflow.ellipsis, + child: ListTile( + 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 b9fa2e1..b999be4 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,12 +16,10 @@ 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, @@ -29,73 +27,75 @@ class ReplyWidget extends ConsumerWidget { }); @override - 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!, + Widget build(BuildContext context, WidgetRef ref) { + final room = ref.watch(SelectedRoomController.provider); + return message.replyToMessageId == null || room == null + ? SizedBox.shrink() + : Padding( + padding: EdgeInsets.only(bottom: 12), + child: Quoted( + ref + .watch( + EventController.provider( + GetEventRequest( + room: room, + eventId: message.replyToMessageId!, + ), ), - ), - ) - .betterWhen( - loading: () => Text("Fetching event..."), - data: (event) => event == null - ? SizedBox.shrink() - : ref - .watch( - MessageController.provider( - MessageConfig(room: room, event: event), - ), - ) - .betterWhen( - loading: () => Text("Parsing message..."), - data: (replyMessage) { - if (replyMessage == null) { - return SizedBox.shrink(); - } + ) + .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, - ), + 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, + 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 03cd994..62e282d 100644 --- a/lib/widgets/chat_page/room_appbar.dart +++ b/lib/widgets/chat_page/room_appbar.dart @@ -1,21 +1,20 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; -import "package:nexus/models/room.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/chat_page/expandable_image.dart"; import "package:nexus/widgets/chat_page/room_menu.dart"; -class RoomAppbar extends StatelessWidget implements PreferredSizeWidget { +class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { final bool isDesktop; - final Room room; - final void Function(BuildContext context) onOpenMemberList; + final void Function(BuildContext context)? onOpenMemberList; final void Function(BuildContext context) onOpenDrawer; - const RoomAppbar( - this.room, { + const RoomAppbar({ required this.isDesktop, - required this.onOpenMemberList, required this.onOpenDrawer, + this.onOpenMemberList, super.key, }); @@ -23,50 +22,57 @@ class RoomAppbar extends StatelessWidget implements PreferredSizeWidget { Size get preferredSize => AppBar().preferredSize; @override - 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), + 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, + ), + ), + ], ), - ) - : DrawerButton(onPressed: () => onOpenDrawer(context)), - scrolledUnderElevation: 0, - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - room.metadata?.name ?? "Unnamed Room", - overflow: TextOverflow.ellipsis, - maxLines: 1, + actions: [ + IconButton( + onPressed: null, + icon: Icon(Icons.push_pin), + tooltip: "Open pinned messages", ), - 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(), - ); + IconButton( + onPressed: () => onOpenMemberList?.call(context), + tooltip: "Open member list", + icon: Icon(Icons.people), + ), + if (room != null) RoomMenu(room), + ].toIList(), + ); + } } diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index cfbd1a8..5166d87 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -1,19 +1,26 @@ +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"; @@ -21,7 +28,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:dynamic_polls/dynamic_polls.dart"; +import "package:nexus/main.dart"; class RoomChat extends HookConsumerWidget { final bool isDesktop; @@ -35,46 +42,128 @@ class RoomChat extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final client = ref.watch(ClientController.provider.notifier); - final replyToMessage = useState(null); + final relatedMessage = 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 (room == null || userId == null || room.metadata?.id == null) { - return Center( - child: Text( - "Nothing to see here...", - style: theme.textTheme.headlineMedium, + if (roomId == null || userId == null) { + return Scaffold( + appBar: RoomAppbar( + isDesktop: isDesktop, + onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), + onOpenMemberList: null, + ), + body: Center( + child: Text( + "Nothing to see here...", + style: theme.textTheme.headlineMedium, + ), ), ); } - final controllerProvider = RoomChatController.provider(room.metadata!.id); + final controllerProvider = RoomChatController.provider(roomId); final notifier = ref.watch(controllerProvider.notifier); + final composerNode = useFocusNode( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { + relatedMessage.value = null; + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + }, + ); + List getMessageOptions(Message message) { final isSentByMe = message.authorId == userId; return [ + 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: () { - replyToMessage.value = message; + relatedMessage.value = message; relationType.value = RelationType.reply; + composerNode.requestFocus(); }, child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")), ), if (message is TextMessage && isSentByMe) PopupMenuItem( onTap: () { - replyToMessage.value = message; + relatedMessage.value = message; relationType.value = RelationType.edit; + composerNode.requestFocus(); }, child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")), ), - if (isSentByMe) // TODO: Or if user has permission to redact others' messages + PopupMenuItem( + onTap: () async { + final room = ref.watch(SelectedRoomController.provider); + if (room == null) return; + + final vias = ref.watch(ViaController.provider(room)); + + await Clipboard.setData( + ClipboardData( + text: + "matrix:roomid/${room.metadata?.id.substring(1)}/e/${message.id}$vias)", + ), + ); + }, + child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), + ), + if (ref.watch( + PowerLevelController.provider( + PowerLevelConfig(eventType: "m.room.redaction"), + ), + )) PopupMenuItem( onTap: () => showDialog( context: context, @@ -106,11 +195,13 @@ 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"), ), @@ -119,7 +210,10 @@ class RoomChat extends HookConsumerWidget { }, ), ), - child: ListTile(leading: Icon(Icons.delete), title: Text("Delete")), + child: ListTile( + leading: Icon(Icons.delete, color: danger), + title: Text("Delete", style: TextStyle(color: danger)), + ), ), PopupMenuItem( onTap: () => showDialog( @@ -153,10 +247,9 @@ class RoomChat extends HookConsumerWidget { ), TextButton( onPressed: () { - if (room.metadata == null) return; client.reportEvent( ReportRequest( - roomId: room.metadata!.id, + roomId: roomId, eventId: message.id, reason: reasonController.text.isEmpty ? null @@ -189,7 +282,6 @@ class RoomChat extends HookConsumerWidget { return Scaffold( appBar: RoomAppbar( - room, isDesktop: isDesktop, onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), onOpenMemberList: (thisContext) { @@ -237,18 +329,42 @@ class RoomChat extends HookConsumerWidget { chatAnimatedListBuilder: (_, itemBuilder) => ChatAnimatedList( itemBuilder: itemBuilder, - onEndReached: room.hasMore + onEndReached: + ref.watch( + SelectedRoomController.provider.select( + (room) => room?.hasMore == true, + ), + ) ? notifier.loadOlder : null, - onStartReached: () => client.markRead(room), + onStartReached: () async { + final room = ref.watch( + SelectedRoomController.provider, + ); + return room == null + ? null + : await client.markRead(room); + }, bottomPadding: 72, ), composerBuilder: (_) => ChatBox( + node: composerNode, + onSend: + ( + text, { + required shouldMention, + required tags, + }) => notifier.send( + text, + tags: tags, + relationType: relationType.value, + shouldMention: shouldMention, + relation: relatedMessage.value, + ), relationType: relationType.value, - relatedMessage: replyToMessage.value, - onDismiss: () => replyToMessage.value = null, - room: room, + relatedMessage: relatedMessage.value, + onDismiss: () => relatedMessage.value = null, ), textMessageBuilder: @@ -259,7 +375,6 @@ class RoomChat extends HookConsumerWidget { required bool isSentByMe, MessageGroupStatus? groupStatus, }) => TextMessageWrapper( - room: room, message, content: message.text, groupStatus: groupStatus, @@ -277,7 +392,6 @@ class RoomChat extends HookConsumerWidget { MessageGroupStatus? groupStatus, }) => TextMessageWrapper( message, - room: room, content: message.text, groupStatus: groupStatus, onTapReply: notifier.scrollToMessage, @@ -309,7 +423,6 @@ class RoomChat extends HookConsumerWidget { ), child: FlyerChatFileMessage( topWidget: ReplyWidget( - room: room, message, onTapReply: notifier.scrollToMessage, groupStatus: groupStatus, @@ -319,7 +432,6 @@ class RoomChat extends HookConsumerWidget { ), ), groupStatus, - room, ), systemMessageBuilder: @@ -348,7 +460,7 @@ class RoomChat extends HookConsumerWidget { ), ), ), - resolveUser: notifier.resolveUser, + resolveUser: (_) async => null, chatController: controller, ), ), @@ -358,11 +470,11 @@ class RoomChat extends HookConsumerWidget { ), if (memberListOpened.value == true && showMembersByDefault) - MemberList(room), + MemberList(), ], ), - endDrawer: showMembersByDefault ? null : MemberList(room), + endDrawer: showMembersByDefault ? null : MemberList(), ); } } diff --git a/lib/widgets/chat_page/room_menu.dart b/lib/widgets/chat_page/room_menu.dart index 2687bc8..4405707 100644 --- a/lib/widgets/chat_page/room_menu.dart +++ b/lib/widgets/chat_page/room_menu.dart @@ -1,7 +1,9 @@ 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 { @@ -16,13 +18,6 @@ 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); @@ -33,6 +28,18 @@ 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 771a7ae..f79c38f 100644 --- a/lib/widgets/chat_page/sidebar.dart +++ b/lib/widgets/chat_page/sidebar.dart @@ -1,15 +1,11 @@ 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; @@ -92,53 +88,7 @@ class Sidebar extends HookConsumerWidget { PopupMenuItem( onTap: () => showDialog( context: context, - 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"), - ), - ], - ); - }, - ), + builder: (_) => JoinDialog(ref), ), child: ListTile( title: Text("Join an existing room (or space)"), @@ -146,7 +96,7 @@ class Sidebar extends HookConsumerWidget { ), ), PopupMenuItem( - onTap: () {}, + onTap: null, child: ListTile( title: Text("Create a new room"), leading: Icon(Icons.add), @@ -157,17 +107,15 @@ class Sidebar extends HookConsumerWidget { ), IconButton( tooltip: "Explore other rooms", - onPressed: () => showDialog( - context: context, - builder: (context) => AlertDialog(title: Text("To-do")), - ), + onPressed: null, icon: Icon(Icons.explore), ), IconButton( tooltip: "Open settings", - onPressed: () => Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => SettingsPage())), + onPressed: null, + // () => 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 new file mode 100644 index 0000000..a9a4799 --- /dev/null +++ b/lib/widgets/chat_page/user_popover.dart @@ -0,0 +1,214 @@ +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 1be6c2b..9c70c27 100644 --- a/lib/widgets/chat_page/wrappers/message_wrapper.dart +++ b/lib/widgets/chat_page/wrappers/message_wrapper.dart @@ -1,59 +1,83 @@ 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, - this.room, { - super.key, - }); + const MessageWrapper(this.message, this.child, this.groupStatus, {super.key}); @override - 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, + Widget build(BuildContext context) { + final theme = Theme.of(context); + final error = message.metadata?["error"]; + + return ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(12)), + child: AnimatedContainer( + padding: message.metadata?["flashing"] == true + ? EdgeInsets.all(8) + : EdgeInsets.all(0), + color: message.metadata?["flashing"] == true + ? Theme.of(context).colorScheme.onSurface.withAlpha(50) + : Colors.transparent, + duration: Duration(milliseconds: 250), + child: Row( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + groupStatus?.isFirst != false + ? MessageAvatar(message, height: 40) + : SizedBox(width: 40), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + if (groupStatus?.isFirst != false) + Row( + spacing: 4, + children: [ + Flexible( + child: MessageDisplayname( + message, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + if (message.deliveredAt != null && + groupStatus?.isFirst != false) + Tooltip( + message: message.deliveredAt!.toString(), + child: Text( + format(message.deliveredAt!), + style: theme.textTheme.labelSmall?.copyWith( + color: Colors.grey, + ), + ), + ), + ], ), - ), - child, - ], + child, + if (error != null && error != "not sent") + Text( + error, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ReactionRow(message), + ], + ), ), - ), - ], + ], + ), ), - ), - ); + ); + } } diff --git a/lib/widgets/chat_page/wrappers/reaction_row.dart b/lib/widgets/chat_page/wrappers/reaction_row.dart new file mode 100644 index 0000000..5e8fe86 --- /dev/null +++ b/lib/widgets/chat_page/wrappers/reaction_row.dart @@ -0,0 +1,116 @@ +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 41bc01e..8d7a625 100644 --- a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart +++ b/lib/widgets/chat_page/wrappers/text_message_wrapper.dart @@ -1,15 +1,21 @@ +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:nexus/models/room.dart"; +import "package:flutter_linkify/flutter_linkify.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/cross_cache_controller.dart"; +import "package:nexus/controllers/url_preview_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/widgets/chat_page/html/html.dart"; import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart"; import "package:nexus/widgets/chat_page/reply_widget.dart"; -class TextMessageWrapper extends StatelessWidget { +class TextMessageWrapper extends ConsumerWidget { final Message message; final String? content; - final Room room; final MessageGroupStatus? groupStatus; final Future Function(Message oldMessage, Message newMessage) updateMessage; @@ -21,7 +27,6 @@ class TextMessageWrapper extends StatelessWidget { this.message, { this.content, this.onTapReply, - required this.room, required this.updateMessage, required this.groupStatus, required this.isSentByMe, @@ -30,11 +35,17 @@ class TextMessageWrapper extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final textMessage = message is TextMessage ? message as TextMessage : null; + final link = textMessage == null + ? null + : RegExp( + r'''https?://[^\s"'<>]+''', + ).allMatches(textMessage.text).firstOrNull?.group(0); + return MessageWrapper( message, ClipRRect( @@ -43,7 +54,9 @@ class TextMessageWrapper extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), decoration: BoxDecoration( color: isSentByMe - ? colorScheme.primaryContainer + ? (message.id.startsWith("~") + ? colorScheme.onPrimary + : colorScheme.primaryContainer) : colorScheme.surfaceContainer, ), child: Column( @@ -51,65 +64,84 @@ class TextMessageWrapper extends StatelessWidget { children: [ ReplyWidget( message, - room: room, groupStatus: groupStatus, onTapReply: onTapReply, ), if (content != null) - 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)!; - } + message.metadata?["format"] == "org.matrix.custom.html" + ? Html( + textStyle: message.metadata?["big"] == true + ? TextStyle(fontSize: 32) + : null, + content!.replaceAllMapped( + RegExp( + "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", + caseSensitive: false, + dotAll: true, + ), + (m) { + // If it's already an tag, leave it unchanged + if (m.group(1) != null) { + return m.group(1)!; + } - // Otherwise, wrap the bare URL - final url = m.group(2)!; - return "$url"; - }, + // Otherwise, wrap the bare URL + final url = m.group(2)!; + return "$url"; + }, + ), ) - .replaceAll("\n", "
"), - ), + : Linkify( + text: content!, + options: LinkifyOptions(humanize: false), + onOpen: (link) => ref + .watch(LaunchHelper.provider) + .launchUrl(Uri.parse(link.url)), + linkStyle: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + ), if (textMessage?.editedAt != null) Text("(edited)", style: theme.textTheme.labelSmall), - if (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 (link != null) + ref + .watch(UrlPreviewController.provider(link)) + .betterWhen( + loading: SizedBox.shrink, + data: (preview) => preview == null + ? SizedBox.shrink() + : LinkPreview( + onTap: (url) => ref + .watch(LaunchHelper.provider) + .launchUrl(Uri.parse(url)), + imageBuilder: (url) => Image( + image: CachedNetworkImage( + url, + ref.watch(CrossCacheController.provider), + headers: ref.headers, + ), + fit: BoxFit.cover, + errorBuilder: (_, _, _) => SizedBox.shrink(), + ), + text: link, + backgroundColor: isSentByMe + ? colorScheme.inversePrimary + : colorScheme.surfaceContainerLow, + outsidePadding: EdgeInsets.only(top: 4), + insidePadding: EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + linkPreviewData: preview, + onLinkPreviewDataFetched: (_) => null, + ), ), - ), - ), if (extra != null) extra!, ], ), ), ), groupStatus, - room, ); } } diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index ae1ddeb..26f2a17 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -26,6 +26,7 @@ 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 de807aa..ef7fcd9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -354,6 +354,15 @@ 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: @@ -466,11 +475,10 @@ packages: flutter_chat_ui: dependency: "direct main" description: - path: "packages/flutter_chat_ui" - ref: HEAD - resolved-ref: "03be67c8c81c8f637672ee03dd8f082d2c223627" - url: "https://github.com/Henry-Hiles/flutter_chat_ui" - source: git + name: flutter_chat_ui + sha256: cfbaac38f429beb33d9cc1ca920ae7ccbadbed282c99335d590d61306d3a3d0f + url: "https://pub.dev" + source: hosted version: "2.11.1" flutter_hooks: dependency: "direct main" @@ -491,12 +499,19 @@ packages: flutter_link_previewer: dependency: "direct main" description: - path: "packages/flutter_link_previewer" - ref: HEAD - resolved-ref: "03be67c8c81c8f637672ee03dd8f082d2c223627" - url: "https://github.com/Henry-Hiles/flutter_chat_ui" - source: git + name: flutter_link_previewer + sha256: "346f345064e65bc8bf739bccf19d6d6ca50f8183ffc52e452afa58c06ee2cbf7" + url: "https://pub.dev" + source: hosted 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: @@ -657,7 +672,7 @@ packages: source: hosted version: "0.15.6" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -824,6 +839,14 @@ 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: @@ -1357,6 +1380,14 @@ 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 80c3687..7ecefa1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,14 +40,8 @@ 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: - 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 + flutter_chat_ui: ^2.11.1 + flutter_link_previewer: ^4.2.0 color_hash: ^1.0.1 flutter_widget_from_html_core: ^0.17.0 flutter_svg: ^2.2.2 @@ -61,6 +55,12 @@ 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/foreground.png + adaptive_icon_foreground: assets/smallerForeground.png remove_alpha_ios: true windows: generate: true \ No newline at end of file