diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index c07f0ad..93a5892 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -19,13 +19,20 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: 3.41.5 + flutter-version: 3.41.9 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: gomuks/go.mod + - name: Setup MSYS2 + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + install: >- + mingw-w64-x86_64-gcc + - name: Go build run: | cd gomuks/pkg/ffi @@ -38,6 +45,13 @@ jobs: flutter pub run build_runner build flutter build windows --release + - name: Copy MinGW runtime DLLs + shell: msys2 {0} + run: | + cp /mingw64/bin/libgcc_s_seh-1.dll build/windows/x64/runner/Release/ + cp /mingw64/bin/libwinpthread-1.dll build/windows/x64/runner/Release/ + cp /mingw64/bin/libstdc++-6.dll build/windows/x64/runner/Release/ + - name: Upload exe zip uses: actions/upload-artifact@v6 with: diff --git a/.vscode/settings.json b/.vscode/settings.json index da80f4b..068916b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,8 +5,11 @@ "fluttertagger", "Gomuks", "Homeserver", + "Linkified", "localpart", + "msgtype", "muks", - "prefs" + "prefs", + "unban" ] } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..bfd78a8 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,70 @@ +# Development Documentation + +## Build instructions + +Build instructions can be found in [README.md](./README.md#build-it-yourself). + +## Updating Gomuks + +You can run the following command to update the Gomuks submodule: + +```sh +git submodule update --remote +``` + +## Code Style + +See [Effective Dart: Style](https://dart.dev/effective-dart/style) for general rules. There are some extra rules detailed below: + +### Controllers and Helpers ([Riverpod](https://pub.dev/packages/riverpod)) + +Controllers live in `lib/controllers/` and provide a source that exposes data and logic via Riverpod providers, allowing other parts of the code to watch state changes with ref.watch (`ref.watch(MyController.provider)`), access the current value with ref.read (`ref.read(MyController.provider)`), and run helper methods on those classes using the notifier: + +```dart +ref.watch(MyController.provider.notifier).helperMethod() +``` + +We use an object oriented style for controllers, where `provider` is a static member on the controller class. E.g. + +```dart +class MyController extends AsyncNotifier { + final SomeInputType input; + MyController(this.input); + + @override + Future build() async { + return input.foo; + } + + static final provider = + AsyncNotifierProvider.family( + AuthorController.new, + ); +} +``` + +Providers which are not controllers, e.g. they expose no data, only methods, should instead live in `lib/helpers/`. For an example, see `lib/helpers/launch_helper.dart`. Other, non-provider helpers, like extensions or helper methods can also go in `lib/helpers/`. + +### Don't use StatefulWidgets ([Flutter Hooks](https://pub.dev/packages/flutter_hooks)) + +This project uses Flutter Hooks to help with boilerplate that StatefulWidgets create. Instead of using a StatefulWidget, we just use hooks like `useState` or `useEffect` in the build method of a `HookWidget`, which is a drop in replacement for `StatelessWidget`. If you need both a `WidgetRef` to watch providers, and access to hooks, use `HookConsumerWidget`. + +### Models ([Freezed](https://pub.dev/packages/freezed)) + +We use Freezed for our models to avoid boilerplate and enforce an immutable style of state and data modeling throughout the code. See their documentation for more info, or see our existing models in `lib/models/`. + +### Immutable Data Collections ([Fast Immutable Collections](https://pub.dev/packages/fast_immutable_collections)) + +When possible, use immutable collections instead of the mutable equivalent. For example, use `IMap` over `Map`, `IList` over `List`, `ISet` over `Set`. This matches the immutable style of Riverpod and Freezed. + +### Don't create globals + +When possible, we prefer not to create global variables or methods. You can usually replace a global variable with a Riverpod controller, and a global method with an extension method. + +## LLM/AI Assisted Contributions + +Largely LLM generated code is NOT allowed. All contributions should be written by humans, with minimal to no LLM assistance. Please disclose any usage of LLMs. + +## Code of Conduct + +All contributions must follow the [Federated Nexus Code of Conduct](https://federated.nexus/code/). diff --git a/README.md b/README.md index 0cd7787..bb61124 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,11 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. ## Progress -- [ ] New logo -- [ ] Make context menus appear as bottom sheets on mobile -- [x] Move from the Dart SDK to the Gomuks Backend with Dart bindings: https://git.federated.nexus/Henry-Hiles/nexus/pulls/2 - - [ ] Allow using remote Gomuks over websocket - [ ] Platform Support - [x] Linux - - [ ] Windows (WIP) - - [ ] MacOS + - [x] Windows - [x] Android + - [ ] MacOS - [ ] iOS - [ ] Web (may not be possible) - [x] Login @@ -43,7 +39,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [x] `matrix:` Uri - [x] Matrix.to link - [ ] From space - - [ ] Exploring + - [ ] From directory - [x] Leaving - [x] Subspaces - [x] Messages @@ -54,7 +50,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [x] HTML/Markdown - [x] Replies - [x] Choose ping on/off - - [ ] Per message profiles + - [x] Per message profiles - [ ] Attachments - [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391) - [x] Mentions @@ -85,7 +81,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [x] Users - [x] Clickable - [x] Rooms - - [x] Clickable + - [ ] Clickable - [x] Matrix URIs - [x] Matrix.to links - [x] Events @@ -111,15 +107,23 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [ ] Reporting - [x] Events - [ ] Rooms -- [ ] Notifications using UnifiedPush +- [x] Member list + - [x] Sort by power level + - [ ] Colors based off of power level +- [ ] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35)) - [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) - [ ] Invites -- [ ] Settings +- [ ] Settings ([#37](https://git.federated.nexus/Nexus/nexus/issues/37)) - [ ] Matrix: URIs vs Matrix.to links - [ ] Light/Dark mode + - [ ] Remote Gomuks instance - [ ] SSD or CSD + - [ ] Align your message bubbles to left or right - [ ] Show media by default - [ ] Dynamic Theming + - [ ] Personas + - [ ] Setting per-message profiles for users (MSC4461) + - [ ] Explain how to send messages using a certain PMP - [ ] Devices - [ ] Viewing devices - [ ] Verifying devices @@ -145,7 +149,7 @@ If you want to try out Nexus, grab one of the following artifacts from CI: - [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` +Or, try the Nix package: `nix run git+https://git.federated.nexus/Nexus/nexus` ## Build it yourself @@ -188,7 +192,7 @@ Similar prerequisites apply (Flutter, Git, Go, C toolchain, LLVM/libclang), but First, clone and open the repo: ```sh -git clone --recurse-submodules https://git.federated.nexus/Henry-Hiles/nexus +git clone --recurse-submodules https://git.federated.nexus/Nexus/nexus cd nexus ``` @@ -206,10 +210,17 @@ Generate Gomuks bindings: dart scripts/generate.dart ``` +> [!NOTE] +> If you are having issues with `stddef.h` not being found, try setting CPATH manually: +> +> ```sh +> export CPATH="$(clang -v 2>&1 | grep "Selected GCC installation" | rev | cut -d' ' -f1 | rev)/include" +> ``` + Build generated files, and watch for new changes: ```sh -flutter pub run build_runner watch --delete-conflicting-outputs +flutter pub run build_runner watch ``` Run the app: @@ -218,6 +229,13 @@ Run the app: flutter run ``` +Development instructions can be found in [DEVELOPMENT.md](./DEVELOPMENT.md). + ## Community Join the [Nexus Client Matrix Room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client. + +# Credits + +Thank you Hylke Bons (https://planetpeanut.studio) for making the amazing icon for Nexus! +Thank you Tulir Asokan for making [Gomuks](https://github.com/gomuks/gomuks), and helping us integrate it into Nexus! 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 index 791aed8..874adb1 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png 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 86ebaa5..e74a72b 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-hdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..f65add9 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.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 index b00666d..f1bd7fc 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png 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 3c64f70..288167d 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-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..624519b Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.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 index bad307d..1550061 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png 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 c2b441b..d1aaece 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-xhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..81a2593 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.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 index b3e4f12..6a9cce4 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png 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 e472dab..3277b43 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-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..d10b599 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.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 index 0aac7b2..7d9e6cb 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png 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 64a5154..9380189 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/drawable-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..0ffb5be Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index d047760..854fef6 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 @@ -6,4 +6,9 @@ android:drawable="@drawable/ic_launcher_foreground" android:inset="16%" /> + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 80efd04..e97fe0e 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index b02e5ef..4e9192d 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 54aed69..f18b718 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index eb2221d..2f6a559 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index c5ac464..0118074 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/background.png b/assets/background.png index c418606..9f1d8e7 100644 Binary files a/assets/background.png and b/assets/background.png differ diff --git a/assets/background.svg b/assets/background.svg index 1e0699d..749e03a 100644 --- a/assets/background.svg +++ b/assets/background.svg @@ -1,21 +1,22 @@ - - + + + + + + stop-color="#3584E4" + id="stop11" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/foreground.png b/assets/foreground.png index 76a446a..a98eb11 100644 Binary files a/assets/foreground.png and b/assets/foreground.png differ diff --git a/assets/foreground.svg b/assets/foreground.svg index 4f2f2b2..9aad561 100644 --- a/assets/foreground.svg +++ b/assets/foreground.svg @@ -1,20 +1,19 @@ - - + + inkscape:current-layer="svg11" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icon.png b/assets/icon.png index 04b75cb..d6d4906 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/assets/icon.svg b/assets/icon.svg index 0effd9a..b36fa26 100644 --- a/assets/icon.svg +++ b/assets/icon.svg @@ -1,21 +1,22 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + stop-color="#26A269" + id="stop16" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/mobile.png b/assets/mobile.png new file mode 100644 index 0000000..6b1b81c Binary files /dev/null and b/assets/mobile.png differ diff --git a/assets/mobile.svg b/assets/mobile.svg new file mode 100644 index 0000000..7ca0a7d --- /dev/null +++ b/assets/mobile.svg @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/monochrome.png b/assets/monochrome.png new file mode 100644 index 0000000..941c706 Binary files /dev/null and b/assets/monochrome.png differ diff --git a/assets/monochrome.svg b/assets/monochrome.svg new file mode 100644 index 0000000..a86f36e --- /dev/null +++ b/assets/monochrome.svg @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/reactions.png b/assets/reactions.png deleted file mode 100644 index c413051..0000000 Binary files a/assets/reactions.png and /dev/null differ diff --git a/assets/screenshotDark.png b/assets/screenshotDark.png index ae75dc7..322a64e 100644 Binary files a/assets/screenshotDark.png and b/assets/screenshotDark.png differ diff --git a/assets/screenshotLight.png b/assets/screenshotLight.png index 0b2ce0d..8772bcf 100644 Binary files a/assets/screenshotLight.png and b/assets/screenshotLight.png differ diff --git a/assets/smallerForeground.png b/assets/smallerForeground.png deleted file mode 100644 index c962d2b..0000000 Binary files a/assets/smallerForeground.png and /dev/null differ diff --git a/assets/smallerForeground.svg b/assets/smallerForeground.svg deleted file mode 100644 index a821be9..0000000 --- a/assets/smallerForeground.svg +++ /dev/null @@ -1,126 +0,0 @@ - - - - diff --git a/flake.lock b/flake.lock index 8070c6c..d6167fb 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1767609335, - "narHash": "sha256-feveD98mQpptwrAEggBQKJTYbvwwglSbOv53uCfH9PY=", + "lastModified": 1778716662, + "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "250481aafeb741edfe23d29195671c19b36b6dca", + "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", "type": "github" }, "original": { @@ -42,11 +42,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1774604963, - "narHash": "sha256-MtAW1FIdirSlUAAO7s1u9auv5y3I6t3uJ+GeEbqiqxI=", + "lastModified": 1774860670, + "narHash": "sha256-YjJkQrvxrErXtfDi3obUn6rNmkA+CIAZ3f5NgL5xuYE=", "owner": "neobrain", "repo": "nix2flatpak", - "rev": "3e04657fbcb49956ac301410b071a7f0b2ad5988", + "rev": "61d68e21e3fbc2d57590051f48736bea271f4aba", "type": "github" }, "original": { @@ -73,11 +73,11 @@ }, "nixpkgs-lib": { "locked": { - "lastModified": 1765674936, - "narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=", + "lastModified": 1777168982, + "narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85", + "rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14", "type": "github" }, "original": { @@ -88,11 +88,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1767640445, - "narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=", + "lastModified": 1778869304, + "narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=", "owner": "nixos", "repo": "nixpkgs", - "rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5", + "rev": "d233902339c02a9c334e7e593de68855ad26c4cb", "type": "github" }, "original": { diff --git a/gomuks b/gomuks index daa0ba0..da3b823 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit daa0ba028e7d89ba9fc7580fc8099348e6145cb3 +Subproject commit da3b823e1435afd6f2a1ea6c354d9a35c489b624 diff --git a/hook/build.dart b/hook/build.dart index 165e613..6956d7c 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -3,6 +3,7 @@ import "package:hooks/hooks.dart"; import "package:code_assets/code_assets.dart"; Future main(List args) => build(args, (input, output) async { + if (!input.config.buildCodeAssets) return; final codeConfig = input.config.code; final targetOS = codeConfig.targetOS; final targetArch = codeConfig.targetArchitecture; diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 2b21522..0d531c4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 8471cd6..da4acee 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index c145b15..a3cfb1d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 5da5679..adbdcd5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index cd2b74f..fee4302 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index 68cbdbf..4d21624 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 306efe8..3e7a859 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index c145b15..a3cfb1d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 959cc28..c11ce99 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index d86b69c..25f2b47 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index 3a5c49b..8f79bb9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index e563327..c48dec6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index 30ae8c6..99d44e8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index 2fb68c4..6f987f0 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index d86b69c..25f2b47 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index 151862a..fcf969a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index c5ca065..1e0defa 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index a5880bd..3366fb5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 6ea8156..e280112 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 657cf77..efda04b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 87d1ce7..3774574 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/controllers/account_data_controller.dart b/lib/controllers/account_data_controller.dart index 125d7cf..0926e08 100644 --- a/lib/controllers/account_data_controller.dart +++ b/lib/controllers/account_data_controller.dart @@ -4,10 +4,10 @@ import "package:nexus/models/account_data.dart"; class AccountDataController extends Notifier> { @override - IMap build() => const IMap.empty(); + IMap build() => .new(); void update(IMap newData) => - state = IMap({...state.unlock, ...newData.unlock}); + state = .new({...state.unlock, ...newData.unlock}); static final provider = NotifierProvider>( diff --git a/lib/controllers/author_controller.dart b/lib/controllers/author_controller.dart index 70b7343..77202ca 100644 --- a/lib/controllers/author_controller.dart +++ b/lib/controllers/author_controller.dart @@ -1,47 +1,30 @@ import "dart:async"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/user_controller.dart"; -import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/models/membership.dart"; -import "package:nexus/models/membership_status.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/event.dart"; -class AuthorController extends AsyncNotifier { - final Message message; - AuthorController(this.message); +class AuthorController extends AsyncNotifier { + final Event event; + AuthorController(this.event); @override - Future build() async { + Future build() async { final member = await ref.watch( - UserController.provider(message.authorId).future, + UserController.provider( + .new(roomId: event.roomId, userId: event.sender), + ).future, ); - final pmp = message.metadata?["pmp"] == null - ? null - : Membership.fromContent( - IMap(message.metadata?["pmp"]), - message.authorId, - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - "", - ); - - return Membership( - status: member?.status ?? MembershipStatus.leave, - avatarUrl: pmp?.avatarUrl ?? member?.avatarUrl, - displayName: - pmp?.displayName ?? member?.displayName ?? message.authorId.localpart, - userId: message.authorId, + return .new( + status: member.status, + avatarUrl: event.pmp?.avatarUrl ?? member.avatarUrl, + displayName: event.pmp?.displayName ?? member.displayName, ); } static final provider = - AsyncNotifierProvider.family( + AsyncNotifierProvider.family( AuthorController.new, ); } diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index cc68871..fb57735 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -1,22 +1,19 @@ -import "dart:developer"; import "dart:ffi"; import "dart:io"; import "dart:isolate"; -import "package:collection/collection.dart"; +import "dart:math"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:ffi/ffi.dart"; import "package:flutter/foundation.dart"; import "package:nexus/controllers/account_data_controller.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/init_complete_controller.dart"; -import "package:nexus/controllers/new_events_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/space_edges_controller.dart"; import "package:nexus/controllers/sync_status_controller.dart"; 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"; import "package:nexus/models/requests/get_event_request.dart"; @@ -33,7 +30,6 @@ 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"; import "package:nexus/src/third_party/gomuks.g.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:path_provider/path_provider.dart"; @@ -68,26 +64,27 @@ class ClientController extends AsyncNotifier { case "client_state": ref .watch(ClientStateController.provider.notifier) - .set(ClientState.fromJson(decodedMuksEvent)); + .set(.fromJson(decodedMuksEvent)); break; case "sync_status": ref .watch(SyncStatusController.provider.notifier) - .set(SyncStatus.fromJson(decodedMuksEvent)); + .set(.fromJson(decodedMuksEvent)); break; case "init_complete": ref.watch(InitCompleteController.provider.notifier).complete(); break; case "send_complete": final event = Event.fromJson(decodedMuksEvent["event"]); + ref + .watch(RoomsController.provider.notifier) + .update( + .new({ + event.roomId: .new(events: .new({event.rowId: event})), + }), + .new(), + ); - 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); @@ -127,9 +124,12 @@ class ClientController extends AsyncNotifier { } debugPrint("Finished handling $muksEventType..."); } catch (error, stackTrace) { - debugger(); - showError(error, stackTrace); - debugPrintStack(stackTrace: stackTrace, label: error.toString()); + if (kDebugMode) { + debugPrintStack(stackTrace: stackTrace, label: error.toString()); + rethrow; + } else { + showError(error, stackTrace); + } } }); @@ -206,9 +206,11 @@ class ClientController extends AsyncNotifier { (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(); + return .new( + (response ?? await getState(request.copyWith(refetch: true)) ?? []).map( + (event) => .fromJson(event), + ), + ); } Future?> getRelatedEvents( @@ -216,24 +218,21 @@ class ClientController extends AsyncNotifier { ) async { final response = (await _sendCommand("get_related_events", request.toJson())) as List?; - return response?.map((event) => Event.fromJson(event)).toIList(); + return .new(response?.map((event) => .fromJson(event))); } Future getEvent(GetEventRequest request) async { - final event = request.room.events.firstWhereOrNull( - (event) => event.eventId == request.eventId, - ); - if (event != null) return event; - final json = await _sendCommand("get_event", request.toJson()); - return json == null ? null : Event.fromJson(json); + return json == null ? null : .fromJson(json); } Future paginate(PaginateRequest request) async => - Paginate.fromJson(await _sendCommand("paginate", request.toJson())); + .fromJson(await _sendCommand("paginate", request.toJson())); - Future getProfile(String userId) async => - Profile.fromJson(await _sendCommand("get_profile", {"user_id": userId})); + Future getProfile(String userId) async { + final json = await _sendCommand("get_profile", {"user_id": userId}); + return .fromJsonWithCatch({...json, "id": userId}); + } Future reportEvent(ReportRequest request) => _sendCommand("report_event", request.toJson()); @@ -242,9 +241,8 @@ class ClientController extends AsyncNotifier { _sendCommand("set_membership", request.toJson()); Future markRead(Room room) async { - final event = room.events.firstWhereOrNull( - (event) => event.rowId == room.timeline.last.eventRowId, - ); + final eventRowId = room.timeline[room.timeline.keys.reduce(max)]; + final event = eventRowId == null ? null : room.events[eventRowId]; if (event == null || room.metadata == null) return; await _sendCommand("mark_read", { @@ -263,12 +261,12 @@ class ClientController extends AsyncNotifier { } } - Future discoverHomeserver(Uri homeserver) async { + Future discoverHomeserver(Uri homeserver) async { try { final response = await _sendCommand("discover_homeserver", { "user_id": "@fake-user:${homeserver.host}", }); - return response["m.homeserver"]?["base_url"]; + return Uri.parse(response["m.homeserver"]?["base_url"]); } catch (error) { return null; } diff --git a/lib/controllers/client_state_controller.dart b/lib/controllers/client_state_controller.dart index 998d4a1..1b77ecb 100644 --- a/lib/controllers/client_state_controller.dart +++ b/lib/controllers/client_state_controller.dart @@ -5,9 +5,7 @@ class ClientStateController extends Notifier { @override Null build() => null; - void set(ClientState newState) { - state = newState; - } + void set(ClientState newState) => state = newState; static final provider = NotifierProvider( ClientStateController.new, diff --git a/lib/controllers/cross_cache_controller.dart b/lib/controllers/cross_cache_controller.dart index 1d6d4b6..4d5611a 100644 --- a/lib/controllers/cross_cache_controller.dart +++ b/lib/controllers/cross_cache_controller.dart @@ -2,11 +2,8 @@ import "package:cross_cache/cross_cache.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; class CrossCacheController extends Notifier { - static const String spaceKey = "space"; - static const String roomKey = "room"; - @override - CrossCache build() => CrossCache(); + CrossCache build() => .new(); static final provider = NotifierProvider( CrossCacheController.new, diff --git a/lib/controllers/emoji_controller.dart b/lib/controllers/emoji_controller.dart new file mode 100644 index 0000000..caea3de --- /dev/null +++ b/lib/controllers/emoji_controller.dart @@ -0,0 +1,84 @@ +import "dart:convert"; +import "package:emoji_text_field/models/emoji_category.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:http/http.dart"; +import "package:nexus/models/emoji.dart"; + +typedef EmojiTuple = (IMap, IMap>); + +class EmojiController extends AsyncNotifier { + @override + Future build() async { + final response = await get( + .https("github.com", "github/gemoji/raw/refs/heads/master/db/emoji.json"), + ); + + if (response.statusCode != 200) { + throw Exception("Failed to load emoji data"); + } + + final data = json.decode(response.body); + + final entries = (data as List) + .cast>() + .map(Emoji.fromJson) + .toIList(); + + final categoryMap = entries.fold>>( + .new(), + (acc, entry) => acc.update( + entry.category, + (list) => list.add(entry.emoji), + ifAbsent: () => .new([entry.emoji]), + ), + ); + + final keywordMap = entries.fold>>( + .new(), + (acc, entry) => acc.add( + entry.emoji, + .new([...entry.tags, ...entry.aliases, entry.description]), + ), + ); + + final customCategories = IMap.fromEntries( + categoryMap.entries.map( + (entry) => MapEntry( + entry.key, + EmojiCategory( + name: entry.key, + icon: switch (entry.key) { + "Smileys & Emotion" => Icons.emoji_emotions, + "People & Body" => Icons.emoji_people, + "Animals & Nature" => Icons.emoji_nature, + "Food & Drink" => Icons.emoji_food_beverage, + "Travel & Places" => Icons.travel_explore, + "Activities" => Icons.sports_soccer, + "Objects" => Icons.emoji_objects, + "Symbols" => Icons.emoji_symbols, + "Flags" => Icons.emoji_flags, + _ => Icons.category, + }, + emojis: entry.value.toList(growable: false), + ), + ), + ), + ); + + final customKeywords = IMap( + .fromEntries( + keywordMap.entries.map( + (e) => .new(e.key, e.value.toList(growable: false)), + ), + ), + ); + + return (customCategories, customKeywords); + } + + static final provider = AsyncNotifierProvider( + EmojiController.new, + ); +} diff --git a/lib/controllers/event_controller.dart b/lib/controllers/event_controller.dart index 4f72963..94992ca 100644 --- a/lib/controllers/event_controller.dart +++ b/lib/controllers/event_controller.dart @@ -1,5 +1,7 @@ +import "package:collection/collection.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_event_request.dart"; @@ -9,8 +11,18 @@ class EventController extends AsyncNotifier { @override Future build() async { - final client = ref.watch(ClientController.provider.notifier); - return await client.getEvent(request).onError((_, _) => null); + final room = ref.watch( + RoomsController.provider.select((value) => value[request.roomId]), + ); + final event = room?.events.values.firstWhereOrNull( + (event) => event.eventId == request.eventId, + ); + + return event ?? + await ref + .watch(ClientController.provider.notifier) + .getEvent(request) + .onError((_, _) => null); } static final provider = AsyncNotifierProvider.family diff --git a/lib/controllers/key_controller.dart b/lib/controllers/key_controller.dart index 946892e..59d49ca 100644 --- a/lib/controllers/key_controller.dart +++ b/lib/controllers/key_controller.dart @@ -12,14 +12,14 @@ class KeyController extends Notifier { String? build() => ref.watch(SharedPrefsController.provider).requireValue.getString(key); - Future set(String? id) async { + Future set(String? value) async { final prefs = ref.watch(SharedPrefsController.provider).requireValue; - state = id; + state = value; - if (id == null) { + if (value == null) { prefs.remove(key); } else { - prefs.setString(key, id); + prefs.setString(key, value); } } diff --git a/lib/controllers/members_by_status_controller.dart b/lib/controllers/members_by_status_controller.dart new file mode 100644 index 0000000..2b49903 --- /dev/null +++ b/lib/controllers/members_by_status_controller.dart @@ -0,0 +1,32 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/members_controller.dart"; +import "package:nexus/models/configs/members_by_status_config.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/event.dart"; + +class MembersByStatusController extends AsyncNotifier> { + final MembersByStatusConfig config; + MembersByStatusController(this.config); + + @override + Future> build() => ref.watch( + MembersController.provider(config.roomId).selectAsync( + (members) => members + .where( + (membership) => switch (membership.content) { + MembershipContent(:final status) => config.status == status, + _ => false, + }, + ) + .toISet(), + ), + ); + + static final provider = + AsyncNotifierProvider.family< + MembersByStatusController, + ISet, + MembersByStatusConfig + >(MembersByStatusController.new); +} diff --git a/lib/controllers/members_by_type_controller.dart b/lib/controllers/members_by_type_controller.dart deleted file mode 100644 index cdc8d07..0000000 --- a/lib/controllers/members_by_type_controller.dart +++ /dev/null @@ -1,25 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/members_controller.dart"; -import "package:nexus/models/membership.dart"; -import "package:nexus/models/membership_status.dart"; - -class MembersByTypeController extends AsyncNotifier> { - final MembershipStatus status; - MembersByTypeController(this.status); - - @override - Future> build() => ref.watch( - MembersController.provider.selectAsync( - (members) => - members.where((membership) => membership.status == status).toIList(), - ), - ); - - static final provider = - AsyncNotifierProvider.family< - MembersByTypeController, - IList, - MembershipStatus - >(MembersByTypeController.new); -} diff --git a/lib/controllers/members_controller.dart b/lib/controllers/members_controller.dart index 39666d4..57fc5be 100644 --- a/lib/controllers/members_controller.dart +++ b/lib/controllers/members_controller.dart @@ -1,52 +1,46 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/models/membership.dart"; +import "package:nexus/controllers/rooms_controller.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/models/requests/get_room_state_request.dart"; -class MembersController extends AsyncNotifier> { +class MembersController extends AsyncNotifier> { + final String roomId; + MembersController(this.roomId); + @override - Future> build() async { - final data = ref.watch( - SelectedRoomController.provider.select( - (value) => value?.metadata == null - ? null - : (value!.metadata!.id, value.metadata!.hasMemberList), - ), + Future> build() async { + final room = ref.watch( + RoomsController.provider.select((value) => value[roomId]), ); - if (data == null) return const IList.empty(); - final state = await ref - .watch(ClientController.provider.notifier) - .getRoomState( - GetRoomStateRequest( - roomId: data.$1, - fetchMembers: data.$2 == false, - includeMembers: true, - ), - ); + if (room == null) return .new(); - return state.nonNulls - .where((state) => state.type == "m.room.member") - .map( - (membership) => Membership.fromContent( - membership.content, - membership.stateKey!, - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - "", - ), - ) - .toIList(); + if (!room.hasFetchedMembers) { + final fetchedState = await ref + .watch(ClientController.provider.notifier) + .getRoomState( + GetRoomStateRequest( + roomId: roomId, + fetchMembers: !(room.metadata?.hasMemberList ?? false), + includeMembers: true, + ), + ); + + await ref + .read(RoomsController.provider.notifier) + .addState(roomId, fetchedState, isMembers: true); + } + + return room.state[EventType.membership.type]?.values + .map((rowId) => room.events[rowId]) + .nonNulls + .toISet() ?? + .new(); } - static final provider = - AsyncNotifierProvider>( - MembersController.new, - ); + static final provider = AsyncNotifierProvider.autoDispose + .family, String>(MembersController.new); } diff --git a/lib/controllers/members_grouped_controller.dart b/lib/controllers/members_grouped_controller.dart new file mode 100644 index 0000000..e07bcf3 --- /dev/null +++ b/lib/controllers/members_grouped_controller.dart @@ -0,0 +1,70 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/members_by_status_controller.dart"; +import "package:nexus/controllers/rooms_controller.dart"; +import "package:nexus/models/configs/members_by_status_config.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/event.dart"; + +class MembersGroupedController + extends AsyncNotifier>>> { + final MembersByStatusConfig config; + MembersGroupedController(this.config); + + @override + Future>>> build() async { + final room = ref.watch( + RoomsController.provider.select((value) => value[config.roomId]), + ); + + final createRowId = room?.state[EventType.create.type]?[""]; + final createEvent = createRowId == null ? null : room?.events[createRowId]; + final createEventContent = switch (createEvent?.content) { + CreateContent content => content, + _ => null, + }; + final creators = createEventContent?.additionalCreatorIds.add( + createEvent!.sender, + ); + + final powerLevelsRowId = room?.state[EventType.powerLevels.type]?[""]; + final powerLevelsEvent = powerLevelsRowId == null + ? null + : room?.events[powerLevelsRowId]; + + final content = switch (powerLevelsEvent?.content) { + PowerLevelsContent content => content, + _ => PowerLevelsContent(), + }; + + final members = await ref.watch( + MembersByStatusController.provider(config).future, + ); + + return members + .fold>>(.new(), (result, event) { + final groupKey = creators?.contains(event.stateKey!) == true + ? null + : content.users[event.stateKey!] ?? content.usersDefault; + + return result.update( + groupKey, + (value) => value.add(event), + ifAbsent: () => .new({event}), + ); + }) + .toEntryIList( + compare: (a, b) => + (b?.key ?? double.infinity).compareTo(a?.key ?? double.infinity), + ); + } + + static final provider = + AsyncNotifierProvider.family< + MembersGroupedController, + IList>>, + MembersByStatusConfig + >(MembersGroupedController.new); +} diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart deleted file mode 100644 index c65d18d..0000000 --- a/lib/controllers/message_controller.dart +++ /dev/null @@ -1,214 +0,0 @@ -import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/models/configs/message_config.dart"; -import "package:nexus/models/requests/get_related_events_request.dart"; - -class MessageController extends AsyncNotifier { - final MessageConfig config; - MessageController(this.config); - - @override - Future build() async { - try { - final isEdit = config.event.relationType == "m.replace"; - if ((isEdit && !config.includeEdits) || config.room.metadata == null) { - return null; - } - - final event = config.event.lastEditRowId == null - ? config.event - : config.room.events.firstWhereOrNull( - (e) => e.rowId == config.event.lastEditRowId, - ) ?? - config.event; - - final decrypted = (event.decrypted ?? event.content); - final type = (config.event.decryptedType ?? config.event.type); - final content = decrypted["m.new_content"] == null - ? decrypted - : IMap(decrypted["m.new_content"]); - - final homeserver = ref - .read(ClientStateController.provider) - ?.homeserverUrl; - final source = homeserver == null || content["url"] == null - ? "null" - : Uri.parse(content["url"]).mxcToHttps(homeserver).toString(); - - final metadata = { - "body": config.event.redactedBy == null - ? (content["body"] ?? "") - : "Deleted Message", - "flashing": false, - "timelineId": event.timelineRowId, - "big": event.localContent?.bigEmoji == true, - "eventType": type, - "pmp": content["com.beeper.per_message_profile"], - "error": event.sendError, - "format": content["format"] ?? content["format"], - "editSource": event.localContent?.editSource ?? content["body"], - "txnId": config.event.transactionId, - }; - - final editedAt = event.relationType == "m.replace" - ? event.timestamp - : null; - - if ((event.redactedBy != null && !config.alwaysReturn) || - (!config.includeEdits && - (config.event.relationType == "m.replace"))) { - return null; - } - - final replyId = - config.event.content["m.relates_to"]?["m.in_reply_to"]?["event_id"]; - - final reactionEvents = config.event.reactions.isEmpty && !isEdit - ? null - : await ref - .watch(ClientController.provider.notifier) - .getRelatedEvents( - GetRelatedEventsRequest( - roomId: config.room.metadata!.id, - eventId: - (isEdit ? config.event.relatesTo : null) ?? - config.event.eventId, - relationType: "m.annotation", - ), - ); - - final reactions = reactionEvents - ?.where((event) => event.redactedBy == null) - .fold>>(IMap(), (acc, event) { - final key = event.content["m.relates_to"]?["key"]; - if (key == null) return acc; - - return acc.update( - key, - (list) => list.add(event.authorId), - ifAbsent: () => IList([event.authorId]), - ); - }) - .map((key, value) => MapEntry(key, value.unlock)) - .unlock; - - final asText = - Message.text( - metadata: metadata, - id: config.event.eventId, - reactions: reactions, - authorId: event.authorId, - text: content["formatted_body"] ?? content["body"] ?? "", - replyToMessageId: replyId, - deliveredAt: config.event.timestamp, - editedAt: editedAt, - ) - as TextMessage; - - Message toSystemMessage(String content) => Message.system( - metadata: {...metadata, "body": content}, - id: config.event.eventId, - reactions: reactions, - authorId: event.authorId, - deliveredAt: config.event.timestamp, - text: content, - ); - - return switch (type) { - "m.room.encrypted" => asText.copyWith( - text: "Unable to decrypt message.", - metadata: {...metadata, "body": "Unable to decrypt message."}, - ), - // "org.matrix.msc3381.poll.start" => Message.custom( - // metadata: { - // ...metadata, - // "poll": event.parsedPollEventContent.pollStartContent, - // "responses": event.getPollResponses(timeline), - // }, - // id: eventId, - // deliveredAt: originServerTs, - // authorId: senderId, - // ), - ("m.sticker" || "m.room.message") => switch (content["msgtype"]) { - null || "m.image" => Message.image( - id: config.event.eventId, - authorId: event.authorId, - reactions: reactions, - source: source, - replyToMessageId: replyId, - metadata: metadata, - text: asText.text, - deliveredAt: config.event.timestamp, - blurhash: (content["info"] as Map?)?["xyz.amorgan.blurhash"], - ), - "m.audio" || "m.file" => Message.file( - name: content["filename"].toString(), - size: content["info"]["size"], - metadata: metadata, - id: config.event.eventId, - reactions: reactions, - authorId: event.authorId, - source: source, - replyToMessageId: replyId, - deliveredAt: config.event.timestamp, - ), - _ => asText, - }, - "m.room.member" => - content["membership"] == event.unsigned["prev_content"]?["membership"] - ? null - : toSystemMessage( - "${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) { - "invite" => "was invited to", - "join" => "joined", - "leave" => event.authorId == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), - "ban" => "was banned from", - "knock" => "asked to join", - _ => "did something relating to", - }} the room. ${content["reason"] ?? ""}", - ), - - "m.room.server_acl" => toSystemMessage( - "${event.authorId} updated the server ban list.", - ), - - "m.room.redaction" => - config.alwaysReturn - ? asText.copyWith( - metadata: { - ...(asText.metadata ?? {}), - "body": "Deleted Message", - }, - ) - : null, - _ => - config.alwaysReturn - ? asText - : ( - // Turn this on for debugging purposes - false - // ignore: dead_code - ? Message.unsupported( - metadata: metadata, - reactions: reactions, - id: config.event.eventId, - authorId: event.authorId, - replyToMessageId: replyId, - ) - : null), - }; - } catch (error) { - return null; - } - } - - static final provider = AsyncNotifierProvider.family - .autoDispose( - MessageController.new, - ); -} diff --git a/lib/controllers/messages_controller.dart b/lib/controllers/messages_controller.dart deleted file mode 100644 index 28885fb..0000000 --- a/lib/controllers/messages_controller.dart +++ /dev/null @@ -1,27 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/message_controller.dart"; -import "package:nexus/models/configs/message_config.dart"; -import "package:nexus/models/configs/messages_config.dart"; - -class MessagesController extends AsyncNotifier> { - final MessagesConfig config; - MessagesController(this.config); - - @override - Future> build() async => (await Future.wait( - config.events.map( - (event) => ref.watch( - MessageController.provider( - MessageConfig(event: event, room: config.room), - ).future, - ), - ), - )).nonNulls.toIList(); - - static final provider = AsyncNotifierProvider.family - .autoDispose, MessagesConfig>( - MessagesController.new, - ); -} diff --git a/lib/controllers/multi_provider_controller.dart b/lib/controllers/multi_provider_controller.dart index e23ecaa..52dd8d9 100644 --- a/lib/controllers/multi_provider_controller.dart +++ b/lib/controllers/multi_provider_controller.dart @@ -7,9 +7,8 @@ class MultiProviderController extends AsyncNotifier { final IList providers; @override - FutureOr build() async => await Future.wait( - providers.map((provider) => ref.watch(provider.future)), - ); + Future build() => + .wait(providers.map((provider) => ref.watch(provider.future))); static final provider = AsyncNotifierProvider.family< diff --git a/lib/controllers/new_events_controller.dart b/lib/controllers/new_events_controller.dart deleted file mode 100644 index 215ebd3..0000000 --- a/lib/controllers/new_events_controller.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/models/event.dart"; - -class NewEventsController extends Notifier> { - final String roomId; - NewEventsController(this.roomId); - - @override - IList build() => const IList.empty(); - - void add(IList newEvents) => state = newEvents; - - static final provider = NotifierProvider.autoDispose - .family, String>( - NewEventsController.new, - ); -} diff --git a/lib/controllers/power_level_controller.dart b/lib/controllers/power_level_controller.dart index 41b5f19..917ee31 100644 --- a/lib/controllers/power_level_controller.dart +++ b/lib/controllers/power_level_controller.dart @@ -1,9 +1,9 @@ -import "package:collection/collection.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; +import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/models/configs/power_level_config.dart"; -import "package:nexus/models/requests/membership_action.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/power_levels.dart"; class PowerLevelController extends Notifier { final PowerLevelConfig config; @@ -11,56 +11,61 @@ class PowerLevelController extends Notifier { @override bool build() { - final room = ref.watch(SelectedRoomController.provider); - final event = room?.events.firstWhereOrNull( - (event) => event.rowId == room.state["m.room.power_levels"]?[""], + if (config case EventPowerLevelConfig(:final eventType)) { + assert( + eventType != .redaction, + "Checking power level for a redaction should use [PowerLevelConfig.redaction].", + ); + } + + final room = ref.watch( + RoomsController.provider.select((value) => value[config.roomId]), ); - final user = ref.watch(ClientStateController.provider)?.userId; - if (event == null || user == null) return false; - final users = (event.content["users"] as Map? ?? {}); - final events = (event.content["events"] as Map? ?? {}); + final eventRowId = room?.state[EventType.powerLevels.type]?[""]; - int powerLevelOf(String userId) => users.containsKey(userId) - ? (users[userId] as int) - : (event.content["users_default"] as int? ?? 0); + final event = eventRowId == null ? null : room?.events[eventRowId]; + final content = event?.content is PowerLevelsContent + ? event!.content + : PowerLevelsContent(); + + final user = ref.watch( + ClientStateController.provider.select((value) => value?.userId), + ); + if (user == null || content is! PowerLevelsContent) return false; + + int powerLevelOf(String userId) => + content.users[userId] ?? content.usersDefault; final userLevel = powerLevelOf(user); - final targetLevel = config.targetUser != null - ? powerLevelOf(config.targetUser!) - : null; - if (config.action != null) { - return switch (config.action!) { - MembershipAction.invite => - userLevel >= (event.content["invite"] as int? ?? 0), + return switch (config) { + EventPowerLevelConfig(:final eventType) => + userLevel >= (content.events[eventType.type] ?? content.eventsDefault), - MembershipAction.kick => - targetLevel != null && - userLevel >= (event.content["kick"] as int? ?? 50) && - userLevel > targetLevel, + MembershipActionPowerLevelConfig(:final action, :final targetUser) => + switch (action) { + .invite => userLevel >= content.invite, - MembershipAction.ban => - targetLevel != null && - userLevel >= (event.content["ban"] as int? ?? 50) && - userLevel > targetLevel, + .kick => + userLevel >= content.kick && userLevel > powerLevelOf(targetUser), - MembershipAction.unban => - userLevel >= (event.content["ban"] as int? ?? 50), - }; - } + .ban => + userLevel >= content.ban && userLevel > powerLevelOf(targetUser), - if (config.eventType == "m.room.redaction") { - return userLevel >= (event.content["redact"] as int? ?? 50); - } + .unban => userLevel >= content.ban, + }, - final requiredLevel = events.containsKey(config.eventType) - ? (events[config.eventType] as int) - : (config.isStateEvent - ? (event.content["state_default"] as int? ?? 50) - : (event.content["events_default"] as int? ?? 0)); + StatePowerLevelConfig(:final eventType) => + userLevel >= (content.events[eventType.type] ?? content.stateDefault), - return userLevel >= requiredLevel; + RedactionPowerLevelConfig(:final targetUser) => + userLevel >= + (targetUser == user + ? (content.events[EventType.redaction.type] ?? + content.eventsDefault) + : content.redact), + }; } static final provider = NotifierProvider.autoDispose diff --git a/lib/controllers/profile_controller.dart b/lib/controllers/profile_controller.dart index 120d4e4..9aa0e09 100644 --- a/lib/controllers/profile_controller.dart +++ b/lib/controllers/profile_controller.dart @@ -12,6 +12,6 @@ class ProfileController extends AsyncNotifier { return client.getProfile(userId); } - static final provider = AsyncNotifierProvider.autoDispose - .family(ProfileController.new); + static final provider = AsyncNotifierProvider.family + .autoDispose(ProfileController.new); } diff --git a/lib/controllers/reactions_controller.dart b/lib/controllers/reactions_controller.dart new file mode 100644 index 0000000..db9af9b --- /dev/null +++ b/lib/controllers/reactions_controller.dart @@ -0,0 +1,55 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/controllers/rooms_controller.dart"; +import "package:nexus/models/configs/reactions_config.dart"; +import "package:nexus/models/content/reaction.dart"; + +class ReactionsController extends AsyncNotifier>> { + final ReactionsConfig config; + ReactionsController(this.config); + + @override + Future>> build() async { + final eventInfo = ref.watch( + RoomsController.provider.select((value) { + final event = value[config.roomId]?.events[config.eventRowId]; + return event == null ? null : (event.eventId, event.reactions); + }), + ); + + final reactionEvents = eventInfo?.$2.isNotEmpty == true + ? await ref + .watch(ClientController.provider.notifier) + .getRelatedEvents( + .new( + roomId: config.roomId, + eventId: eventInfo!.$1, + relationType: "m.annotation", + ), + ) + : null; + + return reactionEvents + ?.where((event) => event.redactedBy == null) + .fold>>(.new(), (acc, event) { + if (event.content case ReactionContent(:final key?)) { + return acc.update( + key, + (list) => list.add(event.sender), + ifAbsent: () => .new([event.sender]), + ); + } + + return acc; + }) ?? + .new(); + } + + static final provider = + AsyncNotifierProvider.family< + ReactionsController, + IMap>, + ReactionsConfig + >(ReactionsController.new); +} diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index fa32bf8..07b3650 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -1,223 +1,88 @@ import "dart:async"; +import "dart:math"; import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:fluttertagger/fluttertagger.dart"; import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/message_controller.dart"; -import "package:nexus/controllers/messages_controller.dart"; -import "package:nexus/controllers/new_events_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/models/configs/messages_config.dart"; -import "package:nexus/models/configs/message_config.dart"; -import "package:nexus/models/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/content/content.dart"; +import "package:nexus/models/content/reaction.dart"; +import "package:nexus/models/event.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"; -class RoomChatController extends AsyncNotifier { +class RoomChatController extends AsyncNotifier?> { final String roomId; RoomChatController(this.roomId); @override - Future build() async { + Future?> build() async { final client = ref.watch(ClientController.provider.notifier); - var room = ref.read(RoomsController.provider)[roomId]; - if (room == null) return InMemoryChatController(); - final state = await client.getRoomState( - GetRoomStateRequest(roomId: roomId), + final room = ref.watch( + RoomsController.provider.select((rooms) => rooms[roomId]), ); - ref - .read(RoomsController.provider.notifier) - .update( - { - roomId: Room( - events: state, - state: state.fold( - const IMap.empty(), - (previousValue, stateEvent) => previousValue.add( - stateEvent.type, - (previousValue[stateEvent.type] ?? const IMap.empty()).addAll( - IMap({ - if (stateEvent.stateKey != null) - stateEvent.stateKey!: stateEvent.rowId, - }), - ), - ), - ), - ), - }.toIMap(), - const ISet.empty(), - ); + if (room == null) return null; - room = ref.read(RoomsController.provider)[roomId]; - if (room == null) return InMemoryChatController(); + if (!room.hasFetchedState) { + final state = await client.getRoomState(.new(roomId: roomId)); - final messages = await ref.watch( - MessagesController.provider( - MessagesConfig( - room: room, - events: room.timeline - .map( - (timelineRowTuple) => room!.events.firstWhereOrNull( - (event) => event.rowId == timelineRowTuple.eventRowId, - ), - ) - .nonNulls - .toIList(), - ), - ).future, - ); - final controller = InMemoryChatController(messages: messages.toList()); - - ref.onDispose( - ref.listen(NewEventsController.provider(roomId), (_, next) async { - for (final event in next) { - if (event.type == "m.reaction") { - final message = controller.messages.firstWhereOrNull( - (message) => - message.id == event.content["m.relates_to"]?["event_id"], - ); - final key = event.content["m.relates_to"]?["key"]; - if (message == null || key == null || !ref.mounted) return; - - return await controller.updateMessage( - message, - message.copyWith( - reactions: IMap(message.reactions) - .update( - key, - (reactors) => [...reactors, event.authorId], - ifAbsent: () => [event.authorId], - ) - .unlock, - ), - ); - } - - if (event.type == "m.room.redaction") { - final controller = await future; - final redactsId = event.content["redacts"]; - final originalMessage = controller.messages.firstWhereOrNull( - (message) => message.id == redactsId, - ); - if (!ref.mounted) return; - - if (originalMessage != null) { - return await controller.removeMessage(originalMessage); - } - - final redacts = ref - .read(SelectedRoomController.provider) - ?.events - .firstWhere((event) => event.eventId == redactsId); - - if (redacts?.type == "m.reaction") { - final message = controller.messages.firstWhereOrNull( - (message) => - message.id == redacts!.content["m.relates_to"]?["event_id"], - ); - final key = redacts!.content["m.relates_to"]?["key"]; - if (message == null || key == null || !ref.mounted) return; - - return await controller.updateMessage( - message, - message.copyWith( - reactions: IMap(message.reactions) - .update( - key, - (reactors) => - IList(reactors).remove(redacts.authorId).unlock, - ) - .where((_, value) => value.isNotEmpty) - .unlock, - ), - ); - } - } else { - final message = await ref.watch( - MessageController.provider( - MessageConfig(event: event, room: room!, includeEdits: true), - ).future, - ); - if (event.relationType == "m.replace") { - final controller = await future; - final oldMessage = controller.messages.firstWhereOrNull( - (element) => element.id == event.relatesTo, - ); - if (oldMessage == null || message == null || !ref.mounted) return; - - return await controller.updateMessage( - oldMessage, - message.copyWith( - id: oldMessage.id, - replyToMessageId: oldMessage.replyToMessageId, - metadata: { - ...(oldMessage.metadata ?? {}), - ...(message.metadata ?? {}) - .toIMap() - .where((key, value) => value != null) - .unlock, - }, - ), - ); - } - if (message != null && ref.mounted) { - await insertMessage(message); - } - } - } - }, weak: true).close, - ); - - ref.onDispose(controller.dispose); - - // While there are under 20 messages, try up to load more messages until theres no more or we have 20 messages. - for (var more = true; more == true && controller.messages.length < 20;) { - more = await loadOlder(controller); + await ref.read(RoomsController.provider.notifier).addState(roomId, state); } - return controller; + // While there are under 20 events, try to load more + // until there's no more or the conditions are met. + if (room.hasMore && room.timeline.length < 20) { + loadOlder(); + } + + return room.timeline + .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) + .map((element) => element.value) + .toIList() + .addAll(room.sticky) + .map((entry) { + final foundEvent = entry == null ? null : room.events[entry]; + + final editedEvent = + foundEvent == null || foundEvent.lastEditRowId == 0 + ? null + : room.events[foundEvent.lastEditRowId]; + + return editedEvent == null + ? foundEvent + : foundEvent?.copyWith(content: editedEvent.content); + }) + .nonNulls + .toIList(); } - Future insertMessage(Message message) async { - final controller = await future; - final oldMessage = message.metadata?["txnId"] == null - ? null - : controller.messages.firstWhereOrNull( - (element) => - element.metadata?["txnId"] == message.metadata?["txnId"], - ); - - return oldMessage == null - ? controller.insertMessage(message) - : controller.updateMessage(oldMessage, message); - } - - Future deleteMessage(Message message, {String? reason}) => ref + Future deleteMessage(Event event, {String? reason}) => ref .watch(ClientController.provider.notifier) .redactEvent( - RedactEventRequest(eventId: message.id, roomId: roomId, reason: reason), + RedactEventRequest( + eventId: event.eventId, + roomId: roomId, + reason: reason, + ), ); - Future loadOlder([InMemoryChatController? chatController]) async { + Future loadOlder() async { + final timelineKeys = ref + .read(RoomsController.provider.select((value) => value[roomId])) + ?.timeline + .keys; final response = await ref .watch(ClientController.provider.notifier) .paginate( - PaginateRequest( + .new( roomId: roomId, - maxTimelineId: ref - .read(RoomsController.provider)[roomId] - ?.timeline - .firstOrNull - ?.timelineRowId, + maxTimelineId: timelineKeys?.isNotEmpty == true + ? timelineKeys?.reduce(min) + : null, ), ); @@ -226,42 +91,22 @@ class RoomChatController extends AsyncNotifier { .update( IMap({ roomId: Room( - events: response.events.addAll(response.relatedEvents), + events: IMap.fromIterable( + response.events.addAll(response.relatedEvents), + keyMapper: (event) => event.rowId, + valueMapper: (event) => event, + ), hasMore: response.hasMore, - timeline: response.events - .map( - (event) => TimelineRowTuple( - timelineRowId: event.timelineRowId, - eventRowId: event.rowId, - ), - ) - .toIList(), + timeline: IMap.fromIterable( + response.events, + keyMapper: (event) => event.timelineRowId, + valueMapper: (event) => event.rowId, + ), ), }), - const ISet.empty(), - addToNewEvents: false, + .new(), ); - final room = ref.read(RoomsController.provider)[roomId]; - if (room != null) { - final messages = await ref.watch( - MessagesController.provider( - MessagesConfig(room: room, events: response.events.reversed), - ).future, - ); - - final controller = chatController ?? await future; - await controller.insertAllMessages( - messages - .where( - (newMessage) => !controller.messages.any( - (message) => message.id == newMessage.id, - ), - ) - .toList(), - index: 0, - ); - } return response.hasMore; } @@ -270,7 +115,7 @@ class RoomChatController extends AsyncNotifier { bool shouldMention = true, required IList tags, required RelationType relationType, - Message? relation, + Event? relation, }) async { var taggedMessage = text; @@ -285,7 +130,6 @@ class RoomChatController extends AsyncNotifier { } final client = ref.watch(ClientController.provider.notifier); - final room = ref.read(RoomsController.provider)[roomId]; final event = await client.sendMessage( SendMessageRequest( roomId: roomId, @@ -294,52 +138,40 @@ class RoomChatController extends AsyncNotifier { if (shouldMention == true && relation != null && relationType == RelationType.reply) - relation.authorId, + relation.sender, ].toIList(), room: taggedMessage.contains("@room"), ), text: taggedMessage, relation: relation == null ? null - : Relation(eventId: relation.id, relationType: relationType), - ), - ); - final message = room == null - ? null - : await ref.watch( - MessageController.provider( - MessageConfig(room: room, event: event), - ).future, - ); - - if (message != null) insertMessage(message); - } - - Future scrollToMessage(Message message) async { - final controller = await future; - Future setFlashing(bool flashing) => controller.updateMessage( - message, - message.copyWith( - metadata: {...(message.metadata ?? {}), "flashing": flashing}, + : .new(eventId: relation.eventId, relationType: relationType), ), ); - await setFlashing(true); - Timer(Duration(seconds: 1), () => setFlashing(false)); - - return await controller.scrollToMessage(message.id); + ref + .watch(RoomsController.provider.notifier) + .update( + .new({ + roomId: .new( + events: .new({event.rowId: event}), + sticky: .new({event.rowId}), + ), + }), + .new(), + ); } Future removeReaction( String reaction, - Message message, + Event event, String userId, ) async { final client = ref.watch(ClientController.provider.notifier); final allReactionEvents = await client.getRelatedEvents( - GetRelatedEventsRequest( + .new( roomId: roomId, - eventId: message.id, + eventId: event.eventId, relationType: "m.annotation", ), ); @@ -349,30 +181,30 @@ class RoomChatController extends AsyncNotifier { .toIList(); final reactionEvent = reactionEvents?.firstWhereOrNull( - (event) => - event.authorId == userId && - event.content["m.relates_to"]?["key"] == reaction, + (event) => switch (event.content) { + ReactionContent(:final key) => + key == reaction && event.sender == userId, + _ => false, + }, ); if (reactionEvent != null) { await ref .watch(ClientController.provider.notifier) - .redactEvent( - RedactEventRequest(eventId: reactionEvent.eventId, roomId: roomId), - ); + .redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId)); } } - Future sendReaction(String reaction, Message message) async { + Future sendReaction(String reaction, Event event) async { final client = ref.watch(ClientController.provider.notifier); await client.sendEvent( - SendEventRequest( + .new( roomId: roomId, - type: "m.reaction", + type: EventType.reaction.type, content: { "m.relates_to": { - "event_id": message.id, + "event_id": event.eventId, "rel_type": "m.annotation", "key": reaction, }, @@ -384,7 +216,7 @@ class RoomChatController extends AsyncNotifier { } static final provider = AsyncNotifierProvider.family - .autoDispose( + .autoDispose?, String>( RoomChatController.new, ); } diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart index 7013de0..d0c6eb9 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -1,98 +1,86 @@ -import "package:collection/collection.dart"; +import "dart:isolate"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/new_events_controller.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/models/read_receipt.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/models/room.dart"; class RoomsController extends Notifier> { @override - IMap build() => const IMap.empty(); + IMap build() => .new(); - void update( - IMap rooms, - ISet leftRooms, { - bool addToNewEvents = true, - }) { - final homeserver = - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - ""; + Future addState( + String roomId, + IList state, { + bool isMembers = false, + }) async => update( + .new({ + roomId: Room( + events: .fromEntries(state.map((event) => .new(event.rowId, event))), + hasFetchedState: true, + hasFetchedMembers: isMembers, + state: await Isolate.run(() { + final newState = state.fold>>( + .new(), + (previousValue, stateEvent) => previousValue.add( + stateEvent.type, + (previousValue[stateEvent.type] ?? .new()).add( + stateEvent.stateKey!, + stateEvent.rowId, + ), + ), + ); + return newState; + }), + ), + }), + .new(), + ); + + void update(IMap rooms, ISet leftRooms) { final merged = rooms.entries.fold(state, (acc, entry) { final roomId = entry.key; final incoming = entry.value; final existing = acc[roomId]; - final events = existing?.events.updateById( - incoming.events, - (item) => item.eventId, - ); - - if (addToNewEvents) { - ref - .watch(NewEventsController.provider(roomId).notifier) - .add( - incoming.timeline - .map( - (timelineTuple) => events?.firstWhereOrNull( - (event) => timelineTuple.eventRowId == event.rowId, - ), - ) - .nonNulls - .toIList(), - ); - } - return acc.add( roomId, existing?.copyWith( hasMore: incoming.hasMore, - metadata: - incoming.metadata?.copyWith( - avatar: - incoming.metadata?.avatar?.mxcToHttps(homeserver) ?? - existing.metadata?.avatar, - ) ?? - existing.metadata, - events: events!, + sticky: + (incoming.sticky.isEmpty == true + ? existing.sticky + : existing.sticky.addAll(incoming.sticky)) + .removeWhere( + (rowId) => incoming.timeline.values.contains(rowId), + ), + metadata: incoming.metadata ?? existing.metadata, + events: incoming.events.isEmpty + ? existing.events + : existing.events.addAll(incoming.events), state: incoming.state.entries.fold( existing.state, (previousValue, event) => previousValue.add( event.key, - (previousValue[event.key] ?? const IMap.empty()).addAll( - event.value, - ), + (previousValue[event.key] ?? .new()).addAll(event.value), ), ), - timeline: - (incoming.reset - ? incoming.timeline - : existing.timeline.updateById( - incoming.timeline, - (item) => item.timelineRowId, - )) - .sortedBy((element) => element.timelineRowId) - .toIList(), + reset: false, + hasFetchedMembers: + incoming.hasFetchedMembers || existing.hasFetchedMembers, + hasFetchedState: + incoming.hasFetchedState || existing.hasFetchedState, + timeline: (incoming.reset + ? incoming.timeline + : existing.timeline.addAll(incoming.timeline)), receipts: incoming.receipts.entries.fold( existing.receipts, (receiptAcc, event) => receiptAcc.add( event.key, - (receiptAcc[event.key] ?? IList()).addAll( - event.value, - ), + (receiptAcc[event.key] ?? .new()).addAll(event.value), ), ), ) ?? - incoming.copyWith( - metadata: incoming.metadata?.copyWith( - avatar: incoming.metadata?.avatar?.mxcToHttps(homeserver), - ), - ), + incoming, ); }); @@ -100,6 +88,7 @@ class RoomsController extends Notifier> { merged, (acc, roomId) => acc.remove(roomId), ); + state = prunedList; } diff --git a/lib/controllers/selected_room_controller.dart b/lib/controllers/selected_room_controller.dart deleted file mode 100644 index ffba78c..0000000 --- a/lib/controllers/selected_room_controller.dart +++ /dev/null @@ -1,24 +0,0 @@ -import "package:collection/collection.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/selected_space_controller.dart"; -import "package:nexus/models/room.dart"; - -class SelectedRoomController extends Notifier { - @override - Room? build() { - final space = ref.watch(SelectedSpaceController.provider); - final selectedRoomId = ref.watch( - KeyController.provider(KeyController.roomKey), - ); - - return space.children.firstWhereOrNull( - (room) => room.metadata?.id == selectedRoomId, - ) ?? - space.children.firstOrNull; - } - - static final provider = NotifierProvider( - SelectedRoomController.new, - ); -} diff --git a/lib/controllers/selected_space_controller.dart b/lib/controllers/selected_space_controller.dart deleted file mode 100644 index dbeb71f..0000000 --- a/lib/controllers/selected_space_controller.dart +++ /dev/null @@ -1,22 +0,0 @@ -import "package:collection/collection.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/spaces_controller.dart"; -import "package:nexus/models/space.dart"; - -class SelectedSpaceController extends Notifier { - @override - Space build() { - final spaces = ref.watch(SpacesController.provider); - final selectedSpaceId = ref.watch( - KeyController.provider(KeyController.spaceKey), - ); - - return spaces.firstWhereOrNull((space) => space.id == selectedSpaceId) ?? - spaces.first; - } - - static final provider = NotifierProvider( - SelectedSpaceController.new, - ); -} diff --git a/lib/controllers/shared_prefs_controller.dart b/lib/controllers/shared_prefs_controller.dart index f4dcdae..876fc47 100644 --- a/lib/controllers/shared_prefs_controller.dart +++ b/lib/controllers/shared_prefs_controller.dart @@ -3,7 +3,7 @@ import "package:shared_preferences/shared_preferences.dart"; class SharedPrefsController extends AsyncNotifier { @override - Future build() => SharedPreferences.getInstance(); + Future build() async => .getInstance(); static final provider = AsyncNotifierProvider( diff --git a/lib/controllers/space_edges_controller.dart b/lib/controllers/space_edges_controller.dart index 12694d6..81347c5 100644 --- a/lib/controllers/space_edges_controller.dart +++ b/lib/controllers/space_edges_controller.dart @@ -4,7 +4,7 @@ import "package:nexus/models/space_edge.dart"; class SpaceEdgesController extends Notifier>> { @override - IMap> build() => const IMap.empty(); + IMap> build() => .new(); void set(IMap> newEdges) => state = state.addAll(newEdges); diff --git a/lib/controllers/spaces_controller.dart b/lib/controllers/spaces_controller.dart index 7a503ad..03a6b8a 100644 --- a/lib/controllers/spaces_controller.dart +++ b/lib/controllers/spaces_controller.dart @@ -6,9 +6,9 @@ import "package:nexus/controllers/account_data_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/top_level_spaces_controller.dart"; import "package:nexus/controllers/space_edges_controller.dart"; -import "package:nexus/models/space.dart"; import "package:nexus/models/room.dart"; -import "package:nexus/models/space_edge.dart"; +import "package:nexus/models/space.dart"; +import "package:nexus/models/subspace.dart"; class SpacesController extends Notifier> { @override @@ -16,118 +16,130 @@ class SpacesController extends Notifier> { final rooms = ref.watch(RoomsController.provider); final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider); final spaceEdges = ref.watch(SpaceEdgesController.provider); + final accountData = ref.watch(AccountDataController.provider); - final childRoomsBySpaceId = IMap.fromEntries( - topLevelSpaceIds.map((spaceId) { - ISet walk(String currentId) { - final children = spaceEdges[currentId] ?? IList(); + final childrenById = { + for (final entry in spaceEdges.entries) + entry.key: entry.value.map((e) => e.childId).toList(), + }; - return children.fold>(const ISet.empty(), (acc, edge) { - final childId = edge.childId; - final isSpace = spaceEdges.containsKey(childId); + Set collectDescendants(String startId) { + final visited = {}; + final stack = [startId]; - return acc - .addAll(!isSpace ? ISet([childId]) : const ISet.empty()) - .addAll(isSpace ? walk(childId) : const ISet.empty()); - }); + while (stack.isNotEmpty) { + final current = stack.removeLast(); + final children = childrenById[current] ?? const []; + + for (final child in children) { + if (visited.add(child)) { + stack.add(child); + } } + } - return MapEntry( - spaceId, - walk(spaceId).map((id) => rooms[id]).nonNulls.toIList(), - ); - }), - ); + return visited; + } - final allNestedRoomIds = childRoomsBySpaceId.values - .expand((l) => l) - .map( - (room) => rooms.entries - .firstWhere( - (entry) => entry.value.metadata?.id == room.metadata?.id, - ) - .key, - ) - .toISet(); + Space buildSpace(String spaceId) { + final space = rooms[spaceId]; + final directChildrenIds = childrenById[spaceId] ?? const []; + + final directRooms = []; + final subSpaces = []; + + for (final childId in directChildrenIds) { + final room = rooms[childId]; + if (room == null) continue; + + if (childrenById.containsKey(childId)) { + final descendants = collectDescendants(childId); + + subSpaces.add( + .new( + room: room, + children: .new(descendants.map((id) => rooms[id]).nonNulls), + ), + ); + } else { + directRooms.add(room); + } + } + + return .new( + id: spaceId, + room: space, + title: space?.metadata?.name ?? "Unnamed Space", + children: .new(directRooms), + subSpaces: .new(subSpaces), + ); + } + + final spaces = topLevelSpaceIds.map(buildSpace).toIList(); + + final usedRoomIds = { + for (final space in spaces) ...[ + ...space.children.map((r) => r.metadata?.id), + ...space.subSpaces.expand((s) => s.children.map((r) => r.metadata?.id)), + ], + }.nonNulls.toISet(); + + final directMessages = IMap( + accountData["m.direct"]?.content ?? {}, + ).values.expand((e) => e).toISet(); final otherRooms = rooms.entries .where( (e) => - !allNestedRoomIds.contains(e.key) && + !usedRoomIds.contains(e.key) && !topLevelSpaceIds.contains(e.key) && - !spaceEdges.containsKey(e.key), + !childrenById.containsKey(e.key), ) - .map((e) => e.value); - - final accountData = ref.watch(AccountDataController.provider); - - final directMessages = IMap( - accountData["m.direct"]?.content ?? {}, - ).values.expand((element) => element); + .map((e) => e.value) + .toIList(); final homeRooms = otherRooms - .where( - (room) => - directMessages.any( - (directMessage) => directMessage == room.metadata?.id, - ) == - false, - ) + .where((r) => !directMessages.contains(r.metadata?.id)) .toIList(); final dmRooms = otherRooms - .where( - (room) => directMessages.any( - (directMessage) => directMessage == room.metadata?.id, - ), - ) + .where((r) => directMessages.contains(r.metadata?.id)) .toIList(); - final topLevelSpacesList = topLevelSpaceIds - .map((id) { - final room = rooms[id]; - if (room == null) return null; + final allSpaces = [ + .new( + id: "home", + title: "Home", + icon: Icons.home, + children: homeRooms, + subSpaces: .new(), + ), + .new( + id: "dms", + title: "Direct Messages", + icon: Icons.people, + children: dmRooms, + subSpaces: .new(), + ), + ...spaces, + ]; - final children = childRoomsBySpaceId[id] ?? IList(); - return Space( - id: id, - title: room.metadata?.name ?? "Unnamed Room", - room: room, - children: children, - ); - }) - .nonNulls - .toIList(); - - return [ - Space( - id: "home", - title: "Home", - icon: Icons.home, - children: homeRooms, - ), - Space( - id: "dms", - title: "Direct Messages", - icon: Icons.people, - children: dmRooms, - ), - ...topLevelSpacesList, - ] + return allSpaces .map( (space) => space.copyWith( - children: space.children - .sortedBy( - (element) => - element - .metadata - ?.sortingTimestamp - .millisecondsSinceEpoch ?? - 0, - ) - .sortedBy((room) => room.metadata?.unreadMessages ?? 0) - .reversed - .toIList(), + children: .new( + space.children + .sortedBy( + (element) => + element + .metadata + ?.sortingTimestamp + .millisecondsSinceEpoch ?? + 0, + ) + .sortedBy((room) => room.metadata?.unreadMessages ?? 0) + .reversed, + ), ), ) .toIList(); diff --git a/lib/controllers/sync_status_controller.dart b/lib/controllers/sync_status_controller.dart index 8475d9d..256c8e2 100644 --- a/lib/controllers/sync_status_controller.dart +++ b/lib/controllers/sync_status_controller.dart @@ -7,7 +7,7 @@ class SyncStatusController extends Notifier { Null build() => null; void set(SyncStatus newStatus) { - if (newStatus.type == SyncStatusType.permanentlyFailed) { + if (newStatus.type == .permanentlyFailed) { showError(newStatus.error ?? "Syncing failed"); } state = newStatus; diff --git a/lib/controllers/top_level_spaces_controller.dart b/lib/controllers/top_level_spaces_controller.dart index e1f9c88..321e29d 100644 --- a/lib/controllers/top_level_spaces_controller.dart +++ b/lib/controllers/top_level_spaces_controller.dart @@ -3,7 +3,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; class TopLevelSpacesController extends Notifier> { @override - IList build() => const IList.empty(); + IList build() => .new(); void set(IList newSpaces) => state = newSpaces; diff --git a/lib/controllers/url_preview_controller.dart b/lib/controllers/url_preview_controller.dart index c2161d5..bb5626a 100644 --- a/lib/controllers/url_preview_controller.dart +++ b/lib/controllers/url_preview_controller.dart @@ -1,23 +1,25 @@ import "dart:convert"; -import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:http/http.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/header_controller.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; +import "package:nexus/models/open_graph_data.dart"; -class UrlPreviewController extends AsyncNotifier { +class UrlPreviewController extends AsyncNotifier { final String link; UrlPreviewController(this.link); @override - Future build() async { - final homeserver = ref.watch(ClientStateController.provider)?.homeserverUrl; + Future build() async { + final homeserver = ref.watch( + ClientStateController.provider.select((value) => value?.homeserverUrl), + ); if (homeserver != null && !link.contains("matrix.to")) { { final response = await get( - Uri.parse(homeserver) + .parse(homeserver) .resolve("/_matrix/client/v1/media/preview_url") .replace(queryParameters: {"url": link}), headers: await ref.watch(HeaderController.provider.future), @@ -25,27 +27,14 @@ class UrlPreviewController extends AsyncNotifier { if (response.statusCode == 200) { final decodedValue = json.decode(response.body); + if (decodedValue is! Map) return null; + final mxc = decodedValue["og:image"]; final image = mxc == null ? null : Uri.tryParse(mxc)?.mxcToHttps(homeserver); - return LinkPreviewData( - link: link, - title: decodedValue["og:title"], - description: decodedValue["og:description"], - image: image == null - ? null - : ImagePreviewData( - url: image.toString(), - width: - (decodedValue["og:image:width"] as int?)?.toDouble() ?? - 0, - height: - (decodedValue["og:image:height"] as int?)?.toDouble() ?? - 0, - ), - ); + return .fromJson(decodedValue).copyWith(imageUrl: image); } } } @@ -53,8 +42,10 @@ class UrlPreviewController extends AsyncNotifier { return null; } - static final provider = AsyncNotifierProvider.autoDispose - .family( - UrlPreviewController.new, - ); + static final provider = + AsyncNotifierProvider.family< + UrlPreviewController, + OpenGraphData?, + String + >(UrlPreviewController.new); } diff --git a/lib/controllers/user_controller.dart b/lib/controllers/user_controller.dart index e7ca973..7d20e90 100644 --- a/lib/controllers/user_controller.dart +++ b/lib/controllers/user_controller.dart @@ -4,37 +4,44 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/members_controller.dart"; import "package:nexus/controllers/profile_controller.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/models/membership.dart"; -import "package:nexus/models/membership_status.dart"; +import "package:nexus/models/configs/user_config.dart"; +import "package:nexus/models/content/membership.dart"; -class UserController extends AsyncNotifier { - final String userId; - UserController(this.userId); +class UserController extends AsyncNotifier { + final UserConfig config; + UserController(this.config); @override - Future build() async { - final member = await ref.watch( - MembersController.provider.selectAsync( - (value) => - value.firstWhereOrNull((membership) => membership.userId == userId), - ), + Future build() async { + final member = config.roomId == null + ? null + : await ref.watch( + MembersController.provider(config.roomId!).selectAsync( + (value) => value.firstWhereOrNull( + (membership) => membership.stateKey == config.userId, + ), + ), + ); + + if (member?.content case final MembershipContent content) { + return content; + } + + final profile = await ref.watch( + ProfileController.provider(config.userId).future, ); - if (member != null) return member; - - final profile = await ref.watch(ProfileController.provider(userId).future); - return Membership( - status: MembershipStatus.leave, - avatarUrl: profile.avatarUrl == null - ? null - : Uri.tryParse(profile.avatarUrl!), - displayName: profile.displayName ?? userId.localpart, - userId: userId, + return .new( + status: .leave, + avatarUrl: profile.avatarUrl, + displayName: profile.displayName ?? config.userId.localpart, ); } static final provider = - AsyncNotifierProvider.family( - UserController.new, - ); + AsyncNotifierProvider.family< + UserController, + MembershipContent, + UserConfig + >(UserController.new); } diff --git a/lib/controllers/via_controller.dart b/lib/controllers/via_controller.dart index b423947..0b5890a 100644 --- a/lib/controllers/via_controller.dart +++ b/lib/controllers/via_controller.dart @@ -2,6 +2,9 @@ import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state_controller.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/room.dart"; class ViaController extends Notifier { @@ -21,23 +24,29 @@ class ViaController extends Notifier { addUserId(ref.watch(ClientStateController.provider)?.userId); - final powerLevels = room.events.firstWhereOrNull( - (event) => event.rowId == room.state["m.room.power_levels"]?[""], - ); + final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""]; + final powerLevels = powerLevelsEventId == null + ? null + : room.events[powerLevelsEventId]; - for (final userId in IMap(powerLevels?.content["users"]).keys) { - addUserId(userId); - if (servers.length >= 5) break; + if (powerLevels?.content case PowerLevelsContent(:final users)) { + for (final userId in users.keys) { + addUserId(userId); + if (servers.length >= 5) break; + } } - final members = room.state["m.room.member"]?.values.toIList(); + final members = room.state[EventType.membership.type]?.values.toIList(); for (var i = 0; servers.length < 5; i++) { - final member = room.events.firstWhereOrNull( - (event) => event.rowId == members?.getOrNull(i), - ); + final membershipEventId = members?.getOrNull(i); + final member = membershipEventId == null + ? null + : room.events[membershipEventId]; - if (member?.content["membership"] == "join") { - addUserId(member?.stateKey); + if (member?.content case MembershipContent(:final status)) { + if (status == .join) { + addUserId(member?.stateKey); + } } if (members?.getOrNull(i) == null) break; diff --git a/lib/helpers/extensions/get_localpart.dart b/lib/helpers/extensions/get_localpart.dart index 445351f..fa3d285 100644 --- a/lib/helpers/extensions/get_localpart.dart +++ b/lib/helpers/extensions/get_localpart.dart @@ -1,3 +1,3 @@ extension GetLocalpart on String { - String get localpart => substring(1).split(":").first; + String get localpart => length > 1 ? substring(1).split(":").first : "?"; } diff --git a/lib/helpers/extensions/gomuks_buffer.dart b/lib/helpers/extensions/gomuks_buffer.dart index 88cfd5a..cc16b46 100644 --- a/lib/helpers/extensions/gomuks_buffer.dart +++ b/lib/helpers/extensions/gomuks_buffer.dart @@ -7,8 +7,8 @@ import "package:nexus/src/third_party/gomuks.g.dart"; extension GomuksOwnedBufferToX on GomuksOwnedBuffer { Uint8List toBytes() { try { - if (base == nullptr || length <= 0) return Uint8List(0); - return Uint8List.fromList(base.asTypedList(length)); + if (base == nullptr || length <= 0) return .new(0); + return .fromList(base.asTypedList(length)); } finally { calloc.free(base); } diff --git a/lib/helpers/extensions/mxc_to_https.dart b/lib/helpers/extensions/mxc_to_https.dart index 910f87d..b21f056 100644 --- a/lib/helpers/extensions/mxc_to_https.dart +++ b/lib/helpers/extensions/mxc_to_https.dart @@ -1,5 +1,4 @@ extension MxcToHttps on Uri { - Uri mxcToHttps(String homeserver) => Uri.parse( - homeserver, - ).resolve("_matrix/client/v1/media/download/$host$path"); + Uri mxcToHttps(String homeserver) => + .parse(homeserver).resolve("_matrix/client/v1/media/download/$host$path"); } diff --git a/lib/helpers/extensions/scheme_to_theme.dart b/lib/helpers/extensions/scheme_to_theme.dart index df68a05..b7d7972 100644 --- a/lib/helpers/extensions/scheme_to_theme.dart +++ b/lib/helpers/extensions/scheme_to_theme.dart @@ -1,17 +1,16 @@ import "package:flutter/material.dart"; extension SchemeToTheme on ColorScheme { - ThemeData get theme => ThemeData.from(colorScheme: this).copyWith( - cardTheme: CardThemeData(color: primaryContainer), + ThemeData get theme => .from(colorScheme: this).copyWith( + cardTheme: .new(color: primaryContainer), + popupMenuTheme: .new( + shape: RoundedRectangleBorder(borderRadius: .circular(16)), + color: surfaceContainerHigh, + ), appBarTheme: AppBarTheme( titleSpacing: 0, backgroundColor: surfaceContainerLow, ), - menuTheme: MenuThemeData( - style: MenuStyle( - backgroundColor: WidgetStatePropertyAll(primaryContainer), - ), - ), textTheme: ThemeData( fontFamilyFallback: ["sans", "emoji"], brightness: brightness, diff --git a/lib/helpers/extensions/show_context_menu.dart b/lib/helpers/extensions/show_context_menu.dart index 7d8cab6..c860115 100644 --- a/lib/helpers/extensions/show_context_menu.dart +++ b/lib/helpers/extensions/show_context_menu.dart @@ -9,14 +9,13 @@ extension ShowContextMenu on BuildContext { showMenu( context: this, - constraints: BoxConstraints.loose(Size.infinite), - position: RelativeRect.fromLTRB( + constraints: .loose(Size.infinite), + position: .fromLTRB( globalPosition.dx, globalPosition.dy, overlay.size.width - globalPosition.dx, overlay.size.height - globalPosition.dy, ), - color: Theme.of(this).colorScheme.surfaceContainerHighest, items: children, ); } diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart index 1698879..1ea3015 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -1,18 +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"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/widgets/user_bottom_sheet.dart"; extension ShowUserPopover on BuildContext { - void showUserPopover(Membership member, {required Offset globalPosition}) => - showContextMenu( - globalPosition: globalPosition, - children: [ - PopupMenuItem( - enabled: false, - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: IconTheme(data: IconThemeData(), child: UserPopover(member)), - ), - ], - ); + void showUserPopover( + MembershipContent member, + String userId, { + String? roomId, + }) => showModalBottomSheet( + constraints: BoxConstraints.loose( + Size(500, View.of(this).physicalSize.height - 80), + ), + isScrollControlled: true, + context: this, + builder: (context) => UserBottomSheet(member, userId, roomId: roomId), + ); } diff --git a/lib/helpers/extensions/size_to_string.dart b/lib/helpers/extensions/size_to_string.dart new file mode 100644 index 0000000..a9db345 --- /dev/null +++ b/lib/helpers/extensions/size_to_string.dart @@ -0,0 +1,15 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +extension SizeToString on int { + String get sizeAsString { + const suffixes = IListConst(["B", "KB", "MB", "GB", "TB", "PB"]); + + var i = 0; + var size = toDouble(); + while (size > 1024 && i < suffixes.length - 1) { + size /= 1024; + i++; + } + return "${size.toStringAsFixed(2)} ${suffixes[i]}"; + } +} diff --git a/lib/helpers/extensions/string_to_color.dart b/lib/helpers/extensions/string_to_color.dart new file mode 100644 index 0000000..eaa7714 --- /dev/null +++ b/lib/helpers/extensions/string_to_color.dart @@ -0,0 +1,6 @@ +import "package:color_hash/color_hash.dart"; +import "package:flutter/material.dart"; + +extension ToColor on String { + Color get colorHash => ColorHash(this, lightness: .5, saturation: .7).color; +} diff --git a/lib/helpers/launch_helper.dart b/lib/helpers/launch_helper.dart index f872ef7..575395f 100644 --- a/lib/helpers/launch_helper.dart +++ b/lib/helpers/launch_helper.dart @@ -10,9 +10,7 @@ class LaunchHelper { try { return await ul.launchUrl( url, - mode: useWebview - ? ul.LaunchMode.inAppBrowserView - : ul.LaunchMode.externalApplication, + mode: useWebview ? .inAppBrowserView : .externalApplication, ); } on PlatformException catch (_) { return false; diff --git a/lib/helpers/required_validator_helper.dart b/lib/helpers/required_validator_helper.dart new file mode 100644 index 0000000..d243684 --- /dev/null +++ b/lib/helpers/required_validator_helper.dart @@ -0,0 +1,2 @@ +String? requiredValidator(String? value) => + value == null || value.isEmpty ? "This field is required" : null; diff --git a/lib/main.dart b/lib/main.dart index 846f075..a8d8499 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,9 @@ import "dart:io"; +import "package:dynamic_color/dynamic_color.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/foundation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:media_kit/media_kit.dart"; import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/header_controller.dart"; @@ -10,13 +12,12 @@ import "package:nexus/controllers/shared_prefs_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/scheme_to_theme.dart"; import "package:nexus/pages/chat_page.dart"; -import "package:nexus/pages/login_page.dart"; +import "package:nexus/pages/select_server_page.dart"; import "package:nexus/pages/verify_page.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; import "package:window_manager/window_manager.dart"; import "package:flutter/material.dart"; -import "package:dynamic_system_colors/dynamic_system_colors.dart"; final GlobalKey navigatorKey = GlobalKey(); @@ -31,7 +32,7 @@ Time: ${DateTime.now().toIso8601String()} Provider: ${context.provider} Previous Value: ${previousValue is AsyncData ? previousValue.value : previousValue} New Value: ${newValue is AsyncData ? newValue.value : newValue} -}"""); +"""); } void showError(Object error, [StackTrace? stackTrace]) { @@ -56,6 +57,7 @@ void showError(Object error, [StackTrace? stackTrace]) { void main() async { WidgetsFlutterBinding.ensureInitialized(); + MediaKit.ensureInitialized(); if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { await windowManager.ensureInitialized(); @@ -122,7 +124,7 @@ class App extends StatelessWidget { } if (!clientState.isLoggedIn) { - return LoginPage(); + return SelectServerPage(); } else if (!clientState.isVerified) { return VerifyPage(); } else { diff --git a/lib/models/configs/members_by_status_config.dart b/lib/models/configs/members_by_status_config.dart new file mode 100644 index 0000000..8aef586 --- /dev/null +++ b/lib/models/configs/members_by_status_config.dart @@ -0,0 +1,15 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/membership_status.dart"; +part "members_by_status_config.freezed.dart"; +part "members_by_status_config.g.dart"; + +@freezed +abstract class MembersByStatusConfig with _$MembersByStatusConfig { + const factory MembersByStatusConfig({ + required String roomId, + required MembershipStatus status, + }) = _MembersByStatusConfig; + + factory MembersByStatusConfig.fromJson(Map json) => + _$MembersByStatusConfigFromJson(json); +} diff --git a/lib/models/configs/message_config.dart b/lib/models/configs/message_config.dart deleted file mode 100644 index 66a437c..0000000 --- a/lib/models/configs/message_config.dart +++ /dev/null @@ -1,28 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/room.dart"; -part "message_config.freezed.dart"; -part "message_config.g.dart"; - -@freezed -abstract class MessageConfig with _$MessageConfig { - const MessageConfig._(); - const factory MessageConfig({ - @Default(false) bool alwaysReturn, - @Default(false) bool includeEdits, - required Room room, - required Event event, - }) = _MessageConfig; - - @override - bool operator ==(Object other) => - other.runtimeType == runtimeType && - other is MessageConfig && - other.event == event; - - @override - int get hashCode => Object.hash(runtimeType, event); - - factory MessageConfig.fromJson(Map json) => - _$MessageConfigFromJson(json); -} diff --git a/lib/models/configs/messages_config.dart b/lib/models/configs/messages_config.dart deleted file mode 100644 index b33a71c..0000000 --- a/lib/models/configs/messages_config.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/room.dart"; -part "messages_config.freezed.dart"; -part "messages_config.g.dart"; - -@freezed -abstract class MessagesConfig with _$MessagesConfig { - const factory MessagesConfig({ - required Room room, - required IList events, - }) = _MessagesConfig; - - factory MessagesConfig.fromJson(Map json) => - _$MessagesConfigFromJson(json); -} diff --git a/lib/models/configs/power_level_config.dart b/lib/models/configs/power_level_config.dart index 31cc08c..197e171 100644 --- a/lib/models/configs/power_level_config.dart +++ b/lib/models/configs/power_level_config.dart @@ -1,17 +1,28 @@ import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; import "package:nexus/models/requests/membership_action.dart"; part "power_level_config.freezed.dart"; -part "power_level_config.g.dart"; @freezed -abstract class PowerLevelConfig with _$PowerLevelConfig { +sealed class PowerLevelConfig with _$PowerLevelConfig { const factory PowerLevelConfig({ - @Default(false) bool isStateEvent, - required String eventType, - MembershipAction? action, - String? targetUser, - }) = _PowerLevelConfig; + required EventType eventType, + required String roomId, + }) = EventPowerLevelConfig; - factory PowerLevelConfig.fromJson(Map json) => - _$PowerLevelConfigFromJson(json); + const factory PowerLevelConfig.membershipAction({ + required MembershipAction action, + required String targetUser, + required String roomId, + }) = MembershipActionPowerLevelConfig; + + const factory PowerLevelConfig.state({ + required EventType eventType, + required String roomId, + }) = StatePowerLevelConfig; + + const factory PowerLevelConfig.redaction({ + required String targetUser, + required String roomId, + }) = RedactionPowerLevelConfig; } diff --git a/lib/models/configs/reactions_config.dart b/lib/models/configs/reactions_config.dart new file mode 100644 index 0000000..5cae859 --- /dev/null +++ b/lib/models/configs/reactions_config.dart @@ -0,0 +1,14 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +part "reactions_config.freezed.dart"; +part "reactions_config.g.dart"; + +@freezed +abstract class ReactionsConfig with _$ReactionsConfig { + const factory ReactionsConfig({ + required String roomId, + required int eventRowId, + }) = _ReactionsConfig; + + factory ReactionsConfig.fromJson(Map json) => + _$ReactionsConfigFromJson(json); +} diff --git a/lib/models/configs/user_config.dart b/lib/models/configs/user_config.dart new file mode 100644 index 0000000..4f3f8ff --- /dev/null +++ b/lib/models/configs/user_config.dart @@ -0,0 +1,12 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +part "user_config.freezed.dart"; +part "user_config.g.dart"; + +@freezed +abstract class UserConfig with _$UserConfig { + const factory UserConfig({required String? roomId, required String userId}) = + _UserConfig; + + factory UserConfig.fromJson(Map json) => + _$UserConfigFromJson(json); +} diff --git a/lib/models/content/avatar.dart b/lib/models/content/avatar.dart new file mode 100644 index 0000000..66d4c47 --- /dev/null +++ b/lib/models/content/avatar.dart @@ -0,0 +1,14 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/info/image.dart"; +part "avatar.freezed.dart"; +part "avatar.g.dart"; + +@freezed +abstract class AvatarContent extends Content with _$AvatarContent { + AvatarContent._(); + factory AvatarContent({ImageInfo? info, Uri? url}) = _AvatarContent; + + factory AvatarContent.fromJson(Map json) => + _$AvatarContentFromJson(json); +} diff --git a/lib/models/content/canonical_alias.dart b/lib/models/content/canonical_alias.dart new file mode 100644 index 0000000..636be13 --- /dev/null +++ b/lib/models/content/canonical_alias.dart @@ -0,0 +1,18 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "canonical_alias.freezed.dart"; +part "canonical_alias.g.dart"; + +@freezed +abstract class CanonicalAliasContent extends Content + with _$CanonicalAliasContent { + CanonicalAliasContent._(); + factory CanonicalAliasContent({ + String? alias, + @Default(ISet.empty()) ISet altAliases, + }) = _CanonicalAliasContent; + + factory CanonicalAliasContent.fromJson(Map json) => + _$CanonicalAliasContentFromJson(json); +} diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart new file mode 100644 index 0000000..e7b1141 --- /dev/null +++ b/lib/models/content/content.dart @@ -0,0 +1,68 @@ +import "package:collection/collection.dart"; +import "package:nexus/models/content/avatar.dart"; +import "package:nexus/models/content/canonical_alias.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/encryption.dart"; +import "package:nexus/models/content/join_rules.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/name.dart"; +import "package:nexus/models/content/pinned_events.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/content/reaction.dart"; +import "package:nexus/models/content/encrypted.dart"; +import "package:nexus/models/content/redaction.dart"; +import "package:nexus/models/content/server_acl.dart"; +import "package:nexus/models/content/topic.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/content/history_visibility.dart"; + +class Content { + final Error? parseError; + Content({this.parseError}); + + factory Content.fromJson(Map json) => Content(); + Map toJson() => {}; + + static Map readValue(Map json, _) => + json["decrypted"] ?? json["content"]; + + static Content fromEventJson(Map json, String type) { + try { + return (EventType.values + .firstWhereOrNull((eventType) => eventType.type == type) + ?.contentFromJson ?? + Content.fromJson)(json); + } catch (error) { + if (error is Error) return .new(parseError: error); + rethrow; + } + } +} + +enum EventType { + encrypted("m.room.encrypted", EncryptedContent.fromJson), + redaction("m.room.redaction", RedactionContent.fromJson), + encryption("m.room.encryption", EncryptionContent.fromJson), + membership("m.room.member", MembershipContent.fromJson), + create("m.room.create", CreateContent.fromJson), + historyVisibility( + "m.room.history_visibility", + HistoryVisibilityContent.fromJson, + ), + canonicalAlias("m.room.canonical_alias", CanonicalAliasContent.fromJson), + sticker("m.sticker", StickerContent.fromJson), + joinRules("m.room.join_rules", JoinRulesContent.fromJson), + powerLevels("m.room.power_levels", PowerLevelsContent.fromJson), + serverACL("m.room.server_acl", ServerACLContent.fromJson), + avatar("m.room.avatar", AvatarContent.fromJson), + topic("m.room.topic", TopicContent.fromJson), + name("m.room.name", NameContent.fromJson), + reaction("m.reaction", ReactionContent.fromJson), + pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson), + message("m.room.message", MessageContent.fromJson); + + final String type; + final Content Function(Map json) contentFromJson; + const EventType(this.type, this.contentFromJson); +} diff --git a/lib/models/content/create.dart b/lib/models/content/create.dart new file mode 100644 index 0000000..c534558 --- /dev/null +++ b/lib/models/content/create.dart @@ -0,0 +1,39 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "create.freezed.dart"; +part "create.g.dart"; + +@freezed +abstract class CreateContent extends Content with _$CreateContent { + CreateContent._(); + factory CreateContent({ + @JsonKey(name: "additional_creators") + @Default(IList.empty()) + IList additionalCreatorIds, + + PreviousRoom? predecessor, + + @JsonKey(name: "m.federate") @Default(true) bool federated, + + @Default("1") String roomVersion, + @JsonKey(unknownEnumValue: RoomType.room) RoomType? type, + }) = _CreateContent; + + factory CreateContent.fromJson(Map json) => + _$CreateContentFromJson(json); +} + +enum RoomType { + room, + @JsonValue("m.space") + space, +} + +@freezed +abstract class PreviousRoom with _$PreviousRoom { + const factory PreviousRoom({required String roomId}) = _PreviousRoom; + + factory PreviousRoom.fromJson(Map json) => + _$PreviousRoomFromJson(json); +} diff --git a/lib/models/content/encrypted.dart b/lib/models/content/encrypted.dart new file mode 100644 index 0000000..b33a440 --- /dev/null +++ b/lib/models/content/encrypted.dart @@ -0,0 +1,13 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "encrypted.freezed.dart"; +part "encrypted.g.dart"; + +@freezed +abstract class EncryptedContent extends Content with _$EncryptedContent { + EncryptedContent._(); + factory EncryptedContent() = _EncryptedContent; + + factory EncryptedContent.fromJson(Map json) => + _$EncryptedContentFromJson(json); +} diff --git a/lib/models/content/encryption.dart b/lib/models/content/encryption.dart new file mode 100644 index 0000000..3380632 --- /dev/null +++ b/lib/models/content/encryption.dart @@ -0,0 +1,23 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "encryption.freezed.dart"; +part "encryption.g.dart"; + +@freezed +abstract class EncryptionContent extends Content with _$EncryptionContent { + EncryptionContent._(); + factory EncryptionContent({ + required String algorithm, + + @JsonKey(name: "rotation_period_ms") + @Default(604800000) + int rotationPeriodMS, + + @JsonKey(name: "rotation_period_msgs") + @Default(100) + int rotationPeriodMessages, + }) = _EncryptionContent; + + factory EncryptionContent.fromJson(Map json) => + _$EncryptionContentFromJson(json); +} diff --git a/lib/models/content/history_visibility.dart b/lib/models/content/history_visibility.dart new file mode 100644 index 0000000..707805c --- /dev/null +++ b/lib/models/content/history_visibility.dart @@ -0,0 +1,19 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "history_visibility.freezed.dart"; +part "history_visibility.g.dart"; + +@freezed +abstract class HistoryVisibilityContent extends Content + with _$HistoryVisibilityContent { + HistoryVisibilityContent._(); + factory HistoryVisibilityContent({ + required HistoryVisibility historyVisibility, + }) = _HistoryVisibilityContent; + + factory HistoryVisibilityContent.fromJson(Map json) => + _$HistoryVisibilityContentFromJson(json); +} + +@JsonEnum(fieldRename: FieldRename.snake) +enum HistoryVisibility { invited, joined, shared, worldReadable } diff --git a/lib/models/content/join_rules.dart b/lib/models/content/join_rules.dart new file mode 100644 index 0000000..1d14eee --- /dev/null +++ b/lib/models/content/join_rules.dart @@ -0,0 +1,34 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/join_rule.dart"; +part "join_rules.freezed.dart"; +part "join_rules.g.dart"; + +@freezed +abstract class JoinRulesContent extends Content with _$JoinRulesContent { + JoinRulesContent._(); + factory JoinRulesContent({ + required JoinRule joinRule, + @Default(IList.empty()) IList allow, + }) = _JoinRulesContent; + + factory JoinRulesContent.fromJson(Map json) => + _$JoinRulesContentFromJson(json); +} + +@freezed +abstract class AllowCondition with _$AllowCondition { + const factory AllowCondition({ + String? roomId, + required AllowConditionType type, + }) = _AllowCondition; + + factory AllowCondition.fromJson(Map json) => + _$AllowConditionFromJson(json); +} + +enum AllowConditionType { + @JsonValue("m.room_membership") + membership, +} diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart new file mode 100644 index 0000000..dbbd123 --- /dev/null +++ b/lib/models/content/membership.dart @@ -0,0 +1,27 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/membership_status.dart"; +part "membership.freezed.dart"; +part "membership.g.dart"; + +@freezed +abstract class MembershipContent extends Content with _$MembershipContent { + MembershipContent._(); + + static String? displaynameFromJson(String? displayName) => + displayName?.isEmpty == true ? null : displayName; + + factory MembershipContent({ + @JsonKey( + name: "displayname", + fromJson: MembershipContent.displaynameFromJson, + ) + required String? displayName, + @JsonKey(name: "membership") required MembershipStatus status, + Uri? avatarUrl, + String? reason, + }) = _MembershipContent; + + factory MembershipContent.fromJson(Map json) => + _$MembershipContentFromJson(json); +} diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart new file mode 100644 index 0000000..b5e308c --- /dev/null +++ b/lib/models/content/message.dart @@ -0,0 +1,92 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/info/audio.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/info/file.dart"; +import "package:nexus/models/info/image.dart"; +import "package:nexus/models/info/video.dart"; +part "message.freezed.dart"; +part "message.g.dart"; + +@Freezed(unionKey: "msgtype", fallbackUnion: "default") +abstract class MessageContent extends Content with _$MessageContent { + MessageContent._(); + factory MessageContent({required String body}) = UnknownMessageContent; + + @FreezedUnionValue("m.text") + factory MessageContent.text({ + required String body, + MessageFormat? format, + String? formattedBody, + }) = TextMessageContent; + + @FreezedUnionValue("m.notice") + factory MessageContent.notice({ + required String body, + MessageFormat? format, + String? formattedBody, + }) = NoticeMessageContent; + + @FreezedUnionValue("m.emote") + factory MessageContent.emote({ + required String body, + MessageFormat? format, + String? formattedBody, + }) = EmoteMessageContent; + + @FreezedUnionValue("m.image") + factory MessageContent.image({ + required String body, + MessageFormat? format, + String? formattedBody, + // EncryptedFile? file + String? filename, + ImageInfo? info, + Uri? url, + }) = ImageMessageContent; + + @FreezedUnionValue("m.file") + factory MessageContent.file({ + required String body, + MessageFormat? format, + String? formattedBody, + // EncryptedFile? file + String? filename, + FileInfo? info, + Uri? url, + }) = FileMessageContent; + + @FreezedUnionValue("m.audio") + factory MessageContent.audio({ + required String body, + MessageFormat? format, + String? formattedBody, + // EncryptedFile? file + String? filename, + AudioInfo? info, + Uri? url, + }) = AudioMessageContent; + + @FreezedUnionValue("m.video") + factory MessageContent.video({ + required String body, + MessageFormat? format, + String? formattedBody, + // EncryptedFile? file + String? filename, + VideoInfo? info, + Uri? url, + }) = VideoMessageContent; + + @FreezedUnionValue("m.location") + factory MessageContent.location({required String body, required Uri geoUri}) = + LocationMessageContent; + + factory MessageContent.fromJson(Map json) => + _$MessageContentFromJson(json); +} + +@JsonEnum() +enum MessageFormat { + @JsonValue("org.matrix.custom.html") + html, +} diff --git a/lib/models/content/name.dart b/lib/models/content/name.dart new file mode 100644 index 0000000..205f6bb --- /dev/null +++ b/lib/models/content/name.dart @@ -0,0 +1,13 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "name.freezed.dart"; +part "name.g.dart"; + +@freezed +abstract class NameContent extends Content with _$NameContent { + NameContent._(); + factory NameContent({required String name}) = _NameContent; + + factory NameContent.fromJson(Map json) => + _$NameContentFromJson(json); +} diff --git a/lib/models/content/pinned_events.dart b/lib/models/content/pinned_events.dart new file mode 100644 index 0000000..d17a0de --- /dev/null +++ b/lib/models/content/pinned_events.dart @@ -0,0 +1,15 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "pinned_events.freezed.dart"; +part "pinned_events.g.dart"; + +@freezed +abstract class PinnedEventsContent extends Content with _$PinnedEventsContent { + PinnedEventsContent._(); + factory PinnedEventsContent({@Default(IList.empty()) IList pinned}) = + _PinnedEventsContent; + + factory PinnedEventsContent.fromJson(Map json) => + _$PinnedEventsContentFromJson(json); +} diff --git a/lib/models/content/power_levels.dart b/lib/models/content/power_levels.dart new file mode 100644 index 0000000..3709c38 --- /dev/null +++ b/lib/models/content/power_levels.dart @@ -0,0 +1,36 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "power_levels.freezed.dart"; +part "power_levels.g.dart"; + +@freezed +abstract class PowerLevelsContent extends Content with _$PowerLevelsContent { + PowerLevelsContent._(); + factory PowerLevelsContent({ + @Default(IMap.empty()) IMap events, + @Default(IMap.empty()) IMap users, + Notifications? notifications, + @Default(50) int ban, + @Default(0) int eventsDefault, + @Default(0) int invite, + @Default(50) int kick, + @Default(50) int redact, + @Default(50) int stateDefault, + @Default(0) int usersDefault, + }) = _PowerLevelsContent; + + factory PowerLevelsContent.fromJson(Map json) => + _$PowerLevelsContentFromJson(json); +} + +@freezed +abstract class Notifications with _$Notifications { + const factory Notifications({ + @Default(50) int room, + @Default(IMapConst({})) IMap other, + }) = _Notifications; + + factory Notifications.fromJson(Map json) => + _$NotificationsFromJson(json); +} diff --git a/lib/models/content/reaction.dart b/lib/models/content/reaction.dart new file mode 100644 index 0000000..3115ae0 --- /dev/null +++ b/lib/models/content/reaction.dart @@ -0,0 +1,18 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "reaction.freezed.dart"; +part "reaction.g.dart"; + +@freezed +abstract class ReactionContent extends Content with _$ReactionContent { + ReactionContent._(); + static String? keyJsonFromJson(Map json, String key) => + json["m.relates_to"]?["key"]; + + factory ReactionContent({ + @JsonKey(readValue: ReactionContent.keyJsonFromJson) String? key, + }) = _ReactionContent; + + factory ReactionContent.fromJson(Map json) => + _$ReactionContentFromJson(json); +} diff --git a/lib/models/content/redaction.dart b/lib/models/content/redaction.dart new file mode 100644 index 0000000..e9c1a90 --- /dev/null +++ b/lib/models/content/redaction.dart @@ -0,0 +1,14 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "redaction.freezed.dart"; +part "redaction.g.dart"; + +@freezed +abstract class RedactionContent extends Content with _$RedactionContent { + RedactionContent._(); + factory RedactionContent({String? reason, String? redacts}) = + _RedactionContent; + + factory RedactionContent.fromJson(Map json) => + _$RedactionContentFromJson(json); +} diff --git a/lib/models/content/server_acl.dart b/lib/models/content/server_acl.dart new file mode 100644 index 0000000..1e50988 --- /dev/null +++ b/lib/models/content/server_acl.dart @@ -0,0 +1,18 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "server_acl.freezed.dart"; +part "server_acl.g.dart"; + +@freezed +abstract class ServerACLContent extends Content with _$ServerACLContent { + ServerACLContent._(); + factory ServerACLContent({ + @Default(IList.empty()) IList allow, + @Default(IList.empty()) IList deny, + @Default(true) allowIpLiterals, + }) = _ServerACLContent; + + factory ServerACLContent.fromJson(Map json) => + _$ServerACLContentFromJson(json); +} diff --git a/lib/models/content/sticker.dart b/lib/models/content/sticker.dart new file mode 100644 index 0000000..89d9332 --- /dev/null +++ b/lib/models/content/sticker.dart @@ -0,0 +1,18 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/info/image.dart"; +part "sticker.freezed.dart"; +part "sticker.g.dart"; + +@freezed +abstract class StickerContent extends Content with _$StickerContent { + StickerContent._(); + factory StickerContent({ + required String body, + required ImageInfo info, + required Uri url, + }) = _StickerContent; + + factory StickerContent.fromJson(Map json) => + _$StickerContentFromJson(json); +} diff --git a/lib/models/content/topic.dart b/lib/models/content/topic.dart new file mode 100644 index 0000000..8fa5229 --- /dev/null +++ b/lib/models/content/topic.dart @@ -0,0 +1,40 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "topic.freezed.dart"; +part "topic.g.dart"; + +@freezed +abstract class TopicContent extends Content with _$TopicContent { + TopicContent._(); + factory TopicContent({ + required String topic, + @JsonKey(name: "m.topic") TopicContentBlock? content, + }) = _TopicContent; + + factory TopicContent.fromJson(Map json) => + _$TopicContentFromJson(json); +} + +@freezed +abstract class TopicContentBlock with _$TopicContentBlock { + factory TopicContentBlock({ + @Default(IList.empty()) + @JsonKey(name: "m.text") + IList representations, + }) = _TopicContentBlock; + + factory TopicContentBlock.fromJson(Map json) => + _$TopicContentBlockFromJson(json); +} + +@freezed +abstract class TextualRepresentation with _$TextualRepresentation { + factory TextualRepresentation({ + required String body, + @Default("text/plain") String mimetype, + }) = _TextualRepresentation; + + factory TextualRepresentation.fromJson(Map json) => + _$TextualRepresentationFromJson(json); +} diff --git a/lib/models/emoji.dart b/lib/models/emoji.dart new file mode 100644 index 0000000..8e4eac6 --- /dev/null +++ b/lib/models/emoji.dart @@ -0,0 +1,17 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +part "emoji.freezed.dart"; +part "emoji.g.dart"; + +@freezed +abstract class Emoji with _$Emoji { + const factory Emoji({ + required String emoji, + required String category, + required IList aliases, + required String description, + required IList tags, + }) = _Emoji; + + factory Emoji.fromJson(Map json) => _$EmojiFromJson(json); +} diff --git a/lib/models/event.dart b/lib/models/event.dart index 734f667..c54dbc5 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -1,37 +1,69 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; import "package:nexus/models/epoch_date_time_converter.dart"; +import "package:nexus/models/profile.dart"; part "event.freezed.dart"; part "event.g.dart"; @freezed abstract class Event with _$Event { + static String typeJsonFromJson(Map json, _) => + json["decrypted_type"] ?? json["type"]; + + static Map getContentFromJson(Map json) { + final content = json["decrypted"] ?? json["content"]; + + return content["m.new_content"] ?? content; + } + const factory Event({ @JsonKey(name: "rowid") required int rowId, @JsonKey(name: "timeline_rowid") required int timelineRowId, required String roomId, required String eventId, - @JsonKey(name: "sender") required String authorId, - required String type, + required String sender, + @JsonKey(readValue: Event.typeJsonFromJson) required String type, String? stateKey, @EpochDateTimeConverter() required DateTime timestamp, - required IMap content, - IMap? decrypted, - String? decryptedType, @Default(IMap.empty()) IMap unsigned, LocalContent? localContent, String? transactionId, String? redactedBy, String? relatesTo, String? relationType, + String? replyTo, String? decryptionError, String? sendError, @Default(IMap.empty()) IMap reactions, - @JsonKey(name: "last_edit_rowid") int? lastEditRowId, + @JsonKey(name: "last_edit_rowid") @Default(0) int lastEditRowId, @UnreadTypeConverter() UnreadType? unreadType, + Profile? pmp, + required Content content, + required Content? previousContent, }) = _Event; - factory Event.fromJson(Map json) => _$EventFromJson(json); + factory Event.fromJson(Map json) => + _$EventFromJson(json).copyWith( + replyTo: getContentFromJson( + json, + )["m.relates_to"]?["m.in_reply_to"]?["event_id"], + pmp: json["content"]?["com.beeper.per_message_profile"] == null + ? null + : Profile.fromJsonWithCatch( + json["content"]?["com.beeper.per_message_profile"], + ), + content: Content.fromEventJson( + getContentFromJson(json), + json["decrypted_type"] ?? json["type"], + ), + previousContent: json["unsigned"]?["prev_content"] == null + ? null + : Content.fromEventJson( + json["unsigned"]?["prev_content"], + json["decrypted_type"] ?? json["type"], + ), + ); } @freezed diff --git a/lib/models/info/audio.dart b/lib/models/info/audio.dart new file mode 100644 index 0000000..ccfcf7a --- /dev/null +++ b/lib/models/info/audio.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/ms_duration.dart"; +part "audio.freezed.dart"; +part "audio.g.dart"; + +@freezed +abstract class AudioInfo with _$AudioInfo { + /// Information for images, [size] is in bytes. + const factory AudioInfo({ + @MSDuration() Duration? duration, + @JsonKey(name: "mimetype") String? mimeType, + int? size, + }) = _AudioInfo; + + factory AudioInfo.fromJson(Map json) => + _$AudioInfoFromJson(json); +} diff --git a/lib/models/info/file.dart b/lib/models/info/file.dart new file mode 100644 index 0000000..1509c99 --- /dev/null +++ b/lib/models/info/file.dart @@ -0,0 +1,15 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +part "file.freezed.dart"; +part "file.g.dart"; + +@freezed +abstract class FileInfo with _$FileInfo { + /// Information for images, [size] is in bytes. + const factory FileInfo({ + @JsonKey(name: "mimetype") String? mimeType, + int? size, + }) = _FileInfo; + + factory FileInfo.fromJson(Map json) => + _$FileInfoFromJson(json); +} diff --git a/lib/models/info/image.dart b/lib/models/info/image.dart new file mode 100644 index 0000000..9833016 --- /dev/null +++ b/lib/models/info/image.dart @@ -0,0 +1,18 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +part "image.freezed.dart"; +part "image.g.dart"; + +@freezed +abstract class ImageInfo with _$ImageInfo { + /// Information for images, [size] is in bytes. + const factory ImageInfo({ + @JsonKey(name: "h") double? height, + @JsonKey(name: "w") double? width, + @JsonKey(name: "mimetype") String? mimeType, + @JsonKey(name: "xyz.amorgan.blurhash") String? blurHash, + int? size, + }) = _ImageInfo; + + factory ImageInfo.fromJson(Map json) => + _$ImageInfoFromJson(json); +} diff --git a/lib/models/info/video.dart b/lib/models/info/video.dart new file mode 100644 index 0000000..6ff3547 --- /dev/null +++ b/lib/models/info/video.dart @@ -0,0 +1,19 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/ms_duration.dart"; +part "video.freezed.dart"; +part "video.g.dart"; + +@freezed +abstract class VideoInfo with _$VideoInfo { + /// Information for images, [size] is in bytes. + const factory VideoInfo({ + @JsonKey(name: "h") int? height, + @JsonKey(name: "w") int? width, + @JsonKey(name: "mimetype") String? mimeType, + @MSDuration() Duration? duration, + int? size, + }) = _VideoInfo; + + factory VideoInfo.fromJson(Map json) => + _$VideoInfoFromJson(json); +} diff --git a/lib/models/join_rule.dart b/lib/models/join_rule.dart new file mode 100644 index 0000000..3fade23 --- /dev/null +++ b/lib/models/join_rule.dart @@ -0,0 +1,4 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +@JsonEnum(fieldRename: FieldRename.snake) +enum JoinRule { public, knock, invite, private, restricted, knockRestricted } diff --git a/lib/models/membership.dart b/lib/models/membership.dart deleted file mode 100644 index ce0cc42..0000000 --- a/lib/models/membership.dart +++ /dev/null @@ -1,32 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/models/membership_status.dart"; -part "membership.freezed.dart"; - -@freezed -abstract class Membership with _$Membership { - const Membership._(); - const factory Membership({ - required MembershipStatus status, - required Uri? avatarUrl, - required String displayName, - required String userId, - }) = _Membership; - - factory Membership.fromContent( - IMap content, - String userId, - String homeserver, - ) => Membership( - status: MembershipStatus.values.firstWhere( - (status) => status.name == content["membership"], - orElse: () => MembershipStatus.leave, - ), - avatarUrl: Uri.tryParse( - content["avatar_url"] ?? "", - )?.mxcToHttps(homeserver), - userId: userId, - displayName: content["displayname"] ?? userId.substring(1).split(":").first, - ); -} diff --git a/lib/models/membership_status.dart b/lib/models/membership_status.dart index bc85e22..ba7a241 100644 --- a/lib/models/membership_status.dart +++ b/lib/models/membership_status.dart @@ -1,4 +1,4 @@ import "package:freezed_annotation/freezed_annotation.dart"; @JsonEnum() -enum MembershipStatus { leave, invite, ban, join } +enum MembershipStatus { leave, invite, ban, join, knock } diff --git a/lib/models/ms_duration.dart b/lib/models/ms_duration.dart new file mode 100644 index 0000000..de12943 --- /dev/null +++ b/lib/models/ms_duration.dart @@ -0,0 +1,11 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +class MSDuration implements JsonConverter { + const MSDuration(); + + @override + Duration fromJson(int ms) => Duration(milliseconds: ms); + + @override + int toJson(Duration duration) => duration.inMilliseconds; +} diff --git a/lib/models/open_graph_data.dart b/lib/models/open_graph_data.dart new file mode 100644 index 0000000..d7e840d --- /dev/null +++ b/lib/models/open_graph_data.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +part "open_graph_data.freezed.dart"; +part "open_graph_data.g.dart"; + +@freezed +abstract class OpenGraphData with _$OpenGraphData { + const factory OpenGraphData({ + @JsonKey(name: "og:title") required String? title, + @JsonKey(name: "og:description") required String? description, + @JsonKey(name: "og:image") required Uri? imageUrl, + @JsonKey(name: "og:image:width") required double? width, + @JsonKey(name: "og:image:height") required double? height, + }) = _OpenGraphData; + + factory OpenGraphData.fromJson(Map json) => + _$OpenGraphDataFromJson(json); +} diff --git a/lib/models/profile.dart b/lib/models/profile.dart index 584f27b..6ae1e94 100644 --- a/lib/models/profile.dart +++ b/lib/models/profile.dart @@ -1,29 +1,45 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/membership.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, + static Object? readPronouns(Map map, _) => + map["m.pronouns"] ?? map["io.fsky.nyx.pronouns"]; - @JsonKey(readValue: readTimezone) String? timezone, + static Object? readTimezone(Map map, _) => + map["m.tz"] ?? map["us.cloke.msc4175.tz"]; + + const factory Profile({ + required String id, + String? parseError, + Uri? avatarUrl, + + @JsonKey( + name: "displayname", + fromJson: MembershipContent.displaynameFromJson, + ) + String? displayName, + + @JsonKey(readValue: Profile.readTimezone, name: "m.tz") String? timezone, @Default(IList.empty()) - @JsonKey(readValue: readPronouns) + @JsonKey(readValue: Profile.readPronouns, name: "io.fsky.nyx.pronouns") IList pronouns, }) = _Profile; - factory Profile.fromJson(Map json) => + factory Profile.fromJson(Map json) => _$ProfileFromJson(json); + + factory Profile.fromJsonWithCatch(Map json) { + try { + return Profile.fromJson(json); + } catch (error) { + return Profile(id: json["id"], parseError: error.toString()); + } + } } @freezed diff --git a/lib/models/requests/get_event_request.dart b/lib/models/requests/get_event_request.dart index 9374f3a..4fcf7b6 100644 --- a/lib/models/requests/get_event_request.dart +++ b/lib/models/requests/get_event_request.dart @@ -1,32 +1,16 @@ import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/room.dart"; part "get_event_request.freezed.dart"; part "get_event_request.g.dart"; -@Freezed(toJson: false) +@Freezed() abstract class GetEventRequest with _$GetEventRequest { const GetEventRequest._(); const factory GetEventRequest({ - required Room room, + required String roomId, required String eventId, @Default(false) bool unredact, }) = _GetEventRequest; - Map toJson() => { - "room_id": room.metadata?.id, - "event_id": eventId, - "unredact": unredact, - }; - - @override - bool operator ==(Object other) => - other.runtimeType == runtimeType && - other is GetEventRequest && - other.eventId == eventId; - - @override - int get hashCode => Object.hash(runtimeType, eventId); - factory GetEventRequest.fromJson(Map json) => _$GetEventRequestFromJson(json); } diff --git a/lib/models/room.dart b/lib/models/room.dart index 3c3eec0..fb21a55 100644 --- a/lib/models/room.dart +++ b/lib/models/room.dart @@ -8,29 +8,50 @@ part "room.g.dart"; @freezed abstract class Room with _$Room { + static IMap timelineTupleJsonToIMap(List json) => + IMap.fromEntries( + json.map( + (timelineTuple) => MapEntry( + timelineTuple["timeline_rowid"], + timelineTuple["event_rowid"], + ), + ), + ); + + static IMap eventsJsonToIMap(List json) => + IMap.fromEntries( + json.map((eventJson) { + final event = Event.fromJson(eventJson); + return MapEntry(event.rowId, event); + }), + ); + + /// [timeline] is an IMap of timelineRowId to eventRowId + /// [events] is an IMap of eventRowId to event + /// [sticky] is an ISet of eventRowId const factory Room({ @JsonKey(name: "meta") RoomMetadata? metadata, - @Default(IList.empty()) IList timeline, + @Default(IMap.empty()) + @JsonKey(fromJson: Room.timelineTupleJsonToIMap) + IMap timeline, + @Default(ISet.empty()) ISet sticky, + + @Default(IMap.empty()) + @JsonKey(fromJson: Room.eventsJsonToIMap) + IMap events, + @Default(false) bool reset, + @Default(false) bool hasFetchedState, + @Default(false) bool hasFetchedMembers, @Default(IMap.empty()) IMap> state, - // required IMap accountData, - @Default(IList.empty()) IList events, + @Default(IMap.empty()) IMap> receipts, @Default(false) bool dismissNotifications, @Default(true) bool hasMore, + + // required IMap accountData, // required IList notifications, }) = _Room; factory Room.fromJson(Map json) => _$RoomFromJson(json); } - -@freezed -abstract class TimelineRowTuple with _$TimelineRowTuple { - const factory TimelineRowTuple({ - @JsonKey(name: "timeline_rowid") required int timelineRowId, - @JsonKey(name: "event_rowid") int? eventRowId, - }) = _TimelineRowTuple; - - factory TimelineRowTuple.fromJson(Map json) => - _$TimelineRowTupleFromJson(json); -} diff --git a/lib/models/space.dart b/lib/models/space.dart index 631759a..73fbbc6 100644 --- a/lib/models/space.dart +++ b/lib/models/space.dart @@ -2,6 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/widgets.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/room.dart"; +import "package:nexus/models/subspace.dart"; part "space.freezed.dart"; @freezed @@ -12,5 +13,6 @@ abstract class Space with _$Space { IconData? icon, Room? room, required IList children, + required IList subSpaces, }) = _Space; } diff --git a/lib/models/subspace.dart b/lib/models/subspace.dart new file mode 100644 index 0000000..1a1879c --- /dev/null +++ b/lib/models/subspace.dart @@ -0,0 +1,10 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/room.dart"; +part "subspace.freezed.dart"; + +@freezed +abstract class Subspace with _$Subspace { + const factory Subspace({required Room room, required IList children}) = + _Subspace; +} diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index 671891c..2c0ef97 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -1,9 +1,11 @@ import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/emoji_controller.dart"; import "package:nexus/controllers/init_complete_controller.dart"; +import "package:nexus/controllers/key_controller.dart"; import "package:nexus/widgets/appbar.dart"; -import "package:nexus/widgets/chat_page/sidebar.dart"; -import "package:nexus/widgets/chat_page/room_chat.dart"; +import "package:nexus/widgets/sidebar.dart"; +import "package:nexus/widgets/room_chat.dart"; import "package:nexus/widgets/loading.dart"; class ChatPage extends ConsumerWidget { @@ -15,26 +17,27 @@ class ChatPage extends ConsumerWidget { final isDesktop = constraints.maxWidth > 650; final showMembersByDefault = constraints.maxWidth > 1000; final initComplete = ref.watch(InitCompleteController.provider); + final roomId = ref.watch(KeyController.provider(KeyController.roomKey)); + ref.read(EmojiController.provider); return Scaffold( appBar: initComplete ? null : Appbar(), body: initComplete - ? Builder( - builder: (context) => Row( - children: [ - if (isDesktop) Sidebar(isDesktop: isDesktop), - Expanded( - child: RoomChat( - isDesktop: isDesktop, - showMembersByDefault: showMembersByDefault, - ), + ? Row( + children: [ + if (isDesktop) Sidebar(isDesktop: isDesktop), + Expanded( + child: RoomChat( + roomId: roomId, + isDesktop: isDesktop, + showMembersByDefault: showMembersByDefault, ), - ], - ), + ), + ], ) : Center( child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [Loading(), Text("Syncing...")], ), ), diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index fda53d0..5c9d53d 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -1,209 +1,98 @@ import "package:flutter/material.dart"; import "package:flutter_hooks/flutter_hooks.dart"; -import "package:flutter_svg/flutter_svg.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/helpers/launch_helper.dart"; -import "package:nexus/models/homeserver.dart"; -import "package:nexus/models/requests/login_request.dart"; import "package:nexus/widgets/appbar.dart"; -import "package:nexus/widgets/divider_text.dart"; -import "package:nexus/widgets/loading.dart"; +import "package:nexus/helpers/required_validator_helper.dart"; class LoginPage extends HookConsumerWidget { - const LoginPage({super.key}); + final Uri homeserver; + const LoginPage({super.key, required this.homeserver}); @override Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); final client = ref.watch(ClientController.provider.notifier); final isLoading = useState(false); - final homeserver = useState(null); - - final launch = ref.watch(LaunchHelper.provider).launchUrl; - - Future setHomeserver(Uri? newHomeserver) async { - isLoading.value = true; - - homeserver.value = newHomeserver == null - ? null - : await client.discoverHomeserver( - newHomeserver.hasScheme - ? newHomeserver - : Uri.https(newHomeserver.path), - ); - - if (homeserver.value == null && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Homeserver verification failed. Is your homeserver down?", - style: TextStyle(color: theme.colorScheme.onErrorContainer), - ), - backgroundColor: theme.colorScheme.errorContainer, - ), - ); - } - isLoading.value = false; - } - - final homeserverUrl = useTextEditingController(); final username = useTextEditingController(); final password = useTextEditingController(); + final inputError = useState(null); + final formKey = useRef(GlobalKey()); + + Future tryLogin() async { + isLoading.value = true; + + try { + if (formKey.value.currentState?.validate() != true) return; + + final error = await client.login( + .new( + username: username.text, + password: password.text, + homeserverUrl: homeserver.origin, + ), + ); + + if (error != null) { + inputError.value = error; + isLoading.value = false; + } else { + if (context.mounted) Navigator.of(context).pop(); + } + } finally { + isLoading.value = false; + } + } + return Scaffold( - appBar: Appbar(), - body: Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: 600), - child: ListView( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 64), + appBar: Appbar( + leading: IconButton( + icon: Icon(Icons.arrow_back), + onPressed: Navigator.of(context).pop, + ), + ), + body: AlertDialog( + title: Text("Login to ${homeserver.host}"), + content: Form( + key: formKey.value, + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ - Row( - children: [ - SvgPicture.asset("assets/icon.svg"), - SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Nexus", style: theme.textTheme.displayMedium), - Text( - "A Simple Matrix Client", - style: theme.textTheme.headlineMedium, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], + TextFormField( + autofocus: true, + textInputAction: .next, + autovalidateMode: .onUserInteraction, + validator: requiredValidator, + decoration: .new(label: Text("Username")), + controller: username, ), - Padding( - padding: EdgeInsetsGeometry.symmetric(vertical: 12), - child: Divider(), + SizedBox(height: 12), + TextFormField( + textInputAction: .done, + decoration: .new( + label: Text("Password"), + errorText: inputError.value, + errorMaxLines: 5, + ), + autovalidateMode: .onUserInteraction, + validator: requiredValidator, + controller: password, + obscureText: true, + onFieldSubmitted: (_) => tryLogin(), + // Don't defocus on submit + onEditingComplete: () {}, ), - - DividerText("Enter a homeserver domain:"), - Row( - spacing: 8, - children: [ - Expanded( - child: TextField( - controller: homeserverUrl, - decoration: InputDecoration( - labelText: "Homeserver URL (e.g. matrix.org)", - ), - ), - ), - IconButton.filled( - tooltip: "Confirm homeserver choice", - onPressed: isLoading.value - ? null - : () => setHomeserver(Uri.tryParse(homeserverUrl.text)), - icon: Icon(Icons.check), - ), - ], - ), - - DividerText("Or, choose from some popular homeservers:"), - ...([ - Homeserver( - name: "Matrix.org", - description: - "The Matrix.org Foundation offers the matrix.org homeserver as an easy entry point for anyone wanting to try out Matrix.", - url: Uri.https("matrix.org"), - iconUrl: - "https://raw.githubusercontent.com/element-hq/logos/refs/heads/master/matrix/matrix-favicon${Theme.brightnessOf(context) == Brightness.dark ? "-white" : ""}.png", - ), - Homeserver( - name: "Federated Nexus", - description: - "Federated Nexus is a community resource hosting multiple FOSS (especially federated) services, including Matrix and Forgejo. By the same developers who made Nexus client.", - url: Uri.https("federated.nexus"), - iconUrl: "https://federated.nexus/images/icon.png", - ), - Homeserver( - name: "Unredacted", - description: - "Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.", - url: Uri.https("unredacted.org", "services/si/matrix"), - iconUrl: "https://unredacted.org/favicon.ico", - ), - ].map( - (homeserver) => Card( - child: ListTile( - title: Text(homeserver.name), - leading: Image.network( - homeserver.iconUrl, - errorBuilder: (_, _, _) => SizedBox.shrink(), - height: 32, - ), - subtitle: Text(homeserver.description), - onTap: isLoading.value - ? null - : () => setHomeserver(homeserver.url), - trailing: IconButton( - tooltip: "Launch homeserver info page", - onPressed: () => launch(homeserver.url), - icon: Icon(Icons.info_outline), - ), - ), - ), - )), - SizedBox(height: 8), - TextButton( - onPressed: () => launch(Uri.https("servers.joinmatrix.org")), - child: Text("See more homeservers..."), - ), - if (isLoading.value) - Padding(padding: EdgeInsets.only(top: 32), child: Loading()) - else if (homeserver.value != null) ...[ - DividerText("Then, sign in:"), - SizedBox(height: 4), - TextField( - decoration: InputDecoration(label: Text("Username")), - controller: username, - ), - SizedBox(height: 12), - TextField( - decoration: InputDecoration(label: Text("Password")), - controller: password, - obscureText: true, - ), - SizedBox(height: 12), - ElevatedButton( - onPressed: () async { - isLoading.value = true; - final error = await client.login( - LoginRequest( - username: username.text, - password: password.text, - homeserverUrl: homeserver.value!, - ), - ); - - if (error != null && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Login failed. Is your password right?\nError: $error", - style: TextStyle( - color: theme.colorScheme.onErrorContainer, - ), - ), - backgroundColor: theme.colorScheme.errorContainer, - ), - ); - isLoading.value = false; - } - }, - child: Text("Sign In"), - ), - ], ], ), ), + actions: [ + TextButton( + onPressed: isLoading.value ? null : tryLogin, + child: Text("Sign In"), + ), + ], ), ); } diff --git a/lib/pages/select_server_page.dart b/lib/pages/select_server_page.dart new file mode 100644 index 0000000..f0e7dff --- /dev/null +++ b/lib/pages/select_server_page.dart @@ -0,0 +1,169 @@ +import "package:flutter/material.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:flutter_svg/flutter_svg.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/helpers/launch_helper.dart"; +import "package:nexus/models/homeserver.dart"; +import "package:nexus/pages/login_page.dart"; +import "package:nexus/widgets/appbar.dart"; +import "package:nexus/widgets/divider_text.dart"; + +class SelectServerPage extends HookConsumerWidget { + const SelectServerPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + final launch = ref.watch(LaunchHelper.provider).launchUrl; + + final isLoading = useState(false); + final homeserverUrl = useTextEditingController(); + + Future setHomeserver(Uri? newHomeserver) async { + isLoading.value = true; + + try { + if (newHomeserver?.hasScheme == false) { + newHomeserver = Uri.https(newHomeserver!.path); + } + + final newUrl = newHomeserver == null + ? null + : await ref + .watch(ClientController.provider.notifier) + .discoverHomeserver(newHomeserver); + + if (context.mounted) { + if (newUrl == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Homeserver verification failed. Is your homeserver down?", + style: .new(color: theme.colorScheme.onErrorContainer), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } else { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => LoginPage(homeserver: newUrl)), + ); + } + } + } finally { + isLoading.value = false; + } + } + + return Scaffold( + appBar: Appbar(), + body: Center( + child: ConstrainedBox( + constraints: .new(maxWidth: 600), + child: ListView( + children: [ + Row( + children: [ + SvgPicture.asset("assets/icon.svg", width: 128), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + Text("Nexus", style: theme.textTheme.displayMedium), + Text( + "A Simple Matrix Client", + style: theme.textTheme.headlineMedium, + overflow: .ellipsis, + ), + ], + ), + ), + ], + ), + Padding(padding: .symmetric(vertical: 12), child: Divider()), + DividerText("Enter a homeserver domain:"), + Row( + spacing: 8, + children: [ + Expanded( + child: TextField( + textInputAction: .done, + autofocus: true, + onSubmitted: (text) => setHomeserver(.tryParse(text)), + controller: homeserverUrl, + decoration: .new( + labelText: "Homeserver URL", + hintText: "matrix.org", + ), + ), + ), + IconButton.filled( + tooltip: "Confirm homeserver choice", + onPressed: isLoading.value + ? null + : () => setHomeserver(.tryParse(homeserverUrl.text)), + icon: Icon(Icons.check), + ), + ], + ), + DividerText("Or, choose from some popular homeservers:"), + ...([ + .new( + name: "Matrix.org", + description: + "The Matrix.org Foundation offers the matrix.org homeserver as an easy entry point for anyone wanting to try out Matrix.", + url: .https("matrix.org"), + iconUrl: + "https://raw.githubusercontent.com/element-hq/logos/refs/heads/master/matrix/matrix-favicon${Theme.brightnessOf(context) == Brightness.dark ? "-white" : ""}.png", + ), + .new( + name: "Federated Nexus", + description: + "Federated Nexus is a community resource hosting multiple FOSS (especially federated) services, including Matrix and Forgejo. By the same developers who made Nexus client.", + url: .https("federated.nexus"), + iconUrl: "https://federated.nexus/images/icon.png", + ), + .new( + name: "Unredacted", + description: + "Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.", + url: .https("unredacted.org", "services/si/matrix"), + iconUrl: "https://unredacted.org/favicon.ico", + ), + ].map( + (homeserver) => Card( + child: ListTile( + enabled: !isLoading.value, + title: Text(homeserver.name), + leading: Image.network( + homeserver.iconUrl, + errorBuilder: (_, _, _) => SizedBox.shrink(), + height: 32, + ), + subtitle: Text(homeserver.description), + onTap: isLoading.value + ? null + : () => setHomeserver(homeserver.url), + trailing: IconButton( + tooltip: "Launch homeserver info page", + onPressed: () => launch(homeserver.url), + icon: Icon(Icons.info_outline), + ), + ), + ), + )), + + TextButton( + onPressed: () => launch(.https("servers.joinmatrix.org")), + child: Text("See more homeservers..."), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/verify_page.dart b/lib/pages/verify_page.dart index 962701c..bf5c9f3 100644 --- a/lib/pages/verify_page.dart +++ b/lib/pages/verify_page.dart @@ -3,7 +3,7 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; import "package:nexus/widgets/appbar.dart"; -import "package:nexus/widgets/form_text_input.dart"; +import "package:nexus/helpers/required_validator_helper.dart"; class VerifyPage extends HookConsumerWidget { const VerifyPage({super.key}); @@ -11,72 +11,61 @@ class VerifyPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final passphraseController = useTextEditingController(); - final isVerifying = useState(false); + final isLoading = useState(false); + final inputError = useState(null); + final formKey = useRef(GlobalKey()); + + Future verify() async { + isLoading.value = true; + + try { + if (formKey.value.currentState?.validate() != true) { + return; + } + + inputError.value = await ref + .watch(ClientController.provider.notifier) + .verify(passphraseController.text); + } finally { + isLoading.value = false; + } + } + return Scaffold( appBar: Appbar(), body: AlertDialog( title: Text("Verify"), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Enter your recovery key or passphrase below to unlock encrypted messages.\nYour passphrase is usually not the same as your password.", - ), - SizedBox(height: 12), - FormTextInput( - required: false, - autofocus: true, - capitalize: true, - controller: passphraseController, - obscure: true, - title: "Recovery Key or Passphrase", - ), - ], + content: Form( + key: formKey.value, + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text( + "Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.", + ), + SizedBox(height: 12), + TextFormField( + autofocus: true, + controller: passphraseController, + textInputAction: .done, + autovalidateMode: .onUserInteraction, + validator: requiredValidator, + obscureText: true, + decoration: .new( + label: Text("Recovery Key or Passphrase"), + errorText: inputError.value, + ), + onFieldSubmitted: (_) => verify(), + // Don't defocus on submit + onEditingComplete: () {}, + ), + ], + ), ), actions: [ TextButton( - onPressed: isVerifying.value - ? null - : () async { - final scaffoldMessenger = ScaffoldMessenger.of(context); - final snackbar = scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - "Attempting to verify with recovery key...", - ), - duration: Duration(days: 999), - ), - ); - - isVerifying.value = true; - - final error = await ref - .watch(ClientController.provider.notifier) - .verify(passphraseController.text); - - snackbar.close(); - if (error != null) { - isVerifying.value = false; - if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - backgroundColor: Theme.of( - context, - ).colorScheme.errorContainer, - content: Text( - "Verification failed. Is your passphrase correct?\nError: $error", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onErrorContainer, - ), - ), - ), - ); - } - } - }, + onPressed: isLoading.value ? null : verify, child: Text("Verify"), ), ], diff --git a/lib/widgets/appbar.dart b/lib/widgets/appbar.dart index aae6c13..811788a 100644 --- a/lib/widgets/appbar.dart +++ b/lib/widgets/appbar.dart @@ -9,18 +9,20 @@ class Appbar extends StatelessWidget implements PreferredSizeWidget { final Color? backgroundColor; final double? scrolledUnderElevation; final IList actions; + final VoidCallback? onTap; const Appbar({ super.key, this.title, + this.onTap, this.backgroundColor, this.scrolledUnderElevation, this.leading, - this.actions = const IList.empty(), + this.actions = const .empty(), }); @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); + Size get preferredSize => const .fromHeight(kToolbarHeight); @override Widget build(BuildContext context) { @@ -37,11 +39,14 @@ class Appbar extends StatelessWidget implements PreferredSizeWidget { return GestureDetector( onPanStart: (_) => windowManager.startDragging(), child: AppBar( - leading: leading, + leading: InkWell(onTap: onTap, child: leading), backgroundColor: backgroundColor, scrolledUnderElevation: scrolledUnderElevation, - actionsPadding: const EdgeInsets.symmetric(horizontal: 8), - title: IgnorePointer(child: title), + actionsPadding: const .symmetric(horizontal: 8), + title: InkWell( + onTap: onTap, + child: IgnorePointer(child: title), + ), flexibleSpace: GestureDetector(onDoubleTap: maximize), actions: [ ...actions, diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatar_or_hash.dart index 28662e2..20f4eac 100644 --- a/lib/widgets/avatar_or_hash.dart +++ b/lib/widgets/avatar_or_hash.dart @@ -2,22 +2,20 @@ import "package:color_hash/color_hash.dart"; import "package:cross_cache/cross_cache.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/cross_cache_controller.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/helpers/extensions/mxc_to_https.dart"; class AvatarOrHash extends ConsumerWidget { final Uri? avatar; final String title; final Widget? fallback; - final bool hasBadge; - final int badgeNumber; final double height; const AvatarOrHash( this.avatar, this.title, { this.fallback, - this.badgeNumber = 0, - this.hasBadge = false, this.height = 24, super.key, }); @@ -26,34 +24,40 @@ class AvatarOrHash extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final box = ColoredBox( color: ColorHash(title).color, - child: Center(child: Text(title.isEmpty ? "" : title[0])), + child: Center(child: Icon(Icons.person, size: height / 2)), ); + + final parsedAvatar = avatar?.mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value?.homeserverUrl, + ), + ) ?? + "", + ); + return SizedBox( width: height, height: height, child: Center( - child: Badge( - isLabelVisible: hasBadge, - label: badgeNumber != 0 ? Text(badgeNumber.toString()) : null, - smallSize: 12, - backgroundColor: Theme.of(context).colorScheme.primary, - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular((height - 8) / 2.5)), - child: SizedBox( - width: height, - height: height, - child: avatar == null - ? fallback ?? box - : Image( - image: CachedNetworkImage( - avatar.toString(), - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - fit: BoxFit.cover, - errorBuilder: (_, _, _) => box, + child: ClipRRect( + borderRadius: .all(.circular((height - 8) / 2.5)), + child: SizedBox( + width: height, + height: height, + child: parsedAvatar == null + ? fallback ?? box + : Image( + image: CachedNetworkImage( + parsedAvatar.toString(), + ref.watch(CrossCacheController.provider), + headers: ref.headers, ), - ), + fit: .cover, + loadingBuilder: (_, child, loadingProgress) => + loadingProgress == null ? child : fallback ?? box, + errorBuilder: (_, _, _) => fallback ?? box, + ), ), ), ), diff --git a/lib/widgets/chat_page/composer/chat_box.dart b/lib/widgets/chat_page/composer/chat_box.dart deleted file mode 100644 index 478974e..0000000 --- a/lib/widgets/chat_page/composer/chat_box.dart +++ /dev/null @@ -1,177 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:fluttertagger/fluttertagger.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/power_level_controller.dart"; -import "package:nexus/models/configs/power_level_config.dart"; -import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/chat_page/composer/mention_overlay.dart"; -import "package:nexus/widgets/chat_page/composer/relation_preview.dart"; -import "package:nexus/widgets/chat_page/emoji_picker_button.dart"; - -class ChatBox extends HookConsumerWidget { - final Message? relatedMessage; - final RelationType relationType; - final VoidCallback onDismiss; - 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.onSend, - this.node, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final controller = useRef(FlutterTaggerController()); - final triggerCharacter = useState(""); - final shouldMention = useState(true); - final query = useState(""); - - if (relationType == RelationType.edit && - relatedMessage is TextMessage && - controller.value.text.isEmpty) { - controller.value.text = relatedMessage?.metadata?["editSource"] ?? ""; - } - - void send() { - if (controller.value.text.isEmpty) return; - onSend( - controller.value.formattedText, - shouldMention: shouldMention.value, - tags: controller.value.tags.toIList(), - ); - - onDismiss(); - controller.value.text = ""; - } - - 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, - right: 0, - child: Padding( - padding: EdgeInsetsGeometry.all(12), - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), - child: Column( - children: [ - RelationPreview( - relatedMessage, - shouldMention: shouldMention.value, - toggleShouldMention: () => - shouldMention.value = !shouldMention.value, - relationType: relationType, - onDismiss: onDismiss, - ), - Container( - color: theme.colorScheme.surfaceContainerHighest, - padding: EdgeInsets.symmetric(horizontal: 8), - 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( - title: Text("Camera"), - leading: Icon(Icons.add_a_photo), - ), - ), - PopupMenuItem( - child: ListTile( - title: Text("Gallery"), - leading: Icon(Icons.add_photo_alternate), - ), - ), - PopupMenuItem( - child: ListTile( - title: Text("Files"), - leading: Icon(Icons.attachment), - ), - ), - ], - icon: Icon(Icons.add), - ), - Expanded( - child: FlutterTagger( - triggerStrategy: TriggerStrategy.eager, - overlay: MentionOverlay( - query: query.value, - triggerCharacter: triggerCharacter.value, - addTag: ({required id, required name}) { - controller.value.addTag(id: id, name: name); - node?.requestFocus(); - }, - ), - controller: controller.value, - onSearch: (newQuery, newTriggerCharacter) { - triggerCharacter.value = newTriggerCharacter; - query.value = newQuery; - }, - triggerCharacterAndStyles: {"@": style, "#": style}, - builder: (context, key) => TextFormField( - enabled: canSendMessages, - maxLines: 12, - minLines: 1, - autofocus: true, - decoration: InputDecoration( - hintText: canSendMessages - ? "Your message here..." - : "You don't have permission to send messages in this room...", - border: InputBorder.none, - ), - controller: controller.value, - key: key, - onFieldSubmitted: (_) => send(), - // Don't defocus on submit - onEditingComplete: () {}, - textInputAction: TextInputAction.done, - focusNode: node, - ), - ), - ), - IconButton( - onPressed: !canSendMessages ? null : send, - icon: Icon(Icons.send), - tooltip: "Send message", - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/widgets/chat_page/composer/relation_preview.dart b/lib/widgets/chat_page/composer/relation_preview.dart deleted file mode 100644 index c90b07b..0000000 --- a/lib/widgets/chat_page/composer/relation_preview.dart +++ /dev/null @@ -1,93 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; - -class RelationPreview extends ConsumerWidget { - final Message? relatedMessage; - final RelationType relationType; - final VoidCallback onDismiss; - final bool shouldMention; - final VoidCallback toggleShouldMention; - - const RelationPreview( - this.relatedMessage, { - required this.relationType, - required this.onDismiss, - required this.shouldMention, - required this.toggleShouldMention, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - if (relatedMessage == null) return SizedBox.shrink(); - final theme = Theme.of(context); - - return Container( - color: theme.colorScheme.surfaceContainerHigh, - padding: EdgeInsets.symmetric(horizontal: 8), - child: Row( - spacing: 8, - children: [ - if (relationType == RelationType.edit) - Text( - "Editing message:", - style: TextStyle(fontWeight: FontWeight.bold), - ), - - MessageAvatar(relatedMessage!), - - Expanded( - child: Row( - spacing: 8, - children: [ - Flexible( - child: MessageDisplayname( - relatedMessage!, - style: theme.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - Expanded( - child: Text( - relatedMessage?.metadata?["body"] ?? - relatedMessage?.metadata?["eventType"] ?? - "", - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: theme.textTheme.labelMedium, - ), - ), - ], - ), - ), - - if (relationType == RelationType.reply) - TextButton( - onPressed: toggleShouldMention, - child: Text( - shouldMention ? "@On" : "@Off", - style: TextStyle( - fontWeight: FontWeight.w900, - color: shouldMention ? null : Theme.of(context).disabledColor, - ), - ), - ), - - IconButton( - tooltip: - "Cancel ${relationType == RelationType.edit ? "edit" : "reply"}", - onPressed: onDismiss, - 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 deleted file mode 100644 index 0c43c48..0000000 --- a/lib/widgets/chat_page/emoji_picker_button.dart +++ /dev/null @@ -1,41 +0,0 @@ -import "package:emoji_text_field/emoji_text_field.dart"; -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; - -class EmojiPickerButton extends HookWidget { - final TextEditingController? controller; - final void Function(String emoji)? onSelection; - final VoidCallback? onPressed; - final BuildContext context; - const EmojiPickerButton({ - this.controller, - this.onPressed, - this.onSelection, - required this.context, - super.key, - }); - - @override - Widget build(_) => IconButton( - onPressed: () { - onPressed?.call(); - final controller = this.controller ?? TextEditingController(); - showModalBottomSheet( - context: context, - builder: (context) => EmojiKeyboardView( - config: EmojiViewConfig( - showRecentTab: false, - backgroundColor: Theme.of(context).colorScheme.surfaceContainer, - height: 600, - ), - textController: controller - ..addListener(() async { - Navigator.of(context).pop(); - onSelection?.call(controller.text); - }), - ), - ); - }, - icon: Icon(Icons.emoji_emotions), - ); -} diff --git a/lib/widgets/chat_page/expandable_image.dart b/lib/widgets/chat_page/expandable_image.dart deleted file mode 100644 index ac5bbe1..0000000 --- a/lib/widgets/chat_page/expandable_image.dart +++ /dev/null @@ -1,48 +0,0 @@ -import "dart:math"; -import "package:cross_cache/cross_cache.dart"; -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/widgets/error_dialog.dart"; - -class ExpandableImage extends ConsumerWidget { - final Widget child; - final String? source; - const ExpandableImage(this.source, {required this.child, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) => InkWell( - onTap: source == null - ? null - : () => showDialog( - context: context, - builder: (_) => LayoutBuilder( - builder: (context, constraints) => Dialog( - backgroundColor: Colors.transparent, - insetPadding: EdgeInsets.all(constraints.maxWidth / 100), - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: min(constraints.maxWidth, 1000), - ), - child: InteractiveViewer( - child: Image( - fit: BoxFit.contain, - errorBuilder: (_, error, stackTrace) => ErrorDialog( - "Loading failed for $source\nError: $error", - stackTrace, - ), - image: CachedNetworkImage( - source!, - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - ), - ), - ), - ), - ), - ), - child: child, - ); -} diff --git a/lib/widgets/chat_page/expandable_image_message.dart b/lib/widgets/chat_page/expandable_image_message.dart deleted file mode 100644 index f6e8a03..0000000 --- a/lib/widgets/chat_page/expandable_image_message.dart +++ /dev/null @@ -1,35 +0,0 @@ -import "package:cross_cache/cross_cache.dart"; -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flyer_chat_image_message/flyer_chat_image_message.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/widgets/chat_page/expandable_image.dart"; - -class ExpandableImageMessage extends ConsumerWidget { - final ImageMessage message; - final int index; - - const ExpandableImageMessage(this.message, {required this.index, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) => ExpandableImage( - message.source, - child: FlyerChatImageMessage( - customImageProvider: CachedNetworkImage( - message.source, - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - errorBuilder: (context, error, stackTrace) => Center( - child: Text( - "Image Failed to Load", - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ), - message: message, - index: index, - ), - ); -} diff --git a/lib/widgets/chat_page/html/mention_chip.dart b/lib/widgets/chat_page/html/mention_chip.dart deleted file mode 100644 index 575ad03..0000000 --- a/lib/widgets/chat_page/html/mention_chip.dart +++ /dev/null @@ -1,44 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/user_controller.dart"; -import "package:nexus/helpers/extensions/link_to_mention.dart"; -import "package:nexus/helpers/extensions/show_user_popover.dart"; - -class MentionChip extends ConsumerWidget { - final String content; - const MentionChip(this.content, {super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final membership = content.mention!.startsWith("@") == true - ? ref - .watch(UserController.provider(content.mention!)) - .whenOrNull(data: (data) => data) - : null; - - return InkWell( - onTapUp: (details) { - content.mention; - if (membership != null) { - context.showUserPopover( - membership, - globalPosition: details.globalPosition, - ); - } - }, - child: IgnorePointer( - child: Chip( - label: Text( - (membership == null ? null : "@${membership.displayName}") ?? - content.mention!, - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onPrimary, - ), - ), - backgroundColor: Theme.of(context).colorScheme.primary, - ), - ), - ); - } -} diff --git a/lib/widgets/chat_page/lazy_loading/message_avatar.dart b/lib/widgets/chat_page/lazy_loading/message_avatar.dart deleted file mode 100644 index dc8dfef..0000000 --- a/lib/widgets/chat_page/lazy_loading/message_avatar.dart +++ /dev/null @@ -1,32 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/author_controller.dart"; -import "package:nexus/helpers/extensions/better_when.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 double height; - const MessageAvatar(this.message, {this.height = 16, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) => ref - .watch(AuthorController.provider(message)) - .betterWhen( - 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 deleted file mode 100644 index 88d2fa6..0000000 --- a/lib/widgets/chat_page/lazy_loading/message_displayname.dart +++ /dev/null @@ -1,38 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/author_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/show_user_popover.dart"; - -class MessageDisplayname extends ConsumerWidget { - final Message message; - final TextStyle? style; - 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(message)) - .betterWhen( - data: (membership) => InkWell( - onTapUp: clickable - ? (details) => context.showUserPopover( - membership, - globalPosition: details.globalPosition, - ) - : null, - child: Text( - "${membership.displayName}${message.metadata?["pmp"] == null ? "" : " (via ${message.authorId})"}", - style: style, - overflow: TextOverflow.ellipsis, - ), - ), - loading: () => Text(""), - ); -} diff --git a/lib/widgets/chat_page/member_list.dart b/lib/widgets/chat_page/member_list.dart deleted file mode 100644 index 8be1ddd..0000000 --- a/lib/widgets/chat_page/member_list.dart +++ /dev/null @@ -1,94 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/members_by_type_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/show_user_popover.dart"; -import "package:nexus/models/membership_status.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; - -class MemberList extends HookConsumerWidget { - const MemberList({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final status = useState(MembershipStatus.join); - final membersProvider = ref.watch( - MembersByTypeController.provider(status.value), - ); - - return Drawer( - shape: Border(), - child: Column( - spacing: 8, - children: [ - AppBar( - scrolledUnderElevation: 0, - leading: Icon(Icons.people), - title: Text("Members"), - actionsPadding: EdgeInsets.only(right: 4), - actions: [ - if (Scaffold.of(context).hasEndDrawer) - IconButton( - onPressed: Scaffold.of(context).closeEndDrawer, - icon: Icon(Icons.close), - tooltip: "Close member list", - ), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 8, - children: [ - FilterChip( - label: Text("Joined"), - onSelected: (value) => status.value = MembershipStatus.join, - selected: status.value == MembershipStatus.join, - ), - FilterChip( - label: Text("Invited"), - onSelected: (value) => status.value = MembershipStatus.invite, - selected: status.value == MembershipStatus.invite, - ), - FilterChip( - label: Text("Banned"), - onSelected: (value) => status.value = MembershipStatus.ban, - selected: status.value == MembershipStatus.ban, - ), - ], - ), - membersProvider.betterWhen( - data: (members) => Expanded( - child: ListView( - children: members - .map( - (member) => InkWell( - onTapUp: (details) => context.showUserPopover( - member, - globalPosition: details.globalPosition, - ), - child: ListTile( - leading: AvatarOrHash( - member.avatarUrl, - member.displayName, - ), - title: Text( - member.displayName, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - member.userId, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ) - .toList(), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/widgets/chat_page/reply_widget.dart b/lib/widgets/chat_page/reply_widget.dart deleted file mode 100644 index b999be4..0000000 --- a/lib/widgets/chat_page/reply_widget.dart +++ /dev/null @@ -1,101 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/event_controller.dart"; -import "package:nexus/controllers/message_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/configs/message_config.dart"; -import "package:nexus/models/requests/get_event_request.dart"; -import "package:nexus/widgets/chat_page/html/quoted.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; - -typedef OnTapReply = void Function(Message message)?; - -class ReplyWidget extends ConsumerWidget { - final Message message; - final bool alwaysShow; - final MessageGroupStatus? groupStatus; - final OnTapReply onTapReply; - const ReplyWidget( - this.message, { - required this.groupStatus, - this.onTapReply, - this.alwaysShow = false, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final room = ref.watch(SelectedRoomController.provider); - return message.replyToMessageId == null || room == null - ? SizedBox.shrink() - : Padding( - padding: EdgeInsets.only(bottom: 12), - child: Quoted( - ref - .watch( - EventController.provider( - GetEventRequest( - room: room, - eventId: message.replyToMessageId!, - ), - ), - ) - .betterWhen( - loading: () => Text("Fetching event..."), - data: (event) => event == null - ? SizedBox.shrink() - : ref - .watch( - MessageController.provider( - MessageConfig(room: room, event: event), - ), - ) - .betterWhen( - loading: () => Text("Parsing message..."), - data: (replyMessage) { - if (replyMessage == null) { - return SizedBox.shrink(); - } - - return InkWell( - onTap: () => onTapReply?.call(replyMessage), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - MessageAvatar(replyMessage), - Flexible( - child: MessageDisplayname( - replyMessage, - clickable: false, - style: Theme.of(context) - .textTheme - .labelMedium - ?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - Flexible( - child: Text( - replyMessage.metadata!["body"], - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.labelMedium, - maxLines: 1, - ), - ), - ], - ), - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/widgets/chat_page/room_appbar.dart b/lib/widgets/chat_page/room_appbar.dart deleted file mode 100644 index 62e282d..0000000 --- a/lib/widgets/chat_page/room_appbar.dart +++ /dev/null @@ -1,78 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/widgets/appbar.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/chat_page/expandable_image.dart"; -import "package:nexus/widgets/chat_page/room_menu.dart"; - -class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { - final bool isDesktop; - final void Function(BuildContext context)? onOpenMemberList; - final void Function(BuildContext context) onOpenDrawer; - const RoomAppbar({ - required this.isDesktop, - required this.onOpenDrawer, - this.onOpenMemberList, - super.key, - }); - - @override - Size get preferredSize => AppBar().preferredSize; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final room = ref.watch(SelectedRoomController.provider); - return Appbar( - leading: isDesktop - ? room == null - ? null - : ExpandableImage( - room.metadata?.avatar?.toString(), - child: AvatarOrHash( - room.metadata?.avatar, - room.metadata?.name ?? "Unnamed Rooms", - height: 24, - fallback: Icon(Icons.numbers), - ), - ) - : DrawerButton(onPressed: () => onOpenDrawer(context)), - scrolledUnderElevation: 0, - title: room == null - ? null - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - room.metadata?.name ?? "Unnamed Room", - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - if (room.metadata?.topic?.isNotEmpty == true) - Text( - room.metadata!.topic!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - actions: [ - IconButton( - onPressed: null, - icon: Icon(Icons.push_pin), - tooltip: "Open pinned messages", - ), - 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 deleted file mode 100644 index 5166d87..0000000 --- a/lib/widgets/chat_page/room_chat.dart +++ /dev/null @@ -1,480 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:flutter/services.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_chat_ui/flutter_chat_ui.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:flyer_chat_file_message/flyer_chat_file_message.dart"; -import "package:flyer_chat_system_message/flyer_chat_system_message.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/account_data_controller.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/power_level_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/controllers/room_chat_controller.dart"; -import "package:nexus/controllers/via_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/show_context_menu.dart"; -import "package:nexus/models/configs/power_level_config.dart"; -import "package:nexus/models/relation_type.dart"; -import "package:nexus/models/requests/report_request.dart"; -import "package:nexus/widgets/chat_page/composer/chat_box.dart"; -import "package:nexus/widgets/chat_page/emoji_picker_button.dart"; -import "package:nexus/widgets/chat_page/expandable_image_message.dart"; -import "package:nexus/widgets/chat_page/member_list.dart"; -import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart"; -import "package:nexus/widgets/chat_page/room_appbar.dart"; -import "package:nexus/widgets/chat_page/wrappers/text_message_wrapper.dart"; -import "package:nexus/widgets/chat_page/reply_widget.dart"; -import "package:nexus/widgets/form_text_input.dart"; -import "package:nexus/main.dart"; - -class RoomChat extends HookConsumerWidget { - final bool isDesktop; - final bool showMembersByDefault; - const RoomChat({ - required this.isDesktop, - required this.showMembersByDefault, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final client = ref.watch(ClientController.provider.notifier); - final relatedMessage = useState(null); - final memberListOpened = useState(showMembersByDefault); - final relationType = useState(RelationType.reply); - final userId = ref.watch(ClientStateController.provider)?.userId; - final roomId = ref.watch( - SelectedRoomController.provider.select((value) => value?.metadata?.id), - ); - - final theme = Theme.of(context); - final danger = theme.colorScheme.error; - - if (roomId == null || userId == null) { - return Scaffold( - appBar: RoomAppbar( - isDesktop: isDesktop, - onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), - onOpenMemberList: null, - ), - body: Center( - child: Text( - "Nothing to see here...", - style: theme.textTheme.headlineMedium, - ), - ), - ); - } - - final controllerProvider = RoomChatController.provider(roomId); - final notifier = ref.watch(controllerProvider.notifier); - - final composerNode = useFocusNode( - onKeyEvent: (_, event) { - if (event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.escape) { - relatedMessage.value = null; - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - }, - ); - - List getMessageOptions(Message message) { - final isSentByMe = message.authorId == userId; - return [ - PopupMenuItem( - child: Row( - children: [ - ...{ - ...ref.watch( - AccountDataController.provider.select( - (value) => IList( - value["m.recent_emoji"]?.content["recent_emoji"] ?? - [], - ).map((entry) => entry["emoji"]), - ), - ), - "👍", - "🤣", - "😭", - "🤔", - } - .toIList() - .sublist(0, 4) - .map( - (emoji) => IconButton( - onPressed: () async { - Navigator.of(context).pop(); - await notifier - .sendReaction(emoji, message) - .onError(showError); - }, - icon: Text(emoji), - ), - ), - EmojiPickerButton( - context: context, - onPressed: Navigator.of(context).pop, - onSelection: (emoji) => - notifier.sendReaction(emoji, message).onError(showError), - ), - ], - ), - ), - PopupMenuItem( - onTap: () { - relatedMessage.value = message; - relationType.value = RelationType.reply; - composerNode.requestFocus(); - }, - child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")), - ), - if (message is TextMessage && isSentByMe) - PopupMenuItem( - onTap: () { - relatedMessage.value = message; - relationType.value = RelationType.edit; - composerNode.requestFocus(); - }, - child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")), - ), - PopupMenuItem( - onTap: () async { - final room = ref.watch(SelectedRoomController.provider); - if (room == null) return; - - final vias = ref.watch(ViaController.provider(room)); - - await Clipboard.setData( - ClipboardData( - text: - "matrix:roomid/${room.metadata?.id.substring(1)}/e/${message.id}$vias)", - ), - ); - }, - child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), - ), - if (ref.watch( - PowerLevelController.provider( - PowerLevelConfig(eventType: "m.room.redaction"), - ), - )) - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (_) { - final deleteReasonController = useTextEditingController(); - return AlertDialog( - title: Text("Delete Message"), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Are you sure you want to delete this message? This can not be reversed.", - ), - SizedBox(height: 12), - FormTextInput( - required: false, - capitalize: true, - controller: deleteReasonController, - title: "Reason for deletion (optional)", - ), - ], - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - await notifier - .deleteMessage( - message, - reason: deleteReasonController.text, - ) - .onError(showError); - }, - child: Text("Delete"), - ), - ], - ); - }, - ), - ), - child: ListTile( - leading: Icon(Icons.delete, color: danger), - title: Text("Delete", style: TextStyle(color: danger)), - ), - ), - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (_) { - final reasonController = useTextEditingController(); - return AlertDialog( - title: Text("Report"), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Report this event to your server administrators, who can take action like banning this server or room.", - ), - - SizedBox(height: 12), - FormTextInput( - required: false, - capitalize: true, - controller: reasonController, - title: "Reason for report (optional)", - ), - ], - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () { - client.reportEvent( - ReportRequest( - roomId: roomId, - eventId: message.id, - reason: reasonController.text.isEmpty - ? null - : reasonController.text, - ), - ); - Navigator.of(context).pop(); - }, - child: Text("Report"), - ), - ], - ); - }, - ), - ), - child: ListTile( - leading: Icon(Icons.report, color: danger), - title: Text("Report", style: TextStyle(color: danger)), - ), - ), - ]; - } - - final chatTheme = ChatTheme.fromThemeData(theme).copyWith( - colors: ChatColors.fromThemeData(theme).copyWith( - primary: theme.colorScheme.primaryContainer, - onPrimary: theme.colorScheme.onPrimaryContainer, - ), - ); - - return Scaffold( - appBar: RoomAppbar( - isDesktop: isDesktop, - onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), - onOpenMemberList: (thisContext) { - memberListOpened.value = !memberListOpened.value; - Scaffold.of(thisContext).openEndDrawer(); - }, - ), - body: Row( - children: [ - Expanded( - child: Column( - children: [ - Expanded( - child: ref - .watch(controllerProvider) - .betterWhen( - data: (controller) => Chat( - currentUserId: userId, - theme: chatTheme, - onMessageSecondaryTap: - ( - context, - message, { - required index, - TapUpDetails? details, - }) => details?.globalPosition == null - ? null - : context.showContextMenu( - globalPosition: details!.globalPosition, - children: getMessageOptions(message), - ), - onMessageLongPress: - ( - context, - message, { - required details, - required index, - }) => context.showContextMenu( - globalPosition: details.globalPosition, - children: getMessageOptions(message), - ), - builders: Builders( - loadMoreBuilder: (_) => SizedBox.shrink(), - - chatAnimatedListBuilder: (_, itemBuilder) => - ChatAnimatedList( - itemBuilder: itemBuilder, - onEndReached: - ref.watch( - SelectedRoomController.provider.select( - (room) => room?.hasMore == true, - ), - ) - ? notifier.loadOlder - : null, - onStartReached: () async { - final room = ref.watch( - SelectedRoomController.provider, - ); - return room == null - ? null - : await client.markRead(room); - }, - bottomPadding: 72, - ), - - composerBuilder: (_) => ChatBox( - node: composerNode, - onSend: - ( - text, { - required shouldMention, - required tags, - }) => notifier.send( - text, - tags: tags, - relationType: relationType.value, - shouldMention: shouldMention, - relation: relatedMessage.value, - ), - relationType: relationType.value, - relatedMessage: relatedMessage.value, - onDismiss: () => relatedMessage.value = null, - ), - - textMessageBuilder: - ( - context, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => TextMessageWrapper( - message, - content: message.text, - groupStatus: groupStatus, - onTapReply: notifier.scrollToMessage, - updateMessage: controller.updateMessage, - isSentByMe: isSentByMe, - ), - - imageMessageBuilder: - ( - context, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => TextMessageWrapper( - message, - content: message.text, - groupStatus: groupStatus, - onTapReply: notifier.scrollToMessage, - updateMessage: controller.updateMessage, - isSentByMe: isSentByMe, - extra: ExpandableImageMessage( - message, - index: index, - ), - ), - - fileMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => MessageWrapper( - message, - InkWell( - onTap: () => showDialog( - context: context, - builder: (_) => Dialog( - child: Text( - "TODO: Download Attachments", - ), - ), - ), - child: FlyerChatFileMessage( - topWidget: ReplyWidget( - message, - onTapReply: notifier.scrollToMessage, - groupStatus: groupStatus, - ), - message: message, - index: index, - ), - ), - groupStatus, - ), - - systemMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => FlyerChatSystemMessage( - message: message, - index: index, - ), - - unsupportedMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => Text( - "${message.authorId} sent ${message.metadata?["eventType"]}", - style: theme.textTheme.labelSmall?.copyWith( - color: Colors.grey, - ), - ), - ), - resolveUser: (_) async => null, - chatController: controller, - ), - ), - ), - ], - ), - ), - - if (memberListOpened.value == true && showMembersByDefault) - MemberList(), - ], - ), - - endDrawer: showMembersByDefault ? null : MemberList(), - ); - } -} diff --git a/lib/widgets/chat_page/sidebar.dart b/lib/widgets/chat_page/sidebar.dart deleted file mode 100644 index f79c38f..0000000 --- a/lib/widgets/chat_page/sidebar.dart +++ /dev/null @@ -1,185 +0,0 @@ -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/selected_space_controller.dart"; -import "package:nexus/controllers/spaces_controller.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/chat_page/join_dialog.dart"; -import "package:nexus/widgets/chat_page/room_menu.dart"; - -class Sidebar extends HookConsumerWidget { - final bool isDesktop; - const Sidebar({required this.isDesktop, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final selectedSpaceProvider = KeyController.provider( - KeyController.spaceKey, - ); - final selectedSpaceId = ref.watch(selectedSpaceProvider); - final selectedSpaceIdNotifier = ref.watch(selectedSpaceProvider.notifier); - - final selectedRoomController = KeyController.provider( - KeyController.roomKey, - ); - final selectedRoomId = ref.watch(selectedRoomController); - final selectedRoomIdNotifier = ref.watch(selectedRoomController.notifier); - - final spaces = ref.watch(SpacesController.provider); - final indexOfSelected = spaces.indexWhere( - (space) => space.id == selectedSpaceId, - ); - final selectedIndex = indexOfSelected == -1 ? 0 : indexOfSelected; - - final selectedSpace = ref.watch(SelectedSpaceController.provider); - - final indexOfSelectedRoom = selectedSpace.children.indexWhere( - (room) => room.metadata?.id == selectedRoomId, - ); - final selectedRoomIndex = indexOfSelectedRoom == -1 - ? selectedSpace.children.isEmpty - ? null - : 0 - : indexOfSelectedRoom; - - return Drawer( - shape: Border(), - child: Row( - children: [ - NavigationRail( - scrollable: true, - onDestinationSelected: (value) { - selectedSpaceIdNotifier.set(spaces[value].id); - selectedRoomIdNotifier.set( - spaces[value].children.firstOrNull?.metadata?.id, - ); - }, - destinations: spaces - .map( - (space) => NavigationRailDestination( - icon: AvatarOrHash( - space.room?.metadata?.avatar, - fallback: space.icon == null ? null : Icon(space.icon), - space.title, - hasBadge: space.children.any( - (room) => room.metadata?.unreadMessages != 0, - ), - badgeNumber: space.children.fold( - 0, - (previousValue, room) => - previousValue + - (room.metadata?.unreadNotifications ?? 0), - ), - ), - label: Text(space.title), - padding: EdgeInsets.only(top: 4), - ), - ) - .toList(), - selectedIndex: selectedIndex, - trailingAtBottom: true, - trailing: Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Column( - spacing: 8, - children: [ - PopupMenuButton( - itemBuilder: (_) => [ - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (_) => JoinDialog(ref), - ), - child: ListTile( - title: Text("Join an existing room (or space)"), - leading: Icon(Icons.numbers), - ), - ), - PopupMenuItem( - onTap: null, - child: ListTile( - title: Text("Create a new room"), - leading: Icon(Icons.add), - ), - ), - ], - icon: Icon(Icons.add), - ), - IconButton( - tooltip: "Explore other rooms", - onPressed: null, - icon: Icon(Icons.explore), - ), - IconButton( - tooltip: "Open settings", - onPressed: null, - // () => Navigator.of( - // context, - // ).push(MaterialPageRoute(builder: (_) => SettingsPage())), - icon: Icon(Icons.settings), - ), - ], - ), - ), - ), - Expanded( - child: Scaffold( - backgroundColor: Colors.transparent, - appBar: AppBar( - leading: AvatarOrHash( - selectedSpace.room?.metadata?.avatar, - fallback: selectedSpace.icon == null - ? null - : Icon(selectedSpace.icon), - - selectedSpace.title, - ), - title: Text( - selectedSpace.title, - overflow: TextOverflow.ellipsis, - ), - backgroundColor: Colors.transparent, - actions: [ - if (selectedSpace.room != null) - RoomMenu( - selectedSpace.room!, - children: selectedSpace.children, - ), - ], - ), - body: NavigationRail( - scrollable: true, - backgroundColor: Colors.transparent, - extended: true, - selectedIndex: selectedRoomIndex, - destinations: selectedSpace.children - .map( - (room) => NavigationRailDestination( - label: Text(room.metadata?.name ?? "Unnamed Room"), - icon: AvatarOrHash( - room.metadata?.avatar, - hasBadge: room.metadata?.unreadMessages != 0, - badgeNumber: room.metadata?.unreadNotifications ?? 0, - room.metadata?.name ?? "Unnamed Room", - fallback: selectedSpaceId == "dms" - ? null - : Icon(Icons.numbers), - // space.client.headers, - ), - ), - ) - .toList(), - onDestinationSelected: (value) { - selectedRoomIdNotifier.set( - selectedSpace.children[value].metadata?.id, - ); - if (!isDesktop) Navigator.of(context).pop(); - }, - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/widgets/chat_page/user_popover.dart b/lib/widgets/chat_page/user_popover.dart deleted file mode 100644 index a9a4799..0000000 --- a/lib/widgets/chat_page/user_popover.dart +++ /dev/null @@ -1,214 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:intl/intl.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/power_level_controller.dart"; -import "package:nexus/controllers/profile_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/configs/power_level_config.dart"; -import "package:nexus/models/membership.dart"; -import "package:nexus/models/membership_status.dart"; -import "package:nexus/models/requests/membership_action.dart"; -import "package:nexus/models/requests/set_membership_request.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/main.dart"; -import "package:nexus/widgets/chat_page/expandable_image.dart"; -import "package:nexus/widgets/form_text_input.dart"; - -class UserPopover extends ConsumerWidget { - final Membership member; - const UserPopover(this.member, {super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final client = ref.watch(ClientController.provider.notifier); - final roomId = ref.watch( - SelectedRoomController.provider.select((room) => room?.metadata?.id), - ); - - void showMembershipDialog(MembershipAction action) => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (context) { - final actionReasonController = useTextEditingController(); - return AlertDialog( - title: Text( - "${toBeginningOfSentenceCase(action.name)} ${member.userId}", - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Are you sure you want to ${action.name} ${member.userId}?", - ), - SizedBox(height: 12), - FormTextInput( - required: false, - capitalize: true, - controller: actionReasonController, - title: "Reason for ${action.name} (optional)", - ), - ], - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - client - .setMembership( - SetMembershipRequest( - userId: member.userId, - roomId: roomId!, - action: action, - reason: actionReasonController.text, - ), - ) - .onError(showError); - }, - child: Text(toBeginningOfSentenceCase(action.name)), - ), - ], - ); - }, - ), - ); - - return Column( - spacing: 16, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Wrap( - alignment: WrapAlignment.center, - spacing: 16, - runSpacing: 8, - children: [ - ExpandableImage( - member.avatarUrl?.toString(), - child: AvatarOrHash( - member.avatarUrl, - member.displayName, - height: 80, - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SelectableText( - member.displayName, - style: textTheme.headlineSmall, - ), - SelectableText(member.userId, style: textTheme.titleSmall), - SizedBox(height: 4), - ref - .watch(ProfileController.provider(member.userId)) - .betterWhen( - loading: SizedBox.shrink, - data: (profile) => Wrap( - spacing: 4, - children: [ - for (final pronoun in profile.pronouns.where( - (pronoun) => pronoun.language == "en", - )) - Chip( - label: Text(pronoun.summary), - labelStyle: TextStyle( - color: theme.colorScheme.onPrimary, - ), - color: WidgetStatePropertyAll( - theme.colorScheme.primary, - ), - ), - if (profile.timezone != null) - Chip( - label: Text(profile.timezone!), - labelStyle: TextStyle( - color: theme.colorScheme.onPrimary, - ), - color: WidgetStatePropertyAll( - theme.colorScheme.primary, - ), - ), - ], - ), - ), - ], - ), - ], - ), - if (member.userId != - ref.watch(ClientStateController.provider)?.userId && - roomId != null) - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FilledButton.icon(onPressed: null, label: Text("Message")), - - if (ref.watch( - PowerLevelController.provider( - PowerLevelConfig( - eventType: "m.room.member", - action: MembershipAction.kick, - isStateEvent: true, - targetUser: member.userId, - ), - ), - ) && - member.status == MembershipStatus.join || - member.status == MembershipStatus.invite) - FilledButton.icon( - onPressed: () => showMembershipDialog(MembershipAction.kick), - label: Text("Kick"), - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll( - theme.colorScheme.error, - ), - foregroundColor: WidgetStatePropertyAll( - theme.colorScheme.onError, - ), - ), - ), - if (ref.watch( - PowerLevelController.provider( - PowerLevelConfig( - eventType: "m.room.member", - action: MembershipAction.ban, - isStateEvent: true, - targetUser: member.userId, - ), - ), - )) - ElevatedButton.icon( - onPressed: () => showMembershipDialog( - member.status == MembershipStatus.ban - ? MembershipAction.unban - : MembershipAction.ban, - ), - label: Text( - member.status == MembershipStatus.ban ? "Unban" : "Ban", - ), - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll( - theme.colorScheme.errorContainer, - ), - foregroundColor: WidgetStatePropertyAll( - theme.colorScheme.onErrorContainer, - ), - ), - ), - ], - ), - ], - ); - } -} diff --git a/lib/widgets/chat_page/wrappers/message_wrapper.dart b/lib/widgets/chat_page/wrappers/message_wrapper.dart deleted file mode 100644 index 9c70c27..0000000 --- a/lib/widgets/chat_page/wrappers/message_wrapper.dart +++ /dev/null @@ -1,83 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; -import "package:nexus/widgets/chat_page/wrappers/reaction_row.dart"; -import "package:timeago/timeago.dart"; - -class MessageWrapper extends StatelessWidget { - final Message message; - final Widget child; - final MessageGroupStatus? groupStatus; - const MessageWrapper(this.message, this.child, this.groupStatus, {super.key}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final error = message.metadata?["error"]; - - return ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), - child: AnimatedContainer( - padding: message.metadata?["flashing"] == true - ? EdgeInsets.all(8) - : EdgeInsets.all(0), - color: message.metadata?["flashing"] == true - ? Theme.of(context).colorScheme.onSurface.withAlpha(50) - : Colors.transparent, - duration: Duration(milliseconds: 250), - child: Row( - spacing: 8, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - groupStatus?.isFirst != false - ? MessageAvatar(message, height: 40) - : SizedBox(width: 40), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, - children: [ - if (groupStatus?.isFirst != false) - Row( - spacing: 4, - children: [ - Flexible( - child: MessageDisplayname( - message, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - if (message.deliveredAt != null && - groupStatus?.isFirst != false) - Tooltip( - message: message.deliveredAt!.toString(), - child: Text( - format(message.deliveredAt!), - style: theme.textTheme.labelSmall?.copyWith( - color: Colors.grey, - ), - ), - ), - ], - ), - child, - if (error != null && error != "not sent") - Text( - error, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ReactionRow(message), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/chat_page/wrappers/reaction_row.dart b/lib/widgets/chat_page/wrappers/reaction_row.dart deleted file mode 100644 index 5e8fe86..0000000 --- a/lib/widgets/chat_page/wrappers/reaction_row.dart +++ /dev/null @@ -1,116 +0,0 @@ -import "package:cross_cache/cross_cache.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/controllers/room_chat_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/main.dart"; - -class ReactionRow extends ConsumerWidget { - final Message message; - const ReactionRow(this.message, {super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final clientState = ref.watch(ClientStateController.provider); - - return Wrap( - spacing: 4, - runSpacing: 4, - children: clientState?.homeserverUrl == null || message.reactions == null - ? [] - : message.reactions! - .mapTo( - (reaction, reactors) => HookBuilder( - builder: (context) { - final enabled = useState(true); - final selected = reactors.contains(clientState!.userId); - return Tooltip( - message: reactors.join(", "), - child: ChoiceChip( - showCheckmark: false, - selected: selected, - label: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - Flexible( - child: reaction.startsWith("mxc://") - ? Image( - height: 20, - image: CachedNetworkImage( - headers: ref.headers, - Uri.parse(reaction) - .mxcToHttps( - clientState.homeserverUrl!, - ) - .toString(), - ref.watch( - CrossCacheController.provider, - ), - ), - ) - : Text( - reaction, - overflow: TextOverflow.ellipsis, - ), - ), - Text( - reactors.length.toString(), - overflow: TextOverflow.ellipsis, - ), - ], - ), - onSelected: enabled.value - ? (value) async { - enabled.value = false; - try { - final roomId = ref.watch( - SelectedRoomController.provider.select( - (value) => value?.metadata?.id, - ), - ); - if (roomId == null || - clientState.userId == null) { - return; - } - - final controller = ref.watch( - RoomChatController.provider( - roomId, - ).notifier, - ); - - if (selected) { - await controller - .removeReaction( - reaction, - message, - clientState.userId!, - ) - .onError(showError); - } else { - await controller - .sendReaction(reaction, message) - .onError(showError); - } - } finally { - enabled.value = true; - } - } - : null, - ), - ); - }, - ), - ) - .toList(), - ); - } -} diff --git a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart b/lib/widgets/chat_page/wrappers/text_message_wrapper.dart deleted file mode 100644 index 8d7a625..0000000 --- a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart +++ /dev/null @@ -1,147 +0,0 @@ -import "package:cross_cache/cross_cache.dart"; -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_link_previewer/flutter_link_previewer.dart"; -import "package:flutter_linkify/flutter_linkify.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/controllers/url_preview_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/helpers/launch_helper.dart"; -import "package:nexus/widgets/chat_page/html/html.dart"; -import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart"; -import "package:nexus/widgets/chat_page/reply_widget.dart"; - -class TextMessageWrapper extends ConsumerWidget { - final Message message; - final String? content; - final MessageGroupStatus? groupStatus; - final Future Function(Message oldMessage, Message newMessage) - updateMessage; - final bool isSentByMe; - final Widget? extra; - final OnTapReply onTapReply; - - const TextMessageWrapper( - this.message, { - this.content, - this.onTapReply, - required this.updateMessage, - required this.groupStatus, - required this.isSentByMe, - this.extra, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final textMessage = message is TextMessage ? message as TextMessage : null; - - final link = textMessage == null - ? null - : RegExp( - r'''https?://[^\s"'<>]+''', - ).allMatches(textMessage.text).firstOrNull?.group(0); - - return MessageWrapper( - message, - ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(8)), - child: Container( - padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), - decoration: BoxDecoration( - color: isSentByMe - ? (message.id.startsWith("~") - ? colorScheme.onPrimary - : colorScheme.primaryContainer) - : colorScheme.surfaceContainer, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ReplyWidget( - message, - groupStatus: groupStatus, - onTapReply: onTapReply, - ), - if (content != null) - message.metadata?["format"] == "org.matrix.custom.html" - ? Html( - textStyle: message.metadata?["big"] == true - ? TextStyle(fontSize: 32) - : null, - content!.replaceAllMapped( - RegExp( - "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", - caseSensitive: false, - dotAll: true, - ), - (m) { - // If it's already an tag, leave it unchanged - if (m.group(1) != null) { - return m.group(1)!; - } - - // Otherwise, wrap the bare URL - final url = m.group(2)!; - return "$url"; - }, - ), - ) - : Linkify( - text: content!, - options: LinkifyOptions(humanize: false), - onOpen: (link) => ref - .watch(LaunchHelper.provider) - .launchUrl(Uri.parse(link.url)), - linkStyle: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - ), - if (textMessage?.editedAt != null) - Text("(edited)", style: theme.textTheme.labelSmall), - if (link != null) - ref - .watch(UrlPreviewController.provider(link)) - .betterWhen( - loading: SizedBox.shrink, - data: (preview) => preview == null - ? SizedBox.shrink() - : LinkPreview( - onTap: (url) => ref - .watch(LaunchHelper.provider) - .launchUrl(Uri.parse(url)), - imageBuilder: (url) => Image( - image: CachedNetworkImage( - url, - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - fit: BoxFit.cover, - errorBuilder: (_, _, _) => SizedBox.shrink(), - ), - text: link, - backgroundColor: isSentByMe - ? colorScheme.inversePrimary - : colorScheme.surfaceContainerLow, - outsidePadding: EdgeInsets.only(top: 4), - insidePadding: EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - linkPreviewData: preview, - onLinkPreviewDataFetched: (_) => null, - ), - ), - if (extra != null) extra!, - ], - ), - ), - ), - groupStatus, - ); - } -} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart new file mode 100644 index 0000000..618d9ee --- /dev/null +++ b/lib/widgets/composer/composer.dart @@ -0,0 +1,193 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:fluttertagger/fluttertagger.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/power_level_controller.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/relation_type.dart"; +import "package:nexus/widgets/composer/mention_overlay.dart"; +import "package:nexus/widgets/composer/relation_preview.dart"; +import "package:nexus/widgets/emoji_picker_button.dart"; + +class Composer extends HookConsumerWidget { + final String roomId; + final Event? relatedEvent; + final RelationType relationType; + final VoidCallback onDismiss; + final FocusNode? node; + final Future Function( + String text, { + required bool shouldMention, + required IList tags, + }) + onSend; + const Composer( + this.roomId, { + required this.relatedEvent, + required this.relationType, + required this.onDismiss, + required this.onSend, + this.node, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final controller = useRef(FlutterTaggerController()); + final triggerCharacter = useState(""); + final shouldMention = useState(true); + final query = useState(""); + + if (relationType == .edit && controller.value.text.isEmpty) { + controller.value.text = relatedEvent?.localContent?.editSource ?? ""; + } + + void send() { + if (controller.value.text.isEmpty) return; + onSend( + controller.value.formattedText, + shouldMention: shouldMention.value, + tags: .new(controller.value.tags), + ); + + onDismiss(); + controller.value.text = ""; + } + + final style = TextStyle( + color: theme.colorScheme.primary, + fontWeight: .bold, + ); + + return Padding( + padding: .all(12), + child: ClipRRect( + borderRadius: .all(.circular(12)), + child: Column( + children: [ + RelationPreview( + relatedEvent, + shouldMention: shouldMention.value, + toggleShouldMention: () => + shouldMention.value = !shouldMention.value, + relationType: relationType, + onDismiss: onDismiss, + ), + Container( + color: theme.colorScheme.surfaceContainerHighest, + padding: .symmetric(horizontal: 8), + child: Row( + spacing: 8, + mainAxisAlignment: .center, + children: + ref.watch( + PowerLevelController.provider( + .new(eventType: .message, roomId: roomId), + ), + ) + ? [ + EmojiPickerButton( + context: context, + onSelection: (_) => node?.requestFocus(), + controller: controller.value, + ), + PopupMenuButton( + tooltip: "Add media", + itemBuilder: (context) => [ + PopupMenuItem( + child: ListTile( + title: Text("Camera"), + leading: Icon(Icons.add_a_photo), + ), + ), + PopupMenuItem( + child: ListTile( + title: Text("Gallery"), + leading: Icon(Icons.add_photo_alternate), + ), + ), + PopupMenuItem( + child: ListTile( + title: Text("Files"), + leading: Icon(Icons.attachment), + ), + ), + ], + icon: Icon(Icons.add), + ), + Expanded( + child: FlutterTagger( + triggerStrategy: .eager, + overlay: MentionOverlay( + roomId, + query: query.value, + triggerCharacter: triggerCharacter.value, + addTag: ({required id, required name}) { + controller.value.addTag(id: id, name: name); + node?.requestFocus(); + }, + ), + controller: controller.value, + onSearch: (newQuery, newTriggerCharacter) { + triggerCharacter.value = newTriggerCharacter; + query.value = newQuery; + }, + triggerCharacterAndStyles: {"@": style, "#": style}, + builder: (context, key) => Focus( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && + event.logicalKey == + LogicalKeyboardKey.enter) { + final shiftPressed = + HardwareKeyboard.instance.isShiftPressed; + + if (!shiftPressed) { + send(); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + }, + child: TextField( + maxLines: 12, + minLines: 1, + autofocus: true, + decoration: .new( + hintText: "Your message here...", + border: .none, + ), + controller: controller.value, + key: key, + focusNode: node, + ), + ), + ), + ), + IconButton( + onPressed: send, + icon: Icon(Icons.send), + tooltip: "Send message", + ), + ] + : [ + Expanded( + child: Padding( + padding: .symmetric(horizontal: 8, vertical: 12), + child: Text( + "You don't have permission to send messages in this room...", + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/chat_page/composer/mention_overlay.dart b/lib/widgets/composer/mention_overlay.dart similarity index 59% rename from lib/widgets/chat_page/composer/mention_overlay.dart rename to lib/widgets/composer/mention_overlay.dart index b650421..ea5dc6a 100644 --- a/lib/widgets/chat_page/composer/mention_overlay.dart +++ b/lib/widgets/composer/mention_overlay.dart @@ -1,18 +1,21 @@ import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/members_by_type_controller.dart"; +import "package:nexus/controllers/members_by_status_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/via_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/membership_status.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/models/content/membership.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 String roomId; final void Function({required String id, required String name}) addTag; - const MentionOverlay({ + const MentionOverlay( + this.roomId, { required this.query, required this.addTag, required this.triggerCharacter, @@ -24,17 +27,19 @@ class MentionOverlay extends ConsumerWidget { final rooms = ref.watch(RoomsController.provider); return Padding( - padding: EdgeInsets.all(8), + padding: .all(8), child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), + borderRadius: .all(.circular(12)), child: Container( color: Theme.of(context).colorScheme.surfaceContainerHigh, - padding: EdgeInsets.all(8), + padding: .all(8), child: switch (triggerCharacter) { "@" => ref .watch( - MembersByTypeController.provider(MembershipStatus.join), + MembersByStatusController.provider( + .new(roomId: roomId, status: .join), + ), ) .betterWhen( data: (members) => ListView( @@ -43,33 +48,49 @@ class MentionOverlay extends ConsumerWidget { ? members : members.where( (member) => - member.userId.toLowerCase().contains( - query.toLowerCase(), - ) == - true || - member.displayName - .toLowerCase() + member.stateKey + ?.toLowerCase() .contains( query.toLowerCase(), ) == - true, + true || + switch (member.content) { + MembershipContent( + :final displayName, + ) => + displayName + ?.toLowerCase() + .contains( + query.toLowerCase(), + ) == + true, + _ => false, + }, )) .map( - (member) => ListTile( - leading: AvatarOrHash( - member.avatarUrl, - member.displayName, - ), - title: Text(member.displayName), - subtitle: Text(member.userId), - onTap: () => addTag( - id: "[@${member.displayName}](matrix:u/${member.userId.substring(1)})", - name: member.userId - .substring(1) - .split(":") - .first, - ), - ), + (member) => switch (member.content) { + MembershipContent( + :final displayName, + :final avatarUrl, + ) => + ListTile( + leading: AvatarOrHash( + avatarUrl, + displayName ?? + member.stateKey!.localpart, + ), + title: Text( + displayName ?? + member.stateKey!.localpart, + ), + subtitle: Text(member.stateKey!), + onTap: () => addTag( + id: "[@$displayName](matrix:u/${member.stateKey!.substring(1)})", + name: member.stateKey!.localpart, + ), + ), + _ => SizedBox.shrink(), + }, ) .toList(), ), diff --git a/lib/widgets/composer/relation_preview.dart b/lib/widgets/composer/relation_preview.dart new file mode 100644 index 0000000..c9cc271 --- /dev/null +++ b/lib/widgets/composer/relation_preview.dart @@ -0,0 +1,66 @@ +import "package:flutter/material.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/relation_type.dart"; +import "package:nexus/widgets/event_preview.dart"; + +class RelationPreview extends ConsumerWidget { + final Event? relatedEvent; + final RelationType relationType; + final VoidCallback onDismiss; + final bool shouldMention; + final VoidCallback toggleShouldMention; + + const RelationPreview( + this.relatedEvent, { + required this.relationType, + required this.onDismiss, + required this.shouldMention, + required this.toggleShouldMention, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (relatedEvent == null) return SizedBox.shrink(); + final theme = Theme.of(context); + + return Container( + color: theme.colorScheme.surfaceContainerHigh, + padding: .symmetric(horizontal: 12), + child: Row( + spacing: 8, + children: [ + if (relationType == .edit) + Text("Editing message:", style: .new(fontWeight: .bold)), + + Expanded( + child: Padding( + padding: .symmetric(vertical: 8), + child: EventPreview(relatedEvent!), + ), + ), + + if (relationType == .reply) + TextButton( + onPressed: toggleShouldMention, + child: Text( + shouldMention ? "@On" : "@Off", + style: TextStyle( + fontWeight: .w900, + color: shouldMention ? null : Theme.of(context).disabledColor, + ), + ), + ), + + IconButton( + tooltip: "Cancel ${relationType == .edit ? "edit" : "reply"}", + onPressed: onDismiss, + icon: const Icon(Icons.close), + iconSize: 20, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/divider_text.dart b/lib/widgets/divider_text.dart index ca78844..2b0f9bd 100644 --- a/lib/widgets/divider_text.dart +++ b/lib/widgets/divider_text.dart @@ -1,4 +1,5 @@ import "package:flutter/material.dart"; +import "package:nexus/widgets/divider_widget.dart"; class DividerText extends StatelessWidget { final String text; @@ -6,24 +7,6 @@ class DividerText extends StatelessWidget { const DividerText(this.text, {super.key}); @override - Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) => Row( - children: [ - SizedBox( - width: 16, - child: Divider(color: Theme.of(context).colorScheme.onSurface), - ), - ConstrainedBox( - constraints: BoxConstraints(maxWidth: constraints.maxWidth - 32), - child: Padding( - padding: const EdgeInsets.all(8), - child: Text(text, style: Theme.of(context).textTheme.labelLarge), - ), - ), - Expanded( - child: Divider(color: Theme.of(context).colorScheme.onSurface), - ), - ], - ), - ); + Widget build(BuildContext context) => + DividerWidget(Text(text, style: Theme.of(context).textTheme.labelLarge)); } diff --git a/lib/widgets/divider_widget.dart b/lib/widgets/divider_widget.dart new file mode 100644 index 0000000..6f13bd4 --- /dev/null +++ b/lib/widgets/divider_widget.dart @@ -0,0 +1,25 @@ +import "package:flutter/material.dart"; + +class DividerWidget extends StatelessWidget { + final Widget widget; + const DividerWidget(this.widget, {super.key}); + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (_, constraints) => Row( + children: [ + SizedBox( + width: 16, + child: Divider(color: Theme.of(context).colorScheme.onSurface), + ), + ConstrainedBox( + constraints: .new(maxWidth: constraints.maxWidth - 32), + child: Padding(padding: const .all(8), child: widget), + ), + Expanded( + child: Divider(color: Theme.of(context).colorScheme.onSurface), + ), + ], + ), + ); +} diff --git a/lib/widgets/emoji_picker_button.dart b/lib/widgets/emoji_picker_button.dart new file mode 100644 index 0000000..bbe1cdc --- /dev/null +++ b/lib/widgets/emoji_picker_button.dart @@ -0,0 +1,52 @@ +import "package:emoji_text_field/emoji_text_field.dart"; +import "package:flutter/material.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/emoji_controller.dart"; + +class EmojiPickerButton extends HookConsumerWidget { + 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(_, WidgetRef ref) => IconButton( + onPressed: () async { + onPressed?.call(); + final controller = this.controller ?? .new(); + + final emojis = await ref.watch(EmojiController.provider.future); + if (context.mounted) { + showModalBottomSheet( + context: context, + builder: (context) => EmojiKeyboardView( + config: .new( + showRecentTab: false, + customCategories: emojis.$1.unlock, + customKeywords: emojis.$2.unlock, + backgroundColor: Theme.of(context).colorScheme.surfaceContainer, + height: 600, + ), + textController: controller + ..addListener(() { + // Without this, there will sometimes be a debugLocked is not true error sometimes + // It might be preferable to use a microtask instead of a `Future.delayed`. + Future.delayed(.zero, () { + if (context.mounted) Navigator.of(context).pop(); + }); + onSelection?.call(controller.text); + }), + ), + ); + } + }, + icon: Icon(Icons.emoji_emotions), + ); +} diff --git a/lib/widgets/error_dialog.dart b/lib/widgets/error_dialog.dart index b016a8b..9b62200 100644 --- a/lib/widgets/error_dialog.dart +++ b/lib/widgets/error_dialog.dart @@ -21,11 +21,12 @@ class ErrorDialog extends ConsumerWidget { onPressed: () => ref.invalidate(provider!), child: const Text("Try Again"), ), - TextButton( - onPressed: () => - Navigator.of(context).popUntil((route) => route.isFirst), - child: const Text("Go Back"), - ), + if (Navigator.of(context).canPop()) + TextButton( + onPressed: () => + Navigator.of(context).popUntil((route) => route.isFirst), + child: const Text("Go Back"), + ), ], ); } diff --git a/lib/widgets/event_preview.dart b/lib/widgets/event_preview.dart new file mode 100644 index 0000000..7a40a75 --- /dev/null +++ b/lib/widgets/event_preview.dart @@ -0,0 +1,37 @@ +import "package:flutter/material.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/lazy_loading/message_avatar.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/renderers/event.dart"; + +class EventPreview extends StatelessWidget { + final Event event; + const EventPreview(this.event, {super.key}); + + @override + Widget build(BuildContext context) => IgnorePointer( + child: Padding( + padding: .symmetric(vertical: 4), + child: Row( + mainAxisSize: .min, + spacing: 12, + children: [ + if (event.content is MessageContent) MessageAvatar(event), + + Flexible( + child: Wrap( + crossAxisAlignment: .center, + spacing: 8, + runSpacing: 2, + children: [ + if (event.content is MessageContent) MessageDisplayname(event), + EventRenderer(event, textOnly: true, maxLines: 1), + ], + ), + ), + ], + ), + ), + ); +} diff --git a/lib/widgets/expandable_image.dart b/lib/widgets/expandable_image.dart new file mode 100644 index 0000000..fdfabff --- /dev/null +++ b/lib/widgets/expandable_image.dart @@ -0,0 +1,58 @@ +import "package:cross_cache/cross_cache.dart"; +import "package:flutter/material.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:m3e_buttons/m3e_buttons.dart"; +import "package:nexus/controllers/cross_cache_controller.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/widgets/error_dialog.dart"; + +class ExpandableImage extends ConsumerWidget { + final Widget child; + final String? source; + const ExpandableImage(this.source, {required this.child, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) => InkWell( + onTap: source == null + ? null + : () => showDialog( + context: context, + builder: (_) => SafeArea( + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: Navigator.of(context).pop, + child: InteractiveViewer( + maxScale: 10, + child: Image( + errorBuilder: (_, error, stackTrace) => ErrorDialog( + "Loading failed for $source\nError: $error", + stackTrace, + ), + image: CachedNetworkImage( + source!, + ref.watch(CrossCacheController.provider), + headers: ref.headers, + ), + ), + ), + ), + ), + Align( + alignment: .topRight, + child: Padding( + padding: .all(32), + child: M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), + ), + ), + ), + ], + ), + ), + ), + child: child, + ); +} diff --git a/lib/widgets/file_card.dart b/lib/widgets/file_card.dart new file mode 100644 index 0000000..afdad89 --- /dev/null +++ b/lib/widgets/file_card.dart @@ -0,0 +1,25 @@ +import "package:flutter/material.dart"; +import "package:nexus/helpers/extensions/size_to_string.dart"; +import "package:nexus/models/info/file.dart"; + +class FileCard extends StatelessWidget { + final Uri uri; + final FileInfo? info; + final String? filename; + const FileCard(this.uri, this.info, {this.filename, super.key}); + + @override + Widget build(BuildContext context) => SizedBox( + width: 320, + child: Card( + color: Theme.of(context).colorScheme.surfaceContainer, + child: ListTile( + leading: Icon(Icons.file_copy), + title: Text(filename ?? "file", maxLines: 1, overflow: .ellipsis), + subtitle: info?.size == null ? null : Text(info!.size!.sizeAsString), + // TODO: Downloading files + trailing: IconButton(onPressed: null, icon: Icon(Icons.download)), + ), + ), + ); +} diff --git a/lib/widgets/form_text_input.dart b/lib/widgets/form_text_input.dart deleted file mode 100644 index 21b2e5c..0000000 --- a/lib/widgets/form_text_input.dart +++ /dev/null @@ -1,83 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter/services.dart"; - -class FormTextInput extends StatelessWidget { - final List extraValidators; - final TextEditingController? controller; - final TextInputType keyboardType; - final String? initialValue; - final bool readOnly; - final bool obscure; - final String? title; - final int? minLines; - final int? maxLength; - final bool outlined; - final int? maxLines; - final bool capitalize; - final bool required; - final bool autocorrect; - final void Function()? onTap; - final Widget? trailing; - final InputBorder? border; - final List? formatters; - final bool autofocus; - - const FormTextInput({ - super.key, - this.border, - this.controller, - this.autofocus = false, - this.title, - this.obscure = false, - this.readOnly = false, - this.extraValidators = const [], - this.keyboardType = TextInputType.text, - this.initialValue, - this.minLines, - this.capitalize = false, - this.maxLength, - this.formatters, - this.maxLines = 1, - this.outlined = true, - this.trailing, - this.onTap, - this.autocorrect = true, - this.required = true, - }); - - @override - Widget build(BuildContext context) => TextFormField( - autofocus: autofocus, - controller: controller, - keyboardType: keyboardType, - readOnly: readOnly, - minLines: minLines, - maxLines: maxLines, - maxLength: maxLength, - inputFormatters: formatters, - textCapitalization: capitalize - ? TextCapitalization.sentences - : TextCapitalization.none, - initialValue: initialValue, - autocorrect: autocorrect, - obscureText: obscure, - onTap: onTap, - decoration: InputDecoration( - labelText: title, - border: border ?? (outlined ? null : const UnderlineInputBorder()), - suffixIcon: trailing, - ), - validator: (value) { - if ((value?.isEmpty ?? true) && required) { - return "This field is required"; - } - - for (final validator in extraValidators) { - final reason = validator(value!); - if (reason != null) return reason; - } - - return null; - }, - ); -} diff --git a/lib/widgets/highlight_wrapper.dart b/lib/widgets/highlight_wrapper.dart new file mode 100644 index 0000000..920db9e --- /dev/null +++ b/lib/widgets/highlight_wrapper.dart @@ -0,0 +1,20 @@ +import "package:flutter/material.dart"; + +class HighlightWrapper extends StatelessWidget { + final Widget child; + final bool isHighlighted; + const HighlightWrapper(this.child, {this.isHighlighted = false, super.key}); + + @override + Widget build(BuildContext context) => ClipRRect( + borderRadius: .all(.circular(12)), + child: AnimatedContainer( + padding: isHighlighted ? .all(8) : .all(0), + color: isHighlighted + ? Theme.of(context).colorScheme.onSurface.withAlpha(50) + : Colors.transparent, + duration: .new(milliseconds: 250), + child: child, + ), + ); +} diff --git a/lib/widgets/chat_page/html/code_block.dart b/lib/widgets/html/code_block.dart similarity index 75% rename from lib/widgets/chat_page/html/code_block.dart rename to lib/widgets/html/code_block.dart index 80950ce..a5c3dee 100644 --- a/lib/widgets/chat_page/html/code_block.dart +++ b/lib/widgets/html/code_block.dart @@ -11,20 +11,20 @@ class CodeBlock extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); return ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(16)), + borderRadius: .all(.circular(16)), child: ColoredBox( color: theme.colorScheme.surfaceContainerHighest, child: IntrinsicWidth( child: Column( children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: .spaceBetween, children: [ Padding( - padding: EdgeInsets.symmetric(horizontal: 8), + padding: .symmetric(horizontal: 8), child: Text( lang.substring(0, min(lang.length, 15)), - style: TextStyle(fontFamily: "monospace"), + style: .new(fontFamily: "monospace"), ), ), TextButton.icon( @@ -37,13 +37,13 @@ class CodeBlock extends StatelessWidget { ColoredBox( color: theme.colorScheme.surfaceContainerHigh, child: Container( - constraints: BoxConstraints(minWidth: 250), - padding: EdgeInsets.all(8), + constraints: .new(minWidth: 250), + padding: .all(8), child: SelectableText( code, minLines: 1, maxLines: 99, - style: TextStyle(fontFamily: "monospace"), + style: .new(fontFamily: "monospace"), ), ), ), diff --git a/lib/widgets/chat_page/html/html.dart b/lib/widgets/html/html.dart similarity index 84% rename from lib/widgets/chat_page/html/html.dart rename to lib/widgets/html/html.dart index fb533ad..85ec9ae 100644 --- a/lib/widgets/chat_page/html/html.dart +++ b/lib/widgets/html/html.dart @@ -9,20 +9,22 @@ import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/link_to_mention.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/helpers/launch_helper.dart"; -import "package:nexus/widgets/chat_page/expandable_image.dart"; -import "package:nexus/widgets/chat_page/html/mention_chip.dart"; -import "package:nexus/widgets/chat_page/html/spoiler_text.dart"; -import "package:nexus/widgets/chat_page/html/code_block.dart"; -import "package:nexus/widgets/chat_page/html/quoted.dart"; +import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/html/mention_chip.dart"; +import "package:nexus/widgets/html/spoiler_text.dart"; +import "package:nexus/widgets/html/code_block.dart"; +import "package:nexus/widgets/html/quoted.dart"; class Html extends ConsumerWidget { final String html; + final String? roomId; final TextStyle? textStyle; - const Html(this.html, {this.textStyle, super.key}); + const Html(this.html, {this.roomId, this.textStyle, super.key}); @override Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( html, + buildAsync: false, textStyle: textStyle, customWidgetBuilder: (element) { if (element.attributes.keys.contains("data-mx-profile-fallback")) { @@ -58,13 +60,15 @@ class Html extends ConsumerWidget { ) : null, - "blockquote" => Quoted(Html(element.innerHtml)), + "blockquote" => Quoted( + Html(element.innerHtml, textStyle: textStyle, roomId: roomId), + ), "a" => element.attributes["href"]?.mention == null ? null : InlineCustomWidget( - child: MentionChip(element.attributes["href"]!), + child: MentionChip(element.attributes["href"]!, roomId), ), "img" => @@ -82,9 +86,7 @@ class Html extends ConsumerWidget { ), errorBuilder: (_, error, _) => Text( "Image Failed to Load", - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), + style: .new(color: Theme.of(context).colorScheme.error), ), height: height.toDouble(), width: width?.toDouble(), @@ -142,15 +144,14 @@ class Html extends ConsumerWidget { element.attributes .mapTo?>( (key, value) => switch (key) { - "data-mx-color" => MapEntry("color", value), - "data-mx-bg-color" => MapEntry("background-color", value), + "data-mx-color" => .new("color", value), + "data-mx-bg-color" => .new("background-color", value), _ => null, }, ) .nonNulls, ), }, - onTapUrl: (url) => - ref.watch(LaunchHelper.provider).launchUrl(Uri.parse(url)), + onTapUrl: (url) => ref.watch(LaunchHelper.provider).launchUrl(.parse(url)), ); } diff --git a/lib/widgets/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart new file mode 100644 index 0000000..8e0ce11 --- /dev/null +++ b/lib/widgets/html/mention_chip.dart @@ -0,0 +1,48 @@ +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/user_controller.dart"; +import "package:nexus/helpers/extensions/link_to_mention.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; + +class MentionChip extends ConsumerWidget { + final String? roomId; + final String content; + const MentionChip(this.content, this.roomId, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mention = content.mention; + final membership = mention?.startsWith("@") == true + ? ref + .watch( + UserController.provider(.new(roomId: roomId, userId: mention!)), + ) + .whenOrNull(data: (data) => data) + : null; + + return mention == null + ? SizedBox.shrink() + : InkWell( + onTap: () { + if (membership != null) { + context.showUserPopover(membership, mention, roomId: roomId); + } + }, + child: IgnorePointer( + child: Chip( + label: Text( + (membership?.displayName == null + ? null + : "@${membership!.displayName}") ?? + mention, + style: .new( + fontWeight: .bold, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + backgroundColor: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} diff --git a/lib/widgets/chat_page/html/quoted.dart b/lib/widgets/html/quoted.dart similarity index 66% rename from lib/widgets/chat_page/html/quoted.dart rename to lib/widgets/html/quoted.dart index 6640118..e582b06 100644 --- a/lib/widgets/chat_page/html/quoted.dart +++ b/lib/widgets/html/quoted.dart @@ -8,9 +8,9 @@ class Quoted extends StatelessWidget { Widget build(BuildContext context) => Container( decoration: BoxDecoration( border: Border( - left: BorderSide(width: 4, color: Theme.of(context).dividerColor), + left: .new(width: 4, color: Theme.of(context).dividerColor), ), ), - child: Padding(padding: EdgeInsets.only(left: 8), child: child), + child: Padding(padding: .only(left: 8), child: child), ); } diff --git a/lib/widgets/chat_page/html/spoiler_text.dart b/lib/widgets/html/spoiler_text.dart similarity index 69% rename from lib/widgets/chat_page/html/spoiler_text.dart rename to lib/widgets/html/spoiler_text.dart index 9a42bff..a7a457b 100644 --- a/lib/widgets/chat_page/html/spoiler_text.dart +++ b/lib/widgets/html/spoiler_text.dart @@ -13,15 +13,15 @@ class SpoilerText extends HookWidget { return InkWell( onTap: () => revealed.value = !revealed.value, child: AnimatedContainer( - duration: const Duration(milliseconds: 100), - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + duration: const .new(milliseconds: 100), + padding: const .symmetric(horizontal: 4, vertical: 2), decoration: BoxDecoration( color: revealed.value ? Colors.transparent : Colors.blueGrey, - borderRadius: BorderRadius.circular(4), + borderRadius: .circular(4), ), child: Text( text, - style: TextStyle(color: revealed.value ? null : Colors.transparent), + style: .new(color: revealed.value ? null : Colors.transparent), ), ), ); diff --git a/lib/widgets/chat_page/join_dialog.dart b/lib/widgets/join_dialog.dart similarity index 82% rename from lib/widgets/chat_page/join_dialog.dart rename to lib/widgets/join_dialog.dart index e718200..d420dea 100644 --- a/lib/widgets/chat_page/join_dialog.dart +++ b/lib/widgets/join_dialog.dart @@ -1,5 +1,4 @@ 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"; @@ -7,8 +6,6 @@ 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; @@ -20,16 +17,14 @@ class JoinDialog extends HookWidget { return AlertDialog( title: Text("Join a Room"), content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ Text("Enter the room alias, Matrix URI, or Matrix.to link."), SizedBox(height: 12), - FormTextInput( - required: false, - capitalize: true, + TextField( controller: roomAlias, - title: "#room:server", + decoration: .new(hintText: "#room:server"), ), ], ), @@ -45,7 +40,7 @@ class JoinDialog extends HookWidget { final scaffoldMessenger = ScaffoldMessenger.of(context); final snackbar = scaffoldMessenger.showSnackBar( - SnackBar( + .new( content: Text("Joining room $roomIdOrAlias."), duration: Duration(days: 999), ), @@ -55,9 +50,9 @@ class JoinDialog extends HookWidget { final id = await ref .watch(ClientController.provider.notifier) .joinRoom( - JoinRoomRequest( + .new( roomIdOrAlias: roomIdOrAlias, - via: IList( + via: .new( Uri.tryParse( roomAlias.text.replaceAll("/#", ""), )?.queryParametersAll["via"] ?? @@ -69,9 +64,9 @@ class JoinDialog extends HookWidget { snackbar.close(); scaffoldMessenger.showSnackBar( - SnackBar( + .new( content: Text("Room $roomIdOrAlias successfully joined."), - action: SnackBarAction( + action: .new( label: "Open", onPressed: () async { final spaces = ref.watch(SpacesController.provider); @@ -89,9 +84,15 @@ class JoinDialog extends HookWidget { space?.id ?? spaces .firstWhere( - (space) => space.children.any( - (child) => child.metadata?.id == id, - ), + (space) => + space.children.any( + (child) => + child.metadata?.id == id, + ) || + space.subSpaces.any( + (child) => + child.room.metadata?.id == id, + ), ) .id, ); @@ -113,13 +114,13 @@ class JoinDialog extends HookWidget { snackbar.close(); if (context.mounted) { scaffoldMessenger.showSnackBar( - SnackBar( + .new( backgroundColor: Theme.of( context, ).colorScheme.errorContainer, content: Text( error.toString(), - style: TextStyle( + style: .new( color: Theme.of(context).colorScheme.onErrorContainer, ), ), diff --git a/lib/widgets/lazy_loading/message_avatar.dart b/lib/widgets/lazy_loading/message_avatar.dart new file mode 100644 index 0000000..b65e377 --- /dev/null +++ b/lib/widgets/lazy_loading/message_avatar.dart @@ -0,0 +1,29 @@ +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/author_controller.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; + +class MessageAvatar extends ConsumerWidget { + final Event event; + final double height; + const MessageAvatar(this.event, {this.height = 24, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch( + AuthorController.provider(event), + )) { + AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( + onTap: () => + context.showUserPopover(value, event.sender, roomId: event.roomId), + child: AvatarOrHash( + value.avatarUrl, + value.displayName ?? event.sender.localpart, + height: height, + ), + ), + _ => AvatarOrHash(null, event.sender.localpart, height: height), + }; +} diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart new file mode 100644 index 0000000..a817122 --- /dev/null +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -0,0 +1,62 @@ +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/author_controller.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/helpers/extensions/string_to_color.dart"; +import "package:nexus/models/event.dart"; + +class MessageDisplayname extends ConsumerWidget { + final Event event; + final TextStyle? style; + final bool clickable; + const MessageDisplayname( + this.event, { + this.clickable = true, + this.style, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch( + AuthorController.provider(event), + )) { + AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( + onTap: clickable + ? () => context.showUserPopover( + value, + event.sender, + roomId: event.roomId, + ) + : null, + child: Wrap( + spacing: 4, + crossAxisAlignment: .center, + children: [ + Text( + value.displayName ?? event.sender.localpart, + style: + style ?? .new(color: event.sender.colorHash, fontWeight: .bold), + maxLines: 1, + overflow: .ellipsis, + ), + + if (event.pmp != null) + Text( + "(via ${event.sender})", + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: event.sender.colorHash, + fontWeight: .bold, + ), + maxLines: 1, + overflow: .ellipsis, + ), + ], + ), + ), + _ => Text( + event.sender.localpart, + style: .new(color: event.sender.colorHash, fontWeight: .bold), + ), + }; +} diff --git a/lib/widgets/linkified_text.dart b/lib/widgets/linkified_text.dart new file mode 100644 index 0000000..653248c --- /dev/null +++ b/lib/widgets/linkified_text.dart @@ -0,0 +1,23 @@ +import "package:flutter/material.dart"; +import "package:flutter_linkify/flutter_linkify.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/helpers/launch_helper.dart"; + +class LinkifiedText extends ConsumerWidget { + final String text; + final int? maxLines; + final TextStyle? style; + const LinkifiedText(this.text, {this.maxLines, this.style, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) => Linkify( + text: text, + maxLines: maxLines, + style: style, + options: .new(humanize: false), + onOpen: (link) => + ref.watch(LaunchHelper.provider).launchUrl(.parse(link.url)), + linkStyle: .new(color: Theme.of(context).colorScheme.primary), + overflow: maxLines == null ? null : .ellipsis, + ); +} diff --git a/lib/widgets/loading.dart b/lib/widgets/loading.dart index 9bb2858..fc84563 100644 --- a/lib/widgets/loading.dart +++ b/lib/widgets/loading.dart @@ -7,7 +7,7 @@ class Loading extends StatelessWidget { @override Widget build(BuildContext context) => Center( child: Padding( - padding: EdgeInsets.all(16), + padding: .all(16), child: SizedBox(height: height, child: CircularProgressIndicator()), ), ); diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart new file mode 100644 index 0000000..6326421 --- /dev/null +++ b/lib/widgets/member_list.dart @@ -0,0 +1,185 @@ +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:m3e_buttons/m3e_buttons.dart"; +import "package:m3e_card_list/m3e_card_list.dart"; +import "package:nexus/controllers/members_by_status_controller.dart"; +import "package:nexus/controllers/members_grouped_controller.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/string_to_color.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/membership_status.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/divider_text.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/user_bottom_sheet.dart"; + +class MemberList extends HookConsumerWidget { + final String roomId; + const MemberList(this.roomId, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final statusIndex = useState(0); + + final options = { + "Joined": .join, + "Invited": .invite, + "Banned": .ban, + }; + final status = options.values.toIList()[statusIndex.value]; + + return Drawer( + shape: Border(), + child: Column( + children: [ + if (Scaffold.of(context).hasEndDrawer) + AppBar( + scrolledUnderElevation: 0, + leading: Icon(Icons.people), + title: Text("Members"), + actionsPadding: .only(right: 4), + actions: [ + IconButton( + onPressed: Scaffold.of(context).closeEndDrawer, + icon: Icon(Icons.close), + tooltip: "Close member list", + ), + ], + ), + Padding( + padding: .symmetric(vertical: 8), + child: M3EToggleButtonGroup( + selectedIndex: statusIndex.value, + onSelectedIndexChanged: (index) => + statusIndex.value = index ?? statusIndex.value, + actions: options + .mapTo( + (name, value) => M3EToggleButtonGroupAction( + checkedLabel: Text( + "$name${switch (ref.watch(MembersByStatusController.provider(.new(roomId: roomId, status: value)))) { + AsyncData(:final value) || AsyncLoading(:final value?) => " (${value.length})", + _ => "", + }}", + ), + label: Text(name), + ), + ) + .toList(), + ), + ), + + switch (ref.watch( + MembersGroupedController.provider( + .new(roomId: roomId, status: status), + ), + )) { + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + AsyncData(:final value) || AsyncLoading(:final value?) => + value.isEmpty + ? Center( + child: Padding( + padding: .symmetric(vertical: 18), + child: Text( + "No ${options.keys.toIList()[statusIndex.value]} Members", + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ) + : Expanded( + child: CustomScrollView( + slivers: [ + for (final MapEntry(key: powerLevel, value: members) + in value) ...[ + SliverToBoxAdapter( + child: Padding( + padding: .symmetric(horizontal: 16), + child: DividerText( + powerLevel == null + ? "Creators" + : "Power Level $powerLevel", + ), + ), + ), + SliverM3ECardList( + padding: .all(4), + color: Theme.of( + context, + ).colorScheme.surfaceContainerHigh, + margin: .symmetric(horizontal: 12, vertical: 4), + itemCount: members.length, + itemBuilder: (context, index) => + switch (members[index].content) { + MembershipContent( + :final avatarUrl, + :final displayName, + ) => + ListTile( + title: Text( + displayName ?? + members[index] + .stateKey! + .localpart, + overflow: .ellipsis, + style: .new( + color: members[index] + .stateKey! + .colorHash, + fontWeight: .bold, + ), + ), + subtitle: Text( + members[index].stateKey!, + overflow: .ellipsis, + ), + leading: AvatarOrHash( + avatarUrl, + height: 36, + displayName ?? + members[index] + .stateKey! + .localpart, + ), + ), + _ => throw Exception( + "Member content was not MembershipContent", + ), + }, + onTap: (index) { + final member = members[index]; + if (member.content + case MembershipContent content) { + showModalBottomSheet( + constraints: BoxConstraints.loose( + Size( + 500, + (context.size?.height ?? 1000) - 80, + ), + ), + isScrollControlled: true, + context: context, + builder: (context) => UserBottomSheet( + content, + member.stateKey!, + roomId: roomId, + ), + ); + } + }, + ), + ], + ], + ), + ), + AsyncLoading _ => Loading(), + }, + ], + ), + ); + } +} diff --git a/lib/widgets/message_image.dart b/lib/widgets/message_image.dart new file mode 100644 index 0000000..8865a5c --- /dev/null +++ b/lib/widgets/message_image.dart @@ -0,0 +1,57 @@ +import "package:cross_cache/cross_cache.dart"; +import "package:flutter/material.dart"; +import "package:flutter_blurhash/flutter_blurhash.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/cross_cache_controller.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/models/info/image.dart" as i; +import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/loading.dart"; + +class MessageImage extends ConsumerWidget { + final Uri url; + final i.ImageInfo? info; + const MessageImage(this.url, {this.info, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) => ExpandableImage( + url.toString(), + child: ClipRRect( + borderRadius: .all(.circular(8)), + child: AspectRatio( + aspectRatio: info!.width! / info!.height!, + child: Image( + image: CachedNetworkImage( + url.toString(), + ref.watch(CrossCacheController.provider), + headers: ref.headers, + ), + width: info?.width, + fit: BoxFit.fitWidth, + loadingBuilder: (_, child, loadingProgress) => loadingProgress == null + ? child + : switch (info?.blurHash) { + final blurHash? => + info?.width == null || info?.height == null + ? SizedBox( + width: 200, + height: 200, + child: BlurHash(hash: blurHash), + ) + : SizedBox( + width: info!.width, + child: BlurHash(hash: blurHash), + ), + _ => Loading(), + }, + errorBuilder: (context, error, stackTrace) => Center( + child: Text( + "Image Failed to Load", + style: .new(color: Theme.of(context).colorScheme.error), + ), + ), + ), + ), + ), + ); +} diff --git a/lib/widgets/players/audio.dart b/lib/widgets/players/audio.dart new file mode 100644 index 0000000..0c96579 --- /dev/null +++ b/lib/widgets/players/audio.dart @@ -0,0 +1,102 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:media_kit/media_kit.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/models/info/audio.dart"; + +class AudioPlayer extends HookConsumerWidget { + final Uri url; + final AudioInfo? info; + + const AudioPlayer(this.url, this.info, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = useMemoized( + () => Player(configuration: .new(bufferSize: 128 * 1024 * 1024)), + ); + + final playing = useState(false); + final position = useState(Duration.zero); + final duration = useState(Duration.zero); + + useEffect(() { + scheduleMicrotask(() async { + await player.open( + Media(url.toString(), httpHeaders: ref.headers), + play: false, + ); + + player.stream.playing.listen((value) { + playing.value = value; + }); + + player.stream.position.listen((value) { + position.value = value; + }); + + player.stream.duration.listen((value) { + duration.value = value; + }); + }); + + return player.dispose; + }, []); + + String format(Duration duration) { + final minutes = duration.inMinutes + .remainder(60) + .toString() + .padLeft(2, "0"); + final seconds = duration.inSeconds + .remainder(60) + .toString() + .padLeft(2, "0"); + + return "$minutes:$seconds"; + } + + return SizedBox( + height: 60, + child: Card( + color: Theme.of(context).colorScheme.surfaceContainer, + child: Padding( + padding: .only(left: 8, right: 16), + child: Row( + children: [ + IconButton( + onPressed: player.playOrPause, + icon: Icon( + playing.value ? Icons.pause_circle : Icons.play_circle, + ), + ), + SizedBox(width: 8), + Text( + format(position.value), + style: Theme.of(context).textTheme.bodySmall, + ), + Expanded( + child: Slider( + min: 0, + max: duration.value.inMilliseconds <= 0 + ? 1 + : duration.value.inMilliseconds.toDouble(), + value: position.value.inMilliseconds.toDouble(), + onChanged: (value) => + player.seek(.new(milliseconds: value.toInt())), + ), + ), + Text( + format(duration.value), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart new file mode 100644 index 0000000..8083860 --- /dev/null +++ b/lib/widgets/players/video.dart @@ -0,0 +1,36 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/models/info/video.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:media_kit/media_kit.dart"; +import "package:media_kit_video/media_kit_video.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; + +class VideoPlayer extends HookConsumerWidget { + final VideoInfo? info; + final Uri url; + const VideoPlayer(this.url, this.info, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = useMemoized( + () => Player(configuration: .new(bufferSize: 128 * 1024 * 1024)), + ); + final controller = useMemoized(() => VideoController(player)); + + useEffect(() { + scheduleMicrotask( + () => player.open( + Media(url.toString(), httpHeaders: ref.headers), + play: false, + ), + ); + + return player.dispose; + }, []); + + return SizedBox(height: 300, child: Video(controller: controller)); + } +} diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reaction_row.dart new file mode 100644 index 0000000..e41c2eb --- /dev/null +++ b/lib/widgets/reaction_row.dart @@ -0,0 +1,113 @@ +import "package:cross_cache/cross_cache.dart"; +import "package:flutter/material.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_state_controller.dart"; +import "package:nexus/controllers/cross_cache_controller.dart"; +import "package:nexus/controllers/reactions_controller.dart"; +import "package:nexus/controllers/room_chat_controller.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/helpers/extensions/mxc_to_https.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/main.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +class ReactionRow extends ConsumerWidget { + final Event event; + const ReactionRow(this.event, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final clientState = ref.watch(ClientStateController.provider); + + return switch (ref.watch( + ReactionsController.provider( + .new(roomId: event.roomId, eventRowId: event.rowId), + ), + )) { + AsyncData(value: final IMap>? reactors) || + AsyncLoading(value: final reactors) => Wrap( + spacing: 4, + runSpacing: 4, + children: event.reactions + .where((_, value) => value != 0) + .mapTo( + (reaction, count) => HookBuilder( + builder: (context) { + final enabled = useState(true); + + final selected = + reactors?[reaction]?.contains(clientState!.userId) ?? + false; + return Tooltip( + message: reactors?[reaction]?.join(", ") ?? "", + child: ChoiceChip( + showCheckmark: false, + selected: selected, + label: Row( + mainAxisSize: .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: .ellipsis), + ), + Text(count.toString(), overflow: .ellipsis), + ], + ), + onSelected: enabled.value + ? (value) async { + enabled.value = false; + try { + final controller = ref.watch( + RoomChatController.provider( + event.roomId, + ).notifier, + ); + + if (selected) { + await controller + .removeReaction( + reaction, + event, + clientState!.userId!, + ) + .onError(showError); + } else { + await controller + .sendReaction(reaction, event) + .onError(showError); + } + } finally { + enabled.value = true; + } + } + : null, + ), + ); + }, + ), + ) + .toList(), + ), + + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + }; + } +} diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart new file mode 100644 index 0000000..bcca961 --- /dev/null +++ b/lib/widgets/renderers/event.dart @@ -0,0 +1,232 @@ +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/helpers/extensions/show_context_menu.dart"; +import "package:nexus/models/content/avatar.dart"; +import "package:nexus/models/content/canonical_alias.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/encrypted.dart"; +import "package:nexus/models/content/history_visibility.dart"; +import "package:nexus/models/content/join_rules.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/pinned_events.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/content/server_acl.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/content/topic.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/renderers/message.dart"; +import "package:nexus/widgets/reaction_row.dart"; +import "package:nexus/widgets/renderers/membership.dart"; +import "package:nexus/widgets/renderers/generic_event.dart"; + +class EventRenderer extends HookConsumerWidget { + final Event event; + final bool textOnly; + final bool isGrouped; + final int? maxLines; + final VoidCallback? onTapReply; + final IList Function(Event event)? getEventOptions; + const EventRenderer( + this.event, { + this.onTapReply, + this.textOnly = false, + this.isGrouped = false, + this.maxLines, + this.getEventOptions, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final errorStyle = TextStyle(color: colorScheme.error); + final focusNode = useFocusNode(); + useListenable(focusNode); + + final child = event.redactedBy != null || event.relationType == "m.replace" + ? null + : switch (event.content) { + Content(:final parseError?) => Row( + children: [ + ErrorDialog( + "An error occurred while parsing event ${event.eventId}:\n$parseError", + parseError.stackTrace, + ), + ], + ), + + MessageContent() || + EncryptedContent() || + StickerContent() => MessageRenderer( + event, + onTapReply: onTapReply, + isGrouped: isGrouped, + maxLines: maxLines, + textOnly: textOnly, + ), + + MembershipContent content => switch (event.previousContent) { + MembershipContent(:final status) => + status == content.status ? null : MembershipRenderer(event), + _ => MembershipRenderer(event), + }, + + AvatarContent() => GenericEventRenderer(Icons.interests, [ + MessageDisplayname(event), + Text("changed the room avatar"), + ]), + + CreateContent() => GenericEventRenderer(Icons.add, [ + MessageDisplayname(event), + Text("created the room"), + ]), + + PowerLevelsContent() => GenericEventRenderer(Icons.power, [ + MessageDisplayname(event), + Text("changed the room's power levels"), + ]), + + JoinRulesContent() => GenericEventRenderer(Icons.rule, [ + MessageDisplayname(event), + Text("changed the room's join rules"), + ]), + + TopicContent() => GenericEventRenderer(Icons.description, [ + MessageDisplayname(event), + Text("updated the room topic"), + ]), + + HistoryVisibilityContent(:final historyVisibility) => + GenericEventRenderer(Icons.history, [ + MessageDisplayname(event), + Text( + "changed the room's history visibility to ${switch (historyVisibility) { + .invited => "since invited", + .joined => "since joined", + .shared => "all history visible (shared)", + .worldReadable => "all history visible (world readable)", + }}", + ), + ]), + + PinnedEventsContent() => GenericEventRenderer(Icons.push_pin, [ + MessageDisplayname(event), + Text("pinned/unpinned some events"), + ]), + + ServerACLContent() => GenericEventRenderer(Icons.list, [ + MessageDisplayname(event), + Text("updated the server ban list"), + ]), + + CanonicalAliasContent(:final alias, :final altAliases) => + GenericEventRenderer(Icons.numbers, [ + MessageDisplayname(event), + Text(switch ([ + if (event.previousContent case CanonicalAliasContent( + alias: final prevAlias, + altAliases: final prevAltAliases, + )) ...[ + if (prevAlias != alias) + if (alias == null) + "removed the room's canonical alias" + else + "changed the room's canonical alias to $alias", + + if (prevAltAliases + .remove(alias ?? "") + .remove(prevAlias ?? "") != + altAliases.remove(alias ?? "").remove(prevAlias ?? "")) + "changed the room's aliases", + ] else ...[ + if (alias != null) "set the room's canonical alias", + if (altAliases.isNotEmpty) "set the room's aliases", + ], + ]) { + [] => "did something related to room aliases", + List prev => prev.join(" and "), + }), + ]), + _ => null, + }; + + final contextMenuCallback = getEventOptions == null + ? null + : (details) => context.showContextMenu( + globalPosition: details.globalPosition, + children: getEventOptions!(event).toList(), + ); + + return Column( + crossAxisAlignment: .start, + children: [ + if (child != null) ...[ + if (textOnly) + child + else ...[ + Builder( + builder: (context) => FocusableActionDetector( + focusNode: focusNode, + actions: contextMenuCallback == null + ? null + : { + ActivateIntent: CallbackAction( + onInvoke: (_) { + final renderBox = + context.findRenderObject() as RenderBox; + final topLeft = renderBox.localToGlobal( + Offset.zero, + ); + context.showContextMenu( + globalPosition: topLeft, + children: getEventOptions!(event).toList(), + ); + return null; + }, + ), + }, + child: Container( + decoration: BoxDecoration( + color: focusNode.hasPrimaryFocus + ? theme.colorScheme.surfaceContainerHighest + : null, + ), + child: GestureDetector( + onSecondaryTapUp: contextMenuCallback, + onLongPressStart: contextMenuCallback, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 8, + ).copyWith(top: isGrouped ? 0 : 8), + child: child, + ), + ), + ), + ), + ), + + ...[ + if (event.content is! MessageContent) ReactionRow(event), + + if (event.sendError != null && event.sendError != "not sent") + Text( + event.sendError!, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ].map((child) => Padding(padding: .only(left: 4), child: child)), + ], + ] else if (textOnly) + Text("Unknown event type", style: errorStyle), + ], + ); + } +} diff --git a/lib/widgets/renderers/generic_event.dart b/lib/widgets/renderers/generic_event.dart new file mode 100644 index 0000000..4f4380a --- /dev/null +++ b/lib/widgets/renderers/generic_event.dart @@ -0,0 +1,19 @@ +import "package:flutter/material.dart"; + +class GenericEventRenderer extends StatelessWidget { + final IconData icon; + final List children; + const GenericEventRenderer(this.icon, this.children, {super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: .only(bottom: 8), + child: Row( + spacing: 8, + children: [ + Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)), + Expanded(child: Wrap(spacing: 4, children: children)), + ], + ), + ); +} diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart new file mode 100644 index 0000000..b2835b4 --- /dev/null +++ b/lib/widgets/renderers/membership.dart @@ -0,0 +1,53 @@ +import "package:flutter/material.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/helpers/extensions/string_to_color.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/renderers/generic_event.dart"; + +class MembershipRenderer extends StatelessWidget { + final Event event; + const MembershipRenderer(this.event, {super.key}); + + @override + Widget build(BuildContext context) { + assert( + event.content is MembershipContent, + "Make sure to only pass membership events to MembershipRenderer", + ); + + return switch (event.content) { + MembershipContent content => GenericEventRenderer(Icons.people, [ + InkWell( + onTap: () => context.showUserPopover( + content, + event.stateKey!, + roomId: event.roomId, + ), + child: Text( + overflow: .ellipsis, + content.displayName ?? event.stateKey!.localpart, + maxLines: 1, + style: .new(color: event.sender.colorHash, fontWeight: .bold), + ), + ), + Text( + overflow: .ellipsis, + maxLines: 1, + "${switch (content.status) { + .invite => "was invited to", + .join => "joined", + .leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), + .ban => "was banned from", + .knock => "asked to join", + }} the room${event.sender == event.stateKey ? "" : " by "}", + ), + if (event.sender != event.stateKey) MessageDisplayname(event), + if (content.reason != null) Text("for \"${content.reason}\""), + ]), + _ => SizedBox.shrink(), + }; + } +} diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart new file mode 100644 index 0000000..3470246 --- /dev/null +++ b/lib/widgets/renderers/message.dart @@ -0,0 +1,299 @@ +import "package:collection/collection.dart"; +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:linkify/linkify.dart"; +import "package:nexus/controllers/client_state_controller.dart"; +import "package:nexus/controllers/event_controller.dart"; +import "package:nexus/helpers/extensions/mxc_to_https.dart"; +import "package:nexus/models/content/encrypted.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/file_card.dart"; +import "package:nexus/widgets/html/html.dart"; +import "package:nexus/widgets/lazy_loading/message_avatar.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/linkified_text.dart"; +import "package:nexus/widgets/message_image.dart"; +import "package:nexus/widgets/reaction_row.dart"; +import "package:nexus/widgets/url_preview.dart"; +import "package:timeago/timeago.dart"; +import "package:nexus/widgets/event_preview.dart"; +import "package:nexus/widgets/players/video.dart"; +import "package:nexus/widgets/players/audio.dart"; + +class MessageRenderer extends ConsumerWidget { + final Event event; + final bool textOnly; + final bool isGrouped; + final int? maxLines; + final VoidCallback? onTapReply; + const MessageRenderer( + this.event, { + this.onTapReply, + this.textOnly = false, + this.isGrouped = false, + this.maxLines, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final errorStyle = TextStyle(color: colorScheme.error); + + final timestamp = Tooltip( + message: event.timestamp.toString(), + child: Text( + format(event.timestamp), + maxLines: 1, + overflow: .ellipsis, + style: theme.textTheme.labelSmall?.copyWith(color: Colors.grey), + ), + ); + + final textStyle = TextStyle( + fontSize: event.localContent?.bigEmoji == true ? 32 : null, + fontStyle: event.content is EmoteMessageContent ? .italic : null, + ); + + return Row( + crossAxisAlignment: .start, + mainAxisSize: .min, + spacing: 8, + children: [ + if (!textOnly) + if (isGrouped) + SizedBox(width: 40) + else + MessageAvatar(event, height: 40), + Flexible( + child: Column( + spacing: 4, + crossAxisAlignment: .start, + children: [ + if (!isGrouped && !textOnly) + Row( + spacing: 4, + children: [ + Flexible(child: MessageDisplayname(event)), + Flexible(flex: 0, child: timestamp), + ], + ), + Card( + margin: textOnly ? .zero : .only(bottom: 4), + color: textOnly + ? Colors.transparent + : ref.watch( + ClientStateController.provider.select( + (value) => value?.userId, + ), + ) == + event.sender + ? (event.eventId.startsWith("~") + ? colorScheme.onPrimary + : colorScheme.primaryContainer) + : colorScheme.surfaceContainer, + elevation: textOnly ? 0 : null, + + child: Padding( + padding: textOnly ? .zero : .all(12), + child: Column( + crossAxisAlignment: .start, + children: [ + if (!textOnly && event.replyTo != null) + Card( + margin: .only(bottom: 8), + color: theme.colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: onTapReply, + child: Padding( + padding: .symmetric(vertical: 8, horizontal: 12), + child: switch (ref.watch( + EventController.provider( + .new( + roomId: event.roomId, + eventId: event.replyTo!, + ), + ), + )) { + AsyncData(:final value?) || + AsyncLoading( + :final value?, + ) => EventPreview(value), + AsyncError _ => Text( + "An error occurred while fetching the reply", + style: errorStyle, + ), + _ => Text("Fetching event..."), + }, + ), + ), + ), + switch (event.content) { + EncryptedContent() => Text( + "Unable to decrypt event", + style: errorStyle, + ), + StickerContent(:final body, :final url, :final info) => + textOnly + ? Text( + body, + maxLines: maxLines, + overflow: .ellipsis, + ) + : ConstrainedBox( + constraints: .loose(.square(200)), + child: MessageImage( + url.mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value!.homeserverUrl!, + ), + ), + ), + info: info, + ), + ), + // TODO: Handle locations + // LocationMessageContent(:final body , :final geoUri) => + TextMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + NoticeMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + EmoteMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + ImageMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + VideoMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + AudioMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + FileMessageContent( + :final body, + :final formattedBody, + :final format, + ) => Column( + crossAxisAlignment: .start, + children: [ + format == .html && !textOnly + ? Html( + roomId: event.roomId, + textStyle: textStyle, + formattedBody!.replaceAllMapped( + RegExp( + r"(]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)", + caseSensitive: false, + dotAll: true, + ), + (m) { + // If it's already an tag, leave it unchanged + if (m.group(1) != null) { + return m.group(1)!; + } + + // Otherwise, wrap the bare URL + final url = m.group(2)!; + return "$url"; + }, + ), + ) + : LinkifiedText( + body, + style: textStyle, + maxLines: maxLines, + ), + + if (!textOnly) ...[ + if (event.content + case ImageMessageContent(:final url) || + FileMessageContent(:final url) || + VideoMessageContent(:final url) || + AudioMessageContent(:final url)) + switch (url?.mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value!.homeserverUrl!, + ), + ), + )) { + final url? => ConstrainedBox( + constraints: .loose(.square(500)), + child: switch (event.content) { + VideoMessageContent(:final info) => + VideoPlayer(url, info), + AudioMessageContent(:final info) => + AudioPlayer(url, info), + FileMessageContent( + :final info, + :final filename, + ) => + FileCard(url, info, filename: filename), + ImageMessageContent(:final info) => + MessageImage(url, info: info), + _ => SizedBox.shrink(), + }, + ), + _ => Text( + "Nexus currently cannot handle encrypted media", + style: errorStyle, + ), + }, + + if (event.lastEditRowId != 0) + Text( + "(edited)", + style: theme.textTheme.labelSmall, + ), + + if (linkify(body).firstWhereOrNull( + (element) => element is UrlElement, + ) + case final UrlElement link?) + UrlPreview(link.url), + + SizedBox(height: 4), + ReactionRow(event), + ], + ], + ), + MessageContent(:final body) => Row( + spacing: 8, + mainAxisSize: .min, + children: [ + Text("Unknown message type:", style: errorStyle), + Text(body), + ], + ), + _ => throw Exception("This is impossible"), + }, + ], + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/widgets/room_appbar.dart b/lib/widgets/room_appbar.dart new file mode 100644 index 0000000..e1d5708 --- /dev/null +++ b/lib/widgets/room_appbar.dart @@ -0,0 +1,144 @@ +import "package:flutter/material.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/rooms_controller.dart"; +import "package:nexus/helpers/extensions/mxc_to_https.dart"; +import "package:nexus/widgets/appbar.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/controllers/client_state_controller.dart"; +import "package:nexus/widgets/linkified_text.dart"; +import "package:nexus/widgets/room_menu.dart"; + +class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { + final bool isDesktop; + final void Function(BuildContext context)? onOpenMemberList; + final void Function(BuildContext context) onOpenDrawer; + final String? roomId; + const RoomAppbar({ + required this.roomId, + required this.isDesktop, + required this.onOpenDrawer, + this.onOpenMemberList, + super.key, + }); + + @override + Size get preferredSize => AppBar().preferredSize; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final room = roomId == null + ? null + : ref.watch(RoomsController.provider.select((value) => value[roomId!])); + + return Appbar( + onTap: room == null + ? null + : () => showDialog( + context: context, + builder: (context) => Dialog( + constraints: .loose(.fromWidth(400)), + child: Padding( + padding: .all(24), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + spacing: 8, + children: [ + Row( + spacing: 12, + mainAxisSize: .min, + children: [ + if (room.metadata?.avatar != null) + ExpandableImage( + room.metadata!.avatar! + .mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value!.homeserverUrl!, + ), + ), + ) + .toString(), + child: AvatarOrHash( + room.metadata?.avatar, + room.metadata?.name ?? "Unnamed Room", + height: 64, + fallback: Icon(Icons.numbers), + ), + ), + Expanded( + child: Text( + room.metadata?.name ?? "Unnamed Room", + overflow: .ellipsis, + maxLines: 3, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + if (room.metadata?.topic?.isNotEmpty == true) + LinkifiedText( + room.metadata!.topic!, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + leading: isDesktop + ? room == null + ? null + : AvatarOrHash( + room.metadata?.avatar, + room.metadata?.name ?? "Unnamed Room", + height: 24, + fallback: Icon(Icons.numbers), + ) + : DrawerButton(onPressed: () => onOpenDrawer(context)), + scrolledUnderElevation: 0, + title: room == null + ? null + : Column( + crossAxisAlignment: .start, + children: [ + Text( + room.metadata?.name ?? "Unnamed Room", + overflow: .ellipsis, + maxLines: 1, + ), + if (room.metadata?.topic?.isNotEmpty == true) + Text( + room.metadata!.topic!, + maxLines: 1, + overflow: .ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + actions: room == null + ? .new() + : .new([ + IconButton( + onPressed: null, + icon: Icon(Icons.push_pin), + tooltip: "Open pinned messages", + ), + IconButton( + onPressed: () => onOpenMemberList?.call(context), + tooltip: "Open member list", + icon: Icon(Icons.people), + ), + RoomMenu(room), + ]), + ); + } +} diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart new file mode 100644 index 0000000..2817172 --- /dev/null +++ b/lib/widgets/room_chat.dart @@ -0,0 +1,515 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:measure_size/measure_size.dart"; +import "package:nexus/controllers/account_data_controller.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/controllers/client_state_controller.dart"; +import "package:nexus/controllers/power_level_controller.dart"; +import "package:nexus/controllers/rooms_controller.dart"; +import "package:nexus/controllers/room_chat_controller.dart"; +import "package:nexus/controllers/via_controller.dart"; +import "package:nexus/models/configs/power_level_config.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/relation_type.dart"; +import "package:nexus/widgets/composer/composer.dart"; +import "package:nexus/widgets/emoji_picker_button.dart"; +import "package:nexus/widgets/renderers/event.dart"; +import "package:nexus/widgets/member_list.dart"; +import "package:nexus/widgets/room_appbar.dart"; +import "package:nexus/widgets/highlight_wrapper.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:super_sliver_list/super_sliver_list.dart"; + +class RoomChat extends HookConsumerWidget { + final bool isDesktop; + final bool showMembersByDefault; + final String? roomId; + const RoomChat({ + required this.roomId, + required this.isDesktop, + required this.showMembersByDefault, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final relatedEvent = useState(null); + final relationType = useState(RelationType.reply); + final highlightedEvent = useState(null); + + final composerSize = useState(64); + + final memberListOpened = useState(showMembersByDefault); + + final userId = ref.watch(ClientStateController.provider)?.userId; + final theme = Theme.of(context); + + final nothing = Center( + child: Text( + "Nothing to see here...", + style: theme.textTheme.headlineMedium, + ), + ); + if (userId == null || this.roomId == null) { + return Scaffold( + appBar: RoomAppbar( + roomId: this.roomId, + isDesktop: isDesktop, + onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), + onOpenMemberList: null, + ), + body: nothing, + ); + } + + final roomId = this.roomId!; + + final controllerProvider = RoomChatController.provider(roomId); + final notifier = ref.watch(controllerProvider.notifier); + + final client = ref.watch(ClientController.provider.notifier); + + final listController = useRef(ListController()); + final scrollController = useScrollController(); + final controllerData = ref.watch(controllerProvider); + + final topEventBeforeLoad = useState(null); + + Future loadOlder() async { + if (controllerData case AsyncData(:final value?)) { + topEventBeforeLoad.value = value.firstOrNull?.eventId; + await notifier.loadOlder(); + } + } + + useEffect(() { + ref + .read(controllerProvider.future) + .then( + (_) => WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients) { + scrollController.jumpTo( + scrollController.position.maxScrollExtent - .000001, + ); + } + }), + ); + + return null; + }, [scrollController.hasClients]); + + useEffect(() { + if (controllerData case AsyncData( + :final value?, + ) when scrollController.hasClients) { + if (topEventBeforeLoad.value != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients) { + final index = value.indexWhere( + (event) => event.eventId == topEventBeforeLoad.value, + ); + if (index != -1) { + listController.value.jumpToItem( + index: index, + scrollController: scrollController, + alignment: 0, + ); + } + } + topEventBeforeLoad.value = null; + }); + } else if (scrollController.position.atEdge && + scrollController.position.pixels != 0) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients) { + scrollController.jumpTo( + scrollController.position.maxScrollExtent, + ); + } + }); + } + } + + return null; + }, [controllerData]); + + useEffect(() { + Future listener() async { + if (!scrollController.hasClients || !scrollController.position.atEdge) { + return; + } + + final room = ref.watch( + RoomsController.provider.select((value) => value[roomId]), + ); + if (room == null) return; + + if (scrollController.position.pixels == 0) { + if (room.hasMore) { + await loadOlder(); + } + } else { + await client.markRead(room); + } + } + + scrollController.addListener(listener); + return () => scrollController.removeListener(listener); + }, [roomId, controllerData]); + + final composerNode = useFocusNode( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && event.logicalKey == .escape) { + relatedEvent.value = null; + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + }, + ); + + IList getEventOptions(Event event) { + final danger = theme.colorScheme.error; + final isSentByMe = event.sender == userId; + return [ + if (ref.watch( + PowerLevelController.provider( + .new(eventType: .reaction, roomId: roomId), + ), + )) + PopupMenuItem( + enabled: false, + child: IconTheme( + data: theme.iconTheme, + child: Row( + children: [ + ...{ + ...ref.watch( + AccountDataController.provider.select( + (value) => IList( + value["m.recent_emoji"] + ?.content["recent_emoji"] ?? + [], + ).map((entry) => entry["emoji"]).toIList(), + ), + ), + "👍", + "🤣", + "😭", + "🤔", + } + .toIList() + .sublist(0, 4) + .map( + (emoji) => IconButton( + onPressed: () async { + Navigator.of(context).pop(); + await notifier + .sendReaction(emoji, event) + .onError(showError); + }, + icon: Text(emoji), + ), + ), + EmojiPickerButton( + context: context, + onPressed: Navigator.of(context).pop, + onSelection: (emoji) => + notifier.sendReaction(emoji, event).onError(showError), + ), + ], + ), + ), + ), + if (ref.watch( + PowerLevelController.provider( + PowerLevelConfig(eventType: .message, roomId: roomId), + ), + )) + PopupMenuItem( + onTap: () { + relatedEvent.value = event; + relationType.value = .reply; + composerNode.requestFocus(); + }, + child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")), + ), + if (event.content is MessageContent && isSentByMe) + PopupMenuItem( + onTap: () { + relatedEvent.value = event; + relationType.value = .edit; + composerNode.requestFocus(); + }, + child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")), + ), + PopupMenuItem( + onTap: () async { + final room = ref.watch( + RoomsController.provider.select((value) => value[roomId]), + ); + if (room == null) return; + + final vias = ref.watch(ViaController.provider(room)); + + await Clipboard.setData( + ClipboardData( + text: + "matrix:roomid/${room.metadata?.id.substring(1)}/e/${event.eventId}$vias)", + ), + ); + }, + child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), + ), + if (ref.watch( + PowerLevelController.provider( + .redaction(targetUser: event.sender, roomId: roomId), + ), + )) + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (context) => HookBuilder( + builder: (_) { + final deleteReasonController = useTextEditingController(); + return AlertDialog( + title: Text("Delete Message"), + content: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text( + "Are you sure you want to delete this message? This can not be reversed.", + ), + SizedBox(height: 12), + TextField( + controller: deleteReasonController, + textCapitalization: .sentences, + decoration: .new( + labelText: "Reason for deletion (optional)", + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + await notifier + .deleteMessage( + event, + reason: deleteReasonController.text, + ) + .onError(showError); + }, + child: Text("Delete"), + ), + ], + ); + }, + ), + ), + child: ListTile( + leading: Icon(Icons.delete, color: danger), + title: Text("Delete", style: .new(color: danger)), + ), + ), + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (context) => HookBuilder( + builder: (_) { + final reasonController = useTextEditingController(); + return AlertDialog( + title: Text("Report"), + content: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text( + "Report this event to your server administrators, who can take action like banning this server or room.", + ), + + SizedBox(height: 12), + TextField( + controller: reasonController, + textCapitalization: .sentences, + decoration: .new( + labelText: "Reason for report (optional)", + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () { + client.reportEvent( + .new( + roomId: roomId, + eventId: event.eventId, + reason: reasonController.text.isEmpty + ? null + : reasonController.text, + ), + ); + Navigator.of(context).pop(); + }, + child: Text("Report"), + ), + ], + ); + }, + ), + ), + child: ListTile( + leading: Icon(Icons.report, color: danger), + title: Text("Report", style: .new(color: danger)), + ), + ), + ].toIList(); + } + + return Scaffold( + appBar: RoomAppbar( + roomId: roomId, + isDesktop: isDesktop, + onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), + onOpenMemberList: (thisContext) { + memberListOpened.value = !memberListOpened.value; + Scaffold.of(thisContext).openEndDrawer(); + }, + ), + body: Row( + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: .symmetric(horizontal: 4), + child: switch (controllerData) { + AsyncData(:final value?) || + AsyncLoading(:final value?) => CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: .symmetric(vertical: 36), + child: Center( + child: ElevatedButton( + onPressed: controllerData is AsyncData + ? loadOlder + : null, + child: Text("Load More"), + ), + ), + ), + ), + + SuperSliverList.builder( + listController: listController.value, + itemCount: value.length, + itemBuilder: (_, index) { + final event = value[index]; + final previousEvent = value.getOrNull(index - 1); + return HighlightWrapper( + EventRenderer( + event, + onTapReply: () async { + final replyId = event.replyTo; + listController.value.animateToItem( + index: value.indexWhere( + (element) => element.eventId == replyId, + ), + scrollController: scrollController, + alignment: 0.5, + duration: (_) => .new(milliseconds: 700), + curve: (_) => Curves.easeInOut, + ); + highlightedEvent.value = replyId; + await Future.delayed(.new(seconds: 1), () { + if (highlightedEvent.value == replyId) { + highlightedEvent.value = null; + } + }); + }, + getEventOptions: getEventOptions, + isGrouped: + previousEvent?.content + is MessageContent && + previousEvent?.redactedBy == null && + previousEvent?.relationType != + "m.replace" && + "${event.sender}${event.pmp?.id}" == + "${previousEvent?.sender}${previousEvent?.pmp?.id}", + ), + isHighlighted: + highlightedEvent.value == event.eventId, + ); + }, + ), + + SliverPadding( + padding: .only(bottom: composerSize.value), + ), + ], + ), + AsyncData() => nothing, + AsyncLoading() => Loading(), + AsyncError(:final error, :final stackTrace) => + ErrorDialog(error, stackTrace), + }, + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: MeasureSize( + onChange: (size) => composerSize.value = size.height, + child: Composer( + roomId, + node: composerNode, + onSend: (text, {required shouldMention, required tags}) => + notifier + .send( + text, + tags: tags, + relationType: relationType.value, + shouldMention: shouldMention, + relation: relatedEvent.value, + ) + .onError(showError), + relationType: relationType.value, + relatedEvent: relatedEvent.value, + onDismiss: () => relatedEvent.value = null, + ), + ), + ), + ], + ), + ), + + if (memberListOpened.value == true && showMembersByDefault) + MemberList(roomId), + ], + ), + + endDrawer: showMembersByDefault ? null : MemberList(roomId), + ); + } +} diff --git a/lib/widgets/chat_page/room_menu.dart b/lib/widgets/room_menu.dart similarity index 61% rename from lib/widgets/chat_page/room_menu.dart rename to lib/widgets/room_menu.dart index 4405707..f4313fa 100644 --- a/lib/widgets/chat_page/room_menu.dart +++ b/lib/widgets/room_menu.dart @@ -7,7 +7,7 @@ import "package:nexus/controllers/via_controller.dart"; import "package:nexus/models/room.dart"; class RoomMenu extends ConsumerWidget { - final Room room; + final Room? room; final IList children; const RoomMenu(this.room, {this.children = const IList.empty(), super.key}); @@ -20,7 +20,7 @@ class RoomMenu extends ConsumerWidget { itemBuilder: (_) => [ PopupMenuItem( onTap: () async { - await client.markRead(room); + if (room != null) await client.markRead(room!); await Future.wait(children.map((child) => client.markRead(child))); }, child: ListTile( @@ -28,53 +28,61 @@ class RoomMenu extends ConsumerWidget { title: Text("Mark as Read"), ), ), - PopupMenuItem( - onTap: () async { - final vias = ref.watch(ViaController.provider(room)); + if (room != null) ...[ + 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, - builder: (context) => AlertDialog( - title: Text("Leave Room"), - content: Text( - "Are you sure you want to leave \"${room.metadata?.name ?? "Unnamed Room"}\"?", - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), + await Clipboard.setData( + .new( + text: + "matrix:roomid/${room!.metadata?.id.substring(1)}$vias)", ), - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - final snackbar = ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text("Leaving room..."), - duration: Duration(days: 1), - ), - ); - await client.leaveRoom(room); - snackbar.close(); - }, - child: Text("Leave"), - ), - ], + ); + }, + child: ListTile( + leading: Icon(Icons.link), + title: Text("Copy Link"), ), ), - child: ListTile( - leading: Icon(Icons.logout, color: danger), - title: Text("Leave", style: TextStyle(color: danger)), + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text("Leave Room"), + content: Text( + "Are you sure you want to leave \"${room!.metadata?.name ?? "Unnamed Room"}\"?", + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + final snackbar = ScaffoldMessenger.of(context) + .showSnackBar( + .new( + content: Text("Leaving room..."), + duration: Duration(days: 1), + ), + ); + await client.leaveRoom(room!); + snackbar.close(); + }, + child: Text("Leave"), + ), + ], + ), + ), + child: ListTile( + leading: Icon(Icons.logout, color: danger), + title: Text("Leave", style: TextStyle(color: danger)), + ), ), - ), + ], + // PopupMenuItem( // onTap: () => showDialog( // context: context, diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart new file mode 100644 index 0000000..56f8d57 --- /dev/null +++ b/lib/widgets/sidebar.dart @@ -0,0 +1,290 @@ +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:navigation_rail_m3e/navigation_rail_m3e.dart"; +import "package:nexus/controllers/key_controller.dart"; +import "package:nexus/controllers/spaces_controller.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/divider_widget.dart"; +import "package:nexus/widgets/join_dialog.dart"; +import "package:nexus/widgets/room_menu.dart"; + +class Sidebar extends HookConsumerWidget { + final bool isDesktop; + const Sidebar({required this.isDesktop, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedSpaceProvider = KeyController.provider( + KeyController.spaceKey, + ); + final selectedSpaceId = ref.watch(selectedSpaceProvider); + final selectedSpaceIdNotifier = ref.watch(selectedSpaceProvider.notifier); + + final selectedRoomController = KeyController.provider( + KeyController.roomKey, + ); + final selectedRoomId = ref.watch(selectedRoomController); + final selectedRoomIdNotifier = ref.watch(selectedRoomController.notifier); + + final spaces = ref.watch(SpacesController.provider); + final indexOfSelected = spaces.indexWhere( + (space) => space.id == selectedSpaceId, + ); + final selectedIndex = indexOfSelected == -1 ? 0 : indexOfSelected; + + final selectedSpace = + spaces.firstWhereOrNull((space) => space.id == selectedSpaceId) ?? + spaces.first; + + final indexOfSelectedRoom = selectedSpace.children + .addAll( + selectedSpace.subSpaces.map((element) => element.children).flattened, + ) + .indexWhere((room) => room.metadata?.id == selectedRoomId); + final selectedRoomIndex = indexOfSelectedRoom == -1 + ? null + : indexOfSelectedRoom; + + List roomsToDestinations(IList rooms) => + rooms + .map( + (room) => NavigationRailM3EDestination( + label: room.metadata?.name ?? "Unnamed Room", + badgeCount: switch (room.metadata?.unreadNotifications) { + 0 || null => room.metadata?.unreadMessages == 0 ? null : 0, + int unread => unread, + }, + icon: AvatarOrHash( + room.metadata?.avatar, + room.metadata?.name ?? "Unnamed Room", + fallback: selectedSpaceId == "dms" + ? null + : Icon(Icons.numbers), + ), + ), + ) + .toList(); + + return Drawer( + width: 330, + shape: Border(), + child: Row( + children: [ + Theme( + data: Theme.of(context).copyWith( + extensions: [ + NavigationRailM3ETheme( + itemCollapsedHeight: 48, + itemVerticalGap: 0, + ), + ], + ), + child: Container( + color: NavigationRailTokensAdapter(context).containerColor, + padding: EdgeInsets.only(top: 16), + child: NavigationRailM3E( + type: .alwaysCollapse, + labelBehavior: .alwaysHide, + scrollable: true, + onDestinationSelected: (value) { + selectedSpaceIdNotifier.set(spaces[value].id); + selectedRoomIdNotifier.set( + spaces[value].children.firstOrNull?.metadata?.id, + ); + }, + sections: [ + .new( + destinations: spaces + .map( + (space) => NavigationRailM3EDestination( + badgeCount: switch (space.children + .addAll( + space.subSpaces + .map((element) => element.children) + .flattened, + ) + .fold( + 0, + (previousValue, room) => + previousValue + + (room.metadata?.unreadNotifications ?? 0), + )) { + 0 => + space.children + .addAll( + space.subSpaces + .map( + (element) => element.children, + ) + .flattened, + ) + .any( + (room) => + room.metadata?.unreadMessages != + 0, + ) + ? 0 + : null, + int badgeCount => badgeCount, + }, + short: true, + icon: AvatarOrHash( + height: 28, + space.room?.metadata?.avatar, + fallback: space.icon == null + ? null + : Icon(space.icon), + space.title, + ), + label: space.title, + ), + ) + .toList(), + ), + ], + selectedIndex: selectedIndex, + trailingAtBottom: true, + trailing: Padding( + padding: .symmetric(vertical: 16), + child: Column( + spacing: 8, + children: [ + PopupMenuButton( + itemBuilder: (_) => [ + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (_) => JoinDialog(ref), + ), + child: ListTile( + title: Text("Join an existing room (or space)"), + leading: Icon(Icons.numbers), + ), + ), + PopupMenuItem( + onTap: null, + child: ListTile( + title: Text("Create a new room"), + leading: Icon(Icons.add), + ), + ), + ], + icon: Icon(Icons.add), + ), + IconButton( + tooltip: "Explore other rooms", + onPressed: null, + icon: Icon(Icons.explore), + ), + IconButton( + tooltip: "Open settings", + onPressed: null, + // () => Navigator.of( + // context, + // ).push(MaterialPageRoute(builder: (_) => SettingsPage())), + icon: Icon(Icons.settings), + ), + ], + ), + ), + ), + ), + ), + Expanded( + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + leading: AvatarOrHash( + selectedSpace.room?.metadata?.avatar, + fallback: selectedSpace.icon == null + ? null + : Icon(selectedSpace.icon), + + selectedSpace.title, + ), + title: Text(selectedSpace.title, overflow: .ellipsis), + backgroundColor: Colors.transparent, + actions: [ + RoomMenu( + selectedSpace.room, + children: selectedSpace.children.addAll( + selectedSpace.subSpaces + .map((element) => element.children) + .flattened, + ), + ), + ], + ), + body: Theme( + data: Theme.of(context).copyWith( + extensions: [ + NavigationRailM3ETheme( + itemExpandedHeight: 48, + iconLabelGap: 16, + ), + ], + ), + child: NavigationRailM3E( + expandedWidth: double.infinity, + scrollable: true, + background: Colors.transparent, + type: .alwaysExpand, + selectedIndex: selectedRoomIndex ?? 0, + sections: [ + .new( + header: selectedSpace.room == null + ? null + : DividerWidget(Text("Rooms")), + destinations: roomsToDestinations(selectedSpace.children), + ), + for (final subSpace in selectedSpace.subSpaces) + .new( + header: DividerWidget( + Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + if (subSpace.room.metadata?.avatar != null) + AvatarOrHash( + subSpace.room.metadata?.avatar, + subSpace.room.metadata?.name ?? + "Unnamed Room", + height: 16, + ), + Flexible( + child: Text( + subSpace.room.metadata?.name ?? + "Unnamed Space", + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + destinations: roomsToDestinations(subSpace.children), + ), + ], + onDestinationSelected: (value) { + final children = selectedSpace.children.addAll( + selectedSpace.subSpaces + .map((element) => element.children) + .flattened, + ); + selectedRoomIdNotifier.set( + children[value].metadata?.id, // + ); + if (!isDesktop) Navigator.of(context).pop(); + }, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/url_preview.dart b/lib/widgets/url_preview.dart new file mode 100644 index 0000000..669c756 --- /dev/null +++ b/lib/widgets/url_preview.dart @@ -0,0 +1,66 @@ +import "package:cross_cache/cross_cache.dart"; +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/cross_cache_controller.dart"; +import "package:nexus/controllers/url_preview_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/helpers/launch_helper.dart"; + +class UrlPreview extends ConsumerWidget { + final String link; + const UrlPreview(this.link, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) => ConstrainedBox( + constraints: .loose(.fromWidth(400)), + child: ref + .watch(UrlPreviewController.provider(link)) + .betterWhen( + data: (preview) => preview == null + ? SizedBox.shrink() + : InkWell( + onTap: () => + ref.watch(LaunchHelper.provider).launchUrl(.parse(link)), + child: Card( + margin: .symmetric(vertical: 4), + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: Padding( + padding: .all(16), + child: Column( + crossAxisAlignment: .start, + spacing: 4, + children: [ + if (preview.title != null) + Text( + preview.title!, + style: Theme.of(context).textTheme.titleLarge, + ), + if (preview.description != null) ...[ + Text(preview.description!), + SizedBox(height: 4), + ], + if (preview.imageUrl != null) + ClipRRect( + borderRadius: .all(.circular(8)), + child: Image( + errorBuilder: (_, _, _) => SizedBox.shrink(), + width: preview.width, + image: CachedNetworkImage( + preview.imageUrl.toString(), + ref.watch(CrossCacheController.provider), + headers: ref.headers, + ), + fit: .fitWidth, + ), + ), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart new file mode 100644 index 0000000..d422e01 --- /dev/null +++ b/lib/widgets/user_bottom_sheet.dart @@ -0,0 +1,264 @@ +import "package:collection/collection.dart"; +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:m3e_buttons/m3e_buttons.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/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/mxc_to_https.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/requests/membership_action.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/expandable_image.dart"; + +class UserBottomSheet extends ConsumerWidget { + final MembershipContent member; + final String userId; + final String? roomId; + const UserBottomSheet(this.member, this.userId, {this.roomId, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final client = ref.watch(ClientController.provider.notifier); + + void showMembershipDialog(MembershipAction action) => showDialog( + context: context, + builder: (context) => HookBuilder( + builder: (context) { + final actionReasonController = useTextEditingController(); + return AlertDialog( + title: Text("${toBeginningOfSentenceCase(action.name)} $userId"), + content: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text("Are you sure you want to ${action.name} $userId?"), + SizedBox(height: 12), + TextField( + textCapitalization: .sentences, + controller: actionReasonController, + decoration: .new( + labelText: "Reason for ${action.name} (optional)", + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + client + .setMembership( + .new( + userId: userId, + roomId: roomId!, + action: action, + reason: actionReasonController.text, + ), + ) + .onError(showError); + }, + child: Text(toBeginningOfSentenceCase(action.name)), + ), + ], + ); + }, + ), + ); + + return Padding( + padding: .all(42), + child: Column( + spacing: 4, + mainAxisSize: .min, + crossAxisAlignment: .center, + children: [ + Row( + mainAxisAlignment: .end, + children: [ + M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), + ), + ], + ), + SizedBox(height: 18), + + ExpandableImage( + member.avatarUrl + ?.mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value!.homeserverUrl!, + ), + ), + ) + .toString(), + child: AvatarOrHash( + member.avatarUrl, + member.displayName ?? userId.localpart, + height: 200, + ), + ), + + SizedBox(height: 8), + + SelectableText( + member.displayName ?? userId.localpart, + style: textTheme.headlineLarge, + textAlign: .center, + ), + SelectableText( + userId, + textAlign: .center, + style: textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + + ref + .watch(ProfileController.provider(userId)) + .betterWhen( + loading: () => Text(""), + data: (profile) => Column( + children: [ + if (profile.timezone == null && profile.pronouns.isEmpty) + Text(""), + Wrap( + crossAxisAlignment: .center, + alignment: .center, + spacing: 4, + runSpacing: 4, + children: [ + ...profile.pronouns + .where( + // TODO: Check system language (l10n) + (pronoun) => pronoun.language == "en", + ) + .mapIndexed( + (index, pronoun) => [ + if (index != 0) + Icon( + Icons.circle, + size: 4, + color: theme.colorScheme.onSurfaceVariant, + ), + Text( + pronoun.summary, + textAlign: .center, + style: textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ) + .flattened, + + if (profile.timezone != null) ...[ + if (profile.pronouns.isNotEmpty) + SizedBox( + height: 16, + child: VerticalDivider( + thickness: 1.5, + width: 4, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text( + profile.timezone!, + textAlign: .center, + style: textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ], + ), + ), + + SizedBox(height: 8), + if (userId != ref.watch(ClientStateController.provider)?.userId && + roomId != null) ...[ + Row( + children: [ + Expanded( + child: M3EButton.icon( + onPressed: null, + shape: .square, + style: .tonal, + icon: Icon(Icons.message), + label: Text("Message"), + ), + ), + ], + ), + + if (ref.watch( + PowerLevelController.provider( + .membershipAction( + action: .kick, + roomId: roomId!, + targetUser: userId, + ), + ), + ) && + member.status == .join || + member.status == .invite) + Padding( + padding: .only(top: 4), + child: Row( + mainAxisSize: MainAxisSize.max, + spacing: 8, + children: [ + M3EButton.icon( + onPressed: () => showMembershipDialog(.kick), + shape: .square, + icon: Icon(Icons.sports_martial_arts), + label: Text("Kick"), + decoration: .new( + backgroundColor: WidgetStatePropertyAll( + theme.colorScheme.error, + ), + foregroundColor: WidgetStatePropertyAll( + theme.colorScheme.onError, + ), + ), + ), + + M3EButton.icon( + onPressed: () => showMembershipDialog(.ban), + shape: .square, + icon: Icon(Icons.gavel), + label: Text("Ban"), + decoration: .new( + backgroundColor: WidgetStatePropertyAll( + theme.colorScheme.errorContainer, + ), + foregroundColor: WidgetStatePropertyAll( + theme.colorScheme.onErrorContainer, + ), + ), + ), + ].map((e) => Expanded(child: e)).toList(), + ), + ), + ], + ], + ), + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 5485b95..755a54e 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,19 +6,27 @@ #include "generated_plugin_registrant.h" -#include +#include #include +#include +#include #include #include #include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) dynamic_system_colors_registrar = + g_autoptr(FlPluginRegistrar) dynamic_color_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin"); - dynamic_color_plugin_register_with_registrar(dynamic_system_colors_registrar); + dynamic_color_plugin_register_with_registrar(dynamic_color_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); + media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_video_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); + media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 13ef2de..b9ca03c 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,8 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST - dynamic_system_colors + dynamic_color file_selector_linux + media_kit_libs_linux + media_kit_video screen_retriever_linux url_launcher_linux window_manager diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index 91ba95a..ae77467 100644 --- a/linux/nix/devshell.nix +++ b/linux/nix/devshell.nix @@ -22,7 +22,14 @@ pkgs.mkShell { go git jdk17 - flutter + libGL + wayland + (flutter.override { + extraPkgConfigPackages = [ + mpv-unwrapped + libass + ]; + }) android.platform-tools ]; diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index 26f2a17..c879f24 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -1,6 +1,8 @@ { lib, callPackage, + mpv-unwrapped, + libass, libclang, flutter, src, @@ -17,22 +19,26 @@ flutter.buildFlutterApplication { packageRun build_runner build ''; + buildInputs = [ + mpv-unwrapped + libass + ]; + env.LIBCLANG_PATH = lib.makeLibraryPath [ libclang ]; autoPubspecLock = src + "/pubspec.lock"; gitHashes = { window_size = "sha256-XelNtp7tpZ91QCEcvewVphNUtgQX7xrp5QP0oFo6DgM="; - dynamic_system_colors = "sha256-es6rjMK1drkqZBKYUP77yw/q5+0uLwWOEDOXRawy3Dc="; - flutter_chat_ui = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk="; - flutter_link_previewer = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk="; - emoji_text_field = "sha256-F0QbIHP3wpKoL6QbJ20Oun0SsOdwnXe84IqsK2ad85w="; + emoji_text_field = "sha256-3TOys09EP2GRo6pUBGPXaqBlE39O2Cmwt42Hs1cTDKo="; + linkify = "sha256-mxV/XHLxF9cn7sUPr2SUNjVmDr5lbxkuGCbNdyiZi2c="; + navigation_rail_m3e = "sha256-+2awDTQnK58gGRY1nuHckG/jjxarsYSRu9ovR4i4TEc="; }; postInstall = '' install -D assets/icon.svg $out/share/icons/hicolor/scalable/apps/nexus.svg install -Dm755 linux/nexus.federated.Nexus.desktop -t $out/share/applications - wrapProgram $out/bin/nexus \ + wrapProgram $out/bin/nexus \ --suffix LD_LIBRARY_PATH : $out/app/nexus/lib ''; diff --git a/linux/nix/pkg/gomuks.nix b/linux/nix/pkg/gomuks.nix index 1bc92bf..3e4ad90 100644 --- a/linux/nix/pkg/gomuks.nix +++ b/linux/nix/pkg/gomuks.nix @@ -11,7 +11,7 @@ buildGoModule (finalAttrs: { src = "${src}/gomuks"; - vendorHash = "sha256-zBDfBZqUoHIfZ0AajZEvSBbskjpFB7yIsomt0KYDo7Y="; + vendorHash = "sha256-EeGuh73jcK2aKmEJsMaAqQRJMzzHj3s8LrLb/QmorbQ="; buildPhase = '' runHook preBuild diff --git a/pubspec.lock b/pubspec.lock index ef7fcd9..4e30e17 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" url: "https://pub.dev" source: hosted - version: "91.0.0" + version: "92.0.0" analysis_server_plugin: dependency: transitive description: @@ -18,21 +18,21 @@ packages: source: hosted version: "0.3.4" analyzer: - dependency: "direct overridden" + dependency: transitive description: name: analyzer - sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" url: "https://pub.dev" source: hosted - version: "8.4.1" + version: "9.0.0" analyzer_buffer: dependency: transitive description: name: analyzer_buffer - sha256: "5fcd06b0715ebeee99f03e3f437b3412249969d8d12b191ea8a1d76e42a4e4a1" + sha256: ff4bd291778c7417fe53fe24ee0d0a1f1ffe281a2d4ea887e7094f16e36eace7 url: "https://pub.dev" source: hosted - version: "0.3.1" + version: "0.3.0" analyzer_plugin: dependency: transitive description: @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.0.9" args: dependency: transitive description: @@ -57,30 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" - asn1lib: - dependency: transitive - description: - name: asn1lib - sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" - url: "https://pub.dev" - source: hosted - version: "1.6.5" async: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" - blurhash_dart: - dependency: transitive - description: - name: blurhash_dart - sha256: "43955b6c2e30a7d440028d1af0fa185852f3534b795cc6eb81fbf397b464409f" - url: "https://pub.dev" - source: hosted - version: "1.2.1" + version: "2.13.1" boolean_selector: dependency: transitive description: @@ -93,18 +77,18 @@ packages: dependency: transitive description: name: build - sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413 + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 url: "https://pub.dev" source: hosted - version: "4.0.3" + version: "4.0.6" build_config: dependency: transitive description: name: build_config - sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" build_daemon: dependency: transitive description: @@ -117,10 +101,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057" + sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" url: "https://pub.dev" source: hosted - version: "2.10.4" + version: "2.15.0" built_collection: dependency: transitive description: @@ -133,18 +117,26 @@ packages: dependency: transitive description: name: built_value - sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139" + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" url: "https://pub.dev" source: hosted - version: "8.12.1" + version: "8.12.6" + button_m3e: + dependency: transitive + description: + name: button_m3e + sha256: "6754ddeb9068ad2005bd26d5ceabc41268029465095686d7d228296c2e706909" + url: "https://pub.dev" + source: hosted + version: "0.1.2" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -161,14 +153,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" - ci: - dependency: transitive - description: - name: ci - sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" - url: "https://pub.dev" - source: hosted - version: "0.1.0" cli_config: dependency: transitive description: @@ -201,14 +185,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" - url: "https://pub.dev" - source: hosted - version: "4.11.1" collection: dependency: "direct main" description: @@ -253,10 +229,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" url: "https://pub.dev" source: hosted - version: "0.3.5+1" + version: "0.3.5+2" crypto: dependency: transitive description: @@ -273,30 +249,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" - custom_lint: - dependency: "direct dev" - description: - name: custom_lint - sha256: "751ee9440920f808266c3ec2553420dea56d3c7837dd2d62af76b11be3fcece5" - url: "https://pub.dev" - source: hosted - version: "0.8.1" - custom_lint_core: - dependency: transitive - description: - name: custom_lint_core - sha256: "85b339346154d5646952d44d682965dfe9e12cae5febd706f0db3aa5010d6423" - url: "https://pub.dev" - source: hosted - version: "0.8.1" - custom_lint_visitor: - dependency: transitive - description: - name: custom_lint_visitor - sha256: e466d17856197cf9bce7ca03804d784fddab809db7bda787f3d2799ac89faadd - url: "https://pub.dev" - source: hosted - version: "1.0.0+9.0.0" dart_style: dependency: transitive description: @@ -309,68 +261,67 @@ packages: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.11" - diffutil_dart: - dependency: transitive - description: - name: diffutil_dart - sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81" - url: "https://pub.dev" - source: hosted - version: "4.0.1" + version: "0.7.12" dio: dependency: transitive description: name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c url: "https://pub.dev" source: hosted - version: "5.9.0" + version: "5.9.2" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" + dynamic_color: + dependency: "direct main" + description: + name: dynamic_color + sha256: "43a5a6679649a7731ab860334a5812f2067c2d9ce6452cf069c5e0c25336c17c" + url: "https://pub.dev" + source: hosted + version: "1.8.1" dynamic_polls: dependency: "direct main" description: name: dynamic_polls - sha256: fba71ee6fb0ae8f3bebf7d07b3f2a79347d496956de88fb24d3daa32d47e0774 + sha256: "72ff19cdf041ad8dcfa76adaebb216d005f40b278d955e6e0c7bcb769215fabe" url: "https://pub.dev" source: hosted - version: "0.0.6" - dynamic_system_colors: - dependency: "direct main" - description: - path: "." - ref: HEAD - resolved-ref: "3b61760d5e0ac1229eefde5b61247947eede4110" - url: "https://github.com/hasali19/flutter_dynamic_system_colors" - source: git - version: "1.8.0" + version: "0.0.7" emoji_text_field: dependency: "direct main" description: path: "." ref: HEAD - resolved-ref: "0e90703a6e876939be70bd1816c49cf14474de61" + resolved-ref: "5f7baaf8a6f059ec3ab8ff0f5d02339b00bf6997" url: "https://github.com/Henry-Hiles/emoji_text_field" source: git version: "1.0.0" - encrypt: + equatable: dependency: transitive description: - name: encrypt - sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "2.0.8" + fab_m3e: + dependency: transitive + description: + name: fab_m3e + sha256: e4f5abfa3c8c092005449d56dcac45b85e2dbe9c32789d672c5ed71428e43b59 + url: "https://pub.dev" + source: hosted + version: "0.1.1" fake_async: dependency: transitive description: @@ -383,18 +334,18 @@ packages: dependency: "direct main" description: name: fast_immutable_collections - sha256: "19f70498af299cbce5ff919dbbecd5abfd9d0c28139004f68d3810ce23dedfb3" + sha256: "58cec99fc068427c71901e82d4b31b232240ebe6e61200993c2cb91bcada0ff6" url: "https://pub.dev" source: hosted - version: "11.1.0" + version: "11.2.0" ffi: dependency: "direct main" description: name: ffi - sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.2.0" ffigen: dependency: "direct main" description: @@ -415,10 +366,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: d974b6ba2606371ac71dd94254beefb6fa81185bde0b59bdc1df09885da85fde + sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 url: "https://pub.dev" source: hosted - version: "10.3.8" + version: "11.0.2" file_selector_linux: dependency: transitive description: @@ -464,22 +415,14 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_chat_core: + flutter_blurhash: dependency: "direct main" description: - name: flutter_chat_core - sha256: "8c46790f64f106bf6e610e2a7324b3844320e9e295867c06d45d9deb134d848d" + name: flutter_blurhash + sha256: e97b9aff13b9930bbaa74d0d899fec76e3f320aba3190322dcc5d32104e3d25d url: "https://pub.dev" source: hosted - version: "2.9.0" - flutter_chat_ui: - dependency: "direct main" - description: - name: flutter_chat_ui - sha256: cfbaac38f429beb33d9cc1ca920ae7ccbadbed282c99335d590d61306d3a3d0f - url: "https://pub.dev" - source: hosted - version: "2.11.1" + version: "0.9.1" flutter_hooks: dependency: "direct main" description: @@ -496,14 +439,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.14.4" - flutter_link_previewer: - dependency: "direct main" - description: - name: flutter_link_previewer - sha256: "346f345064e65bc8bf739bccf19d6d6ca50f8183ffc52e452afa58c06ee2cbf7" - url: "https://pub.dev" - source: hosted - version: "4.2.0" flutter_linkify: dependency: "direct main" description: @@ -529,10 +464,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" url: "https://pub.dev" source: hosted - version: "2.0.33" + version: "2.0.34" flutter_riverpod: dependency: "direct main" description: @@ -545,10 +480,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.3.0" flutter_test: dependency: transitive description: flutter @@ -563,50 +498,26 @@ packages: dependency: "direct main" description: name: flutter_widget_from_html_core - sha256: "1120ee6ed3509ceff2d55aa6c6cbc7b6b1291434422de2411b5a59364dd6ff03" + sha256: "7ff010b116f6abc16429923e616fbc727f3f65ef4cee12ffdb280aeecbc21e7f" url: "https://pub.dev" source: hosted - version: "0.17.0" + version: "0.17.2" fluttertagger: dependency: "direct main" description: name: fluttertagger - sha256: "3df0132bdd431a7279da78ea70500ea1e767fa093f43f32785b757c10c6a0fcc" + sha256: "04514674b41a063b97901aedf6970d0675b828bd723a0fb9f9dba89b91953382" url: "https://pub.dev" source: hosted - version: "2.3.1" - flyer_chat_file_message: - dependency: "direct main" - description: - name: flyer_chat_file_message - sha256: "96c5c25908cd671dda1963ade03e188e6a14bba6b116e73fac329f1abefc9ad1" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - flyer_chat_image_message: - dependency: "direct main" - description: - name: flyer_chat_image_message - sha256: "04730c9373c9c7315ba0e1a360c67ac5f6c7ec8a700ffe2d2dc00e29b7f8ff90" - url: "https://pub.dev" - source: hosted - version: "2.3.0" - flyer_chat_system_message: - dependency: "direct main" - description: - name: flyer_chat_system_message - sha256: d254f85be55949f8eb1a4a9a9b1c5b54ffed0c9a39dfa7e4fa6a6358bdb5d45a - url: "https://pub.dev" - source: hosted - version: "2.2.0" + version: "2.3.2" freezed: dependency: "direct dev" description: name: freezed - sha256: "03dd9b7423ff0e31b7e01b2204593e5e1ac5ee553b6ea9d8184dff4a26b9fb07" + sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131 url: "https://pub.dev" source: hosted - version: "3.2.4" + version: "3.2.5" freezed_annotation: dependency: "direct main" description: @@ -627,10 +538,10 @@ packages: dependency: transitive description: name: get_x_storage - sha256: c9c65de2baa228783f46a55137538dc599a3c9b1834130cfd3b417ec3b643813 + sha256: "69e4412dd70e25a4991623c10bf72e3b12106f2cb4353a2d167353947597f3aa" url: "https://pub.dev" source: hosted - version: "0.0.8" + version: "0.0.9" glob: dependency: transitive description: @@ -651,10 +562,10 @@ packages: dependency: "direct main" description: name: hooks - sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.3" hooks_riverpod: dependency: "direct main" description: @@ -695,38 +606,46 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + icon_button_m3e: + dependency: transitive + description: + name: icon_button_m3e + sha256: c4524d6141a468679821bbb635b833ac6831925d8a6ae4a4511430b0e4ab9c67 + url: "https://pub.dev" + source: hosted + version: "0.2.1" idb_shim: dependency: transitive description: name: idb_shim - sha256: b26b2ad126be411d0072d1dfc4d97ebe02121a863e4eadc635b511b9bc138489 + sha256: d46b09e116508e817f5ea2d8e1f6f55fb98bf7966175152809fd29791bfba3b8 url: "https://pub.dev" source: hosted - version: "2.7.1+2" + version: "2.9.1" image: dependency: transitive description: name: image - sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.7.2" + version: "4.8.0" image_picker: dependency: "direct main" description: name: image_picker - sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" + sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f url: "https://pub.dev" source: hosted - version: "0.8.13+10" + version: "0.8.13+17" image_picker_for_web: dependency: transitive description: @@ -739,10 +658,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 url: "https://pub.dev" source: hosted - version: "0.8.13+3" + version: "0.8.13+6" image_picker_linux: dependency: transitive description: @@ -791,30 +710,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" json_annotation: dependency: "direct main" description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.11.0" json_serializable: dependency: "direct dev" description: name: json_serializable - sha256: "6b253f7851cf1626a05c8b49c792e04a14897349798c03798137f2b5f7e0b5b1" + sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" url: "https://pub.dev" source: hosted - version: "6.11.3" + version: "6.13.0" leak_tracker: dependency: transitive description: @@ -840,21 +751,22 @@ packages: source: hosted version: "3.0.2" linkify: - dependency: transitive + dependency: "direct main" description: - name: linkify - sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" - url: "https://pub.dev" - source: hosted + path: "." + ref: "fix/consecutive-periods-loose-url" + resolved-ref: e990021f30b8535b462d41a39f37019045ae55f4 + url: "https://github.com/appelladev/linkify" + source: git version: "5.0.0" lints: dependency: transitive description: name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" logging: dependency: transitive description: @@ -863,22 +775,118 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + m3e_buttons: + dependency: "direct main" + description: + name: m3e_buttons + sha256: "50cdf9ba30fb3ab529afafb0e837484549f8599f1f109ac07da50951febaace1" + url: "https://pub.dev" + source: hosted + version: "0.0.3" + m3e_card_list: + dependency: "direct main" + description: + name: m3e_card_list + sha256: d4aba0123cccda40ac80789befa8d355e1dc16aa7dcee910157690b0546d78d6 + url: "https://pub.dev" + source: hosted + version: "0.1.0" + m3e_design: + dependency: transitive + description: + name: m3e_design + sha256: "15ff0ef4c43553d855c5e866a9aee8231d44919fe2bb354b1259337bdfd659b4" + url: "https://pub.dev" + source: hosted + version: "0.2.1" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" + measure_size: + dependency: "direct main" + description: + name: measure_size + sha256: "4b2de7b29567501434902a2f4080cf12a8bc7038b2eb97dfae91b71791620b68" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted + version: "2.0.1" meta: dependency: transitive description: @@ -895,14 +903,31 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - nested: + motor: dependency: transitive description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + name: motor + sha256: cbd49f21b00e568c2b1a55f134ed803614a107782f4fea7769693bca32940c58 url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.1.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + navigation_rail_m3e: + dependency: "direct main" + description: + path: "packages/navigation_rail_m3e" + ref: HEAD + resolved-ref: "667b0bc8526fd53296778903b6ef3f22424f3aa4" + url: "https://github.com/Henry-Hiles/material_3_expressive" + source: git + version: "0.3.5" node_preamble: dependency: transitive description: @@ -911,6 +936,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" package_config: dependency: transitive description: @@ -919,6 +952,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: "direct main" description: @@ -947,18 +996,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" url: "https://pub.dev" source: hosted - version: "2.2.22" + version: "2.2.23" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -987,10 +1036,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -1007,14 +1056,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" - url: "https://pub.dev" - source: hosted - version: "3.9.1" pool: dependency: transitive description: @@ -1027,18 +1068,10 @@ packages: dependency: transitive description: name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" url: "https://pub.dev" source: hosted - version: "6.0.3" - provider: - dependency: transitive - description: - name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" - url: "https://pub.dev" - source: hosted - version: "6.1.5+1" + version: "6.5.0" pub_semver: dependency: transitive description: @@ -1055,14 +1088,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" - punycode: - dependency: transitive - description: - name: punycode - sha256: "39b874cc1f78b94e57db17e74b3f2ba2a96e25c0bebdcc8a571614dccda0ff0c" - url: "https://pub.dev" - source: hosted - version: "1.0.0" quiver: dependency: transitive description: @@ -1071,6 +1096,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" riverpod: dependency: transitive description: @@ -1103,6 +1136,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "287ea1f667c0b93cdc127dccc707158e2d81ee59fba0459c31a0c7da4d09c755" + url: "https://pub.dev" + source: hosted + version: "2.0.3" screen_retriever: dependency: transitive description: @@ -1143,38 +1184,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" - scrollview_observer: - dependency: transitive - description: - name: scrollview_observer - sha256: "6e40ced415145c449a691d892157a3b854b751f024aed20d9aebda04c21444a3" - url: "https://pub.dev" - source: hosted - version: "1.26.3" sembast: dependency: transitive description: name: sembast - sha256: "139cf71496105de32e7a08a4e3a1ead0f81c4a616ec9703ed07e8f0d10cdd505" + sha256: "93654267ad36e72ef130ffc05970287f42955b40f07d0efd264e64f7215fa1de" url: "https://pub.dev" source: hosted - version: "3.8.6" + version: "3.8.7" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 url: "https://pub.dev" source: hosted - version: "2.4.18" + version: "2.4.23" shared_preferences_foundation: dependency: transitive description: @@ -1195,10 +1228,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" shared_preferences_web: dependency: transitive description: @@ -1253,21 +1286,21 @@ packages: source: sdk version: "0.0.0" source_gen: - dependency: "direct overridden" + dependency: transitive description: name: source_gen - sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75" + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.2.3" source_helper: dependency: transitive description: name: source_helper - sha256: e82b1996c63da42aa3e6a34cc1ec17427728a1baf72ed017717a5669a7123f0d + sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" url: "https://pub.dev" source: hosted - version: "1.3.9" + version: "1.3.12" source_map_stack_trace: dependency: transitive description: @@ -1288,10 +1321,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" stack_trace: dependency: transitive description: @@ -1332,14 +1365,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + super_sliver_list: + dependency: "direct main" + description: + name: super_sliver_list + sha256: b1e1e64d08ce40e459b9bb5d9f8e361617c26b8c9f3bb967760b0f436b6e3f56 + url: "https://pub.dev" + source: hosted + version: "0.4.1" synchronized: dependency: transitive description: name: synchronized - sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.4.0+1" term_glyph: dependency: transitive description: @@ -1352,34 +1393,26 @@ packages: dependency: transitive description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.12" - thumbhash: - dependency: transitive - description: - name: thumbhash - sha256: "5f6d31c5279ca0b5caa81ec10aae8dcaab098d82cb699ea66ada4ed09c794a37" - url: "https://pub.dev" - source: hosted - version: "0.1.0+1" + version: "0.6.16" timeago: dependency: "direct main" description: @@ -1412,6 +1445,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" url_launcher: dependency: "direct main" description: @@ -1424,18 +1473,18 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" url: "https://pub.dev" source: hosted - version: "6.3.28" + version: "6.3.29" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.3.6" + version: "6.4.1" url_launcher_linux: dependency: transitive description: @@ -1464,10 +1513,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.3" url_launcher_windows: dependency: transitive description: @@ -1480,18 +1529,18 @@ packages: dependency: transitive description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.5.3" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + sha256: "4d35a36400983c3457c289d4d553b5308f506ea84f7e51c7a564651b5525209a" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.1" vector_graphics_codec: dependency: transitive description: @@ -1504,10 +1553,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "98e7e94de127b46a86ef46197fff84ff99f3d3b80a708390d717ad731efef598" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.2" vector_math: dependency: transitive description: @@ -1520,10 +1569,26 @@ packages: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.2.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b + url: "https://pub.dev" + source: hosted + version: "1.5.1" watcher: dependency: transitive description: @@ -1608,10 +1673,10 @@ packages: dependency: transitive description: name: yaml_edit - sha256: ec709065bb2c911b336853b67f3732dd13e0336bd065cc2f1061d7610ddf45e3 + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.2.4" sdks: - dart: ">=3.10.4 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.11.5 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 7ecefa1..da8b1ec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: nexus description: "Yet another Matrix client" -version: 1.0.0 +version: 0.1.0 publish_to: none flutter: @@ -9,74 +9,80 @@ flutter: uses-material-design: true environment: - sdk: "^3.9.2" + sdk: "^3.11.5" dependency_overrides: - analyzer: ^8.4.0 - source_gen: ^4.0.2 - flutter_hooks: ^0.21.2 + linkify: + git: + url: https://github.com/appelladev/linkify + ref: fix/consecutive-periods-loose-url dependencies: flutter: sdk: flutter flutter_localizations: sdk: flutter - flutter_riverpod: ^3.3.1 - hooks_riverpod: ^3.3.1 - intl: ^0.20.1 - fast_immutable_collections: ^11.0.0 - path_provider: ^2.1.3 - url_launcher: ^6.2.6 - freezed_annotation: ^3.1.0 - image_picker: ^1.1.2 - file_picker: ^10.3.3 - path: ^1.9.0 - dynamic_system_colors: - git: - url: https://github.com/hasali19/flutter_dynamic_system_colors - collection: ^1.19.1 - window_manager: ^0.5.1 - flutter_chat_core: ^2.0.0 - flyer_chat_image_message: ^2.2.2 - flyer_chat_system_message: ^2.1.13 - flyer_chat_file_message: ^2.3.1 - flutter_chat_ui: ^2.11.1 - flutter_link_previewer: ^4.2.0 - color_hash: ^1.0.1 - flutter_widget_from_html_core: ^0.17.0 - flutter_svg: ^2.2.2 - json_annotation: ^4.9.0 - shared_preferences: ^2.5.3 - fluttertagger: ^2.3.1 - dynamic_polls: ^0.0.6 - flutter_hooks: ^0.21.3+1 - cross_cache: ^1.1.0 - ffi: ^2.1.5 - 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 + flutter_riverpod: 3.3.1 + hooks_riverpod: 3.3.1 + intl: 0.20.2 + fast_immutable_collections: 11.2.0 + path_provider: 2.1.5 + url_launcher: 6.3.2 + freezed_annotation: 3.1.0 + image_picker: 1.2.2 + file_picker: 11.0.2 + path: 1.9.1 + dynamic_color: 1.8.1 + collection: 1.19.1 + window_manager: 0.5.1 + color_hash: 1.0.1 + flutter_widget_from_html_core: 0.17.2 + flutter_svg: 2.3.0 + json_annotation: 4.11.0 + shared_preferences: 2.5.5 + fluttertagger: 2.3.2 + dynamic_polls: 0.0.7 + flutter_hooks: 0.21.3+1 + cross_cache: 1.1.0 + ffi: 2.2.0 + hooks: 1.0.3 + code_assets: 1.0.0 + ffigen: 20.1.1 + timeago: 3.7.1 + http: 1.6.0 + flutter_linkify: 6.0.0 + linkify: 5.0.0 emoji_text_field: git: url: https://github.com/Henry-Hiles/emoji_text_field + flutter_blurhash: 0.9.1 + super_sliver_list: 0.4.1 + media_kit: 1.2.6 + media_kit_video: 2.0.1 + media_kit_libs_video: 1.0.7 + measure_size: ^5.0.2 + m3e_buttons: ^0.0.3 + navigation_rail_m3e: + git: + url: https://github.com/Henry-Hiles/material_3_expressive + path: packages/navigation_rail_m3e + m3e_card_list: ^0.1.0 dev_dependencies: - build_runner: ^2.4.11 - custom_lint: ^0.8.0 - flutter_lints: ^6.0.0 - freezed: ^3.2.3 - riverpod_lint: ^3.1.3 - flutter_launcher_icons: ^0.14.1 - json_serializable: ^6.11.1 + build_runner: 2.15.0 + flutter_lints: 6.0.0 + freezed: 3.2.5 + riverpod_lint: 3.1.3 + flutter_launcher_icons: 0.14.4 + json_serializable: 6.13.0 flutter_launcher_icons: ios: true android: true image_path: assets/icon.png adaptive_icon_background: assets/background.png - adaptive_icon_foreground: assets/smallerForeground.png + adaptive_icon_foreground: assets/foreground.png + adaptive_icon_monochrome: assets/monochrome.png remove_alpha_ios: true windows: generate: true \ No newline at end of file diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index bde1c28..7938787 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,8 +6,10 @@ #include "generated_plugin_registrant.h" -#include +#include #include +#include +#include #include #include #include @@ -17,6 +19,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); + MediaKitVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 7b6b425..64ef5b5 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,8 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST - dynamic_system_colors + dynamic_color file_selector_windows + media_kit_libs_windows_video + media_kit_video screen_retriever_windows url_launcher_windows window_manager diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index e3c83c9..f8a91f7 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ