diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml deleted file mode 100644 index dc1e9c7..0000000 --- a/.github/workflows/android.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: "Build APK" - -on: - push: - branches: ["main"] - tags: ["*"] - workflow_dispatch: - -jobs: - build-apk: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - submodules: recursive - - - name: Lix GHA Installer Action - uses: samueldr/lix-gha-installer-action@v2026-02-22 - with: - extra_nix_config: experimental-features = nix-command flakes flake-self-attrs - - - name: Decode keystore - run: echo "$KEYSTORE_CONTENT" | base64 --decode > keystore.jks - env: - KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }} - - - name: Build app - run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --release" - env: - KEYSTORE_PATH: ../../keystore.jks - KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} - - - name: Upload installer artifact - uses: actions/upload-artifact@v6 - with: - name: APK - path: build/app/outputs/flutter-apk/app-release.apk \ No newline at end of file diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml deleted file mode 100644 index 5e693f0..0000000 --- a/.github/workflows/flatpak.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: "Build Flatpaks" - -on: - push: - branches: ["main"] - tags: ["*"] - workflow_dispatch: - -jobs: - build-flatpak: - strategy: - fail-fast: false - matrix: - include: - - arch: x86_64 - runner: ubuntu-latest - - arch: aarch64 - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Lix GHA Installer Action - uses: samueldr/lix-gha-installer-action@v2026-02-22 - with: - extra_nix_config: experimental-features = nix-command flakes flake-self-attrs - - - name: Build app - run: nix build .#flatpak - - - name: Upload installer artifact - uses: actions/upload-artifact@v6 - with: - name: flatpak-${{ matrix.arch }} - path: result/nexus.federated.Nexus.flatpak \ No newline at end of file diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index c010486..c8099d1 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -1,71 +1,46 @@ -name: "Build EXE" +name: "Build Windows Version" on: - push: - branches: ["main"] - tags: ["*"] workflow_dispatch: jobs: - build-exe: - runs-on: windows-latest + build-windows: + runs-on: "windows-latest" steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - submodules: recursive + - name: "Checkout repository" + uses: "actions/checkout@v4" - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - flutter-version: 3.41.9 + - name: "Set up Flutter" + uses: "subosito/flutter-action@v2" - - name: Set up Go - uses: actions/setup-go@v6 + - name: "Set up Rust" + uses: "dtolnay/rust-toolchain@stable" with: - go-version-file: gomuks/go.mod + targets: "x86_64-pc-windows-msvc" - - name: Setup MSYS2 - uses: msys2/setup-msys2@v2 - with: - msystem: MINGW64 - install: >- - mingw-w64-x86_64-gcc + - name: "Install Flutter dependencies" + run: flutter pub get - - name: Go build + - name: "Run build_runner & build Windows EXE" run: | - cd gomuks/pkg/ffi - go build -tags goolm,sqlite_fts5 -o ../../../libgomuks.dll -buildmode=c-shared - - - name: Build with Flutter - run: | - flutter pub get - dart scripts/generate.dart - flutter pub run build_runner build + flutter pub run build_runner build --delete-conflicting-outputs 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 + - name: "Upload exe zip" + uses: "actions/upload-artifact@v4" with: - name: windows-portable - path: build/windows/x64/runner/Release/ + name: "windows-portable" + path: "build/windows/x64/runner/Release/" - - name: Install Inno Setup + - name: "Install Inno Setup" run: choco install innosetup -y - - name: Build Inno Setup installer + - name: "Build Inno Setup installer" run: iscc windows/installer.iss - - name: Upload installer artifact - uses: actions/upload-artifact@v6 + - name: "Upload installer artifact" + uses: "actions/upload-artifact@v4" with: - name: windows-installer - path: windows/dist/Nexus-Setup.exe \ No newline at end of file + name: "windows-installer" + path: "windows/dist/Nexus-Setup.exe" diff --git a/.gitignore b/.gitignore index 2bec583..d6616e1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,9 +36,7 @@ key.properties # Generated Files *.g.dart *.freezed.dart +src/ # Devel Password -password.txt - -# Nix -/result \ No newline at end of file +password.txt \ No newline at end of file diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 145276a..0000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "gomuks"] - path = gomuks - url = https://github.com/gomuks/gomuks - branch = main diff --git a/.vscode/settings.json b/.vscode/settings.json index 068916b..25ea52b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,14 +2,8 @@ "cSpell.words": [ "Appbar", "Displayname", - "fluttertagger", - "Gomuks", "Homeserver", - "Linkified", - "localpart", - "msgtype", - "muks", "prefs", - "unban" + "vodozemac" ] } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md deleted file mode 100644 index bfd78a8..0000000 --- a/DEVELOPMENT.md +++ /dev/null @@ -1,70 +0,0 @@ -# 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 bb61124..4f73220 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # Nexus Client > [!WARNING] -> Nexus Client is still in development, and doesn't support everything needed for daily use. +> Nexus Client is still heavily in development, and is not ready for use! ## Description -A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. +A simple and user-friendly Matrix client made with Flutter and the Matrix Dart SDK. ## Screenshots @@ -15,186 +15,135 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. ## Progress -- [ ] Platform Support - - [x] Linux - - [x] Windows - - [x] Android - - [ ] MacOS - - [ ] iOS - - [ ] Web (may not be possible) -- [x] Login - - [x] Username / password auth - - [ ] OAuth / OIDC - - [x] Improve initial sync experience -- [x] Rooms / Spaces - - [x] Displaying and choosing - - [x] Reading, showing unread - - [x] Mark as read button on rooms and spaces - - [ ] Searching - - [ ] Creating (Rooms, Spaces, and DMs) - - [x] Joining - - [x] Parse vias - - [x] Using a text/uri/link - - [x] Plain text - - [x] `matrix:` Uri - - [x] Matrix.to link - - [ ] From space - - [ ] From directory - - [x] Leaving - - [x] Subspaces -- [x] Messages - - [x] Encryption - - [x] Restoring crypto identity from a recovery passphrase/key - - [x] Sending - - [x] Plain text - - [x] HTML/Markdown - - [x] Replies - - [x] Choose ping on/off - - [x] Per message profiles - - [ ] Attachments - - [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391) - - [x] Mentions - - [x] Users - - [x] Rooms - - [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions) - - [ ] Custom emojis/stickers - - [ ] GIFs using Gomuks' GIF proxies - - [x] Receiving - - [x] Plain text - - [x] Per message profiles - - [x] HTML - - [x] URL Previews - - [x] Replies - - [x] Viewing - - [ ] Jump to original message - - [x] In loaded timeline - - [ ] Out of loaded timeline - - [x] Edits - - [x] Attachments - - [x] Unencrypted - - [ ] Encrypted - - [x] Blurhashing - - [ ] Downloading attachments - - [x] Opening attachments in their own view - - [ ] Polls - - [x] Mentions - - [x] Users - - [x] Clickable - - [x] Rooms - - [ ] Clickable - - [x] Matrix URIs - - [x] Matrix.to links - - [x] Events - - [ ] Render more nicely - - [ ] Clickable - - [x] Custom emojis/stickers - - [x] History loading - - [x] Backwards - - [ ] Forwards - - [x] Editing - - [x] Deleting -- [x] Reactions -- [ ] Pins - - [ ] Displaying - - [ ] Creating -- [ ] Threads -- [x] Profile popouts - - [x] Working actions -- [x] Copy link to: - - [x] Room - - [x] Space - - [x] Message -- [ ] Reporting - - [x] Events - - [ ] Rooms -- [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 ([#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 - - [ ] URL preview: Server / Sending Client (Beeper spec) / None - - [ ] Account changes - - [ ] Display name - - [ ] Profile picture - - [ ] Timezone - - [ ] Pronouns - - [ ] Password - - [ ] About - - [x] Log Out +- [ ] New logo +- [ ] Make context menus appear as bottom sheets on mobile +- [x] Move from the Dart SDK to the Gomuks SDK with Dart bindings: https://git.federated.nexus/Henry-Hiles/nexus/pulls/2 + - [ ] Allow using remote gomuks over websocket +- [ ] Platform Support + - [x] Linux + - [x] Windows + - [ ] MacOS + - [ ] Android + - [ ] iOS + - [ ] Web (may not be possible) +- [x] Login + - [x] Username / password auth + - [ ] OAuth / OIDC + - [x] Improve initial sync experience +- [x] Rooms / Spaces + - [x] Displaying and choosing + - [x] Reading, showing unread + - [x] Mark as read button on rooms and spaces + - [ ] Searching + - [ ] Creating (Rooms, Spaces, and DMs) + - [x] Joining + - [ ] Parse vias + - [x] Using a text/uri/link + - [x] Plain text + - [x] `matrix:` Uri + - [x] Matrix.to link + - [ ] From space + - [ ] Exploring + - [x] Leaving + - [x] Subspaces +- [x] Messages + - [x] Encryption + - [x] Restoring crypto identity from a recovery passphrase/key + - [x] Sending + - [x] Plain text + - [x] HTML/Markdown + - [x] Replies + - [x] Choose ping on/off + - [ ] Per message profiles + - [ ] Attachments + - [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391) + - [x] Mentions + - [x] Users + - [x] Rooms + - [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions) + - [ ] Custom emojis/stickers + - [ ] GIFs using Gomuks' GIF proxies + - [x] Recieving + - [x] Plain text + - [x] Per message profiles + - [x] HTML + - [x] Replies + - [x] Viewing + - [ ] Jump to original message + - [x] In loaded timeline + - [ ] Out of loaded timeline + - [x] Edits + - [x] Attachments + - [x] Unencrypted + - [ ] Encrypted + - [x] Blurhashing + - [ ] Downloading attachments + - [x] Opening attachments in their own view + - [ ] Polls: Waiting on https://github.com/SwanFlutter/dynamic_polls/issues/1 + - [x] Mentions + - [x] Users + - [x] Rooms + - [ ] Plain text (not sure if I want to add this or not, I probably won't unless there's interest) + - [x] Matrix URIs + - [x] Matrix.to links + - [ ] Do some fancy fetching to get nice names + - [ ] Make clickable + - [x] Custom emojis/stickers + - [x] History loading + - [x] Backwards + - [ ] Forwards + - [x] Editing + - [x] Deleting +- [ ] Reactions: Waiting on https://github.com/flyerhq/flutter_chat_ui/pull/838 or me doing a custom impl +- [ ] Pins + - [ ] Displaying + - [ ] Creating +- [ ] Threads +- [ ] Profile popouts +- [ ] Copy link to [room, space] +- [ ] Reporting + - [x] Events + - [ ] Rooms +- [ ] Notifications using UnifiedPush +- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) +- [ ] Invites +- [ ] Settings + - [ ] Light/Dark mode + - [ ] SSD or CSD + - [ ] Show media by default + - [ ] Dynamic Theming + - [ ] Devices + - [ ] Viewing devices + - [ ] Verifying devices + - [ ] URL preview: Server / Client / None + - [ ] Account changes + - [ ] Display name + - [ ] Profile picture + - [ ] Timezone + - [ ] Pronouns + - [ ] Password + - [ ] About + - [x] Log Out -## Try it out +## Build Instructions -If you want to try out Nexus, grab one of the following artifacts from CI: +First, clone and open the repo: -- [Android APK](https://nightly.link/Henry-Hiles/nexus/workflows/android/main/APK.zip) -- Windows - - [Portable Build](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-portable.zip) - - [Installer](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip) -- Flatpak - - [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip) - - [x86_64/AMD64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-x86_64.zip) - -Or, try the Nix package: `nix run git+https://git.federated.nexus/Nexus/nexus` - -## Build it yourself +```sh +git clone https://git.federated.nexus/Henry-Hiles/nexus +cd nexus +``` ### Prerequisites #### Linux -- With Nix: Either use direnv and `direnv allow`, or `nix flake develop` -- Without Nix: Install Flutter, Go, Git, Libclang, and Glibc. Do not use any Snap packages, they cause various compilation issues. +- With Nix: Either use direnv, or `nix flake develop` +- Without Nix: Install Flutter, Go, Olm, Git, Clang, and GLibc. -#### Windows +#### Windows / MacOS -You will need: - -- Flutter -- Android SDK + NDK -- Git -- Go -- Visual Studio 2022 (Desktop development with C++) -- [MSYS2/MinGW-w64 GCC](https://www.msys2.org/) (for CGO) -- [LLVM/Clang + libclang](https://clang.llvm.org/get_started.html) (for `ffigen`) - -On Windows, make sure these are available in your shell `PATH`: - -- `C:\msys64\ucrt64\bin` (or your MinGW bin path containing `x86_64-w64-mingw32-gcc.exe`) -- `C:\Program Files\LLVM\bin` (contains `clang.exe` and `libclang.dll`) - -For `dart scripts/generate.dart`, you may also need: - -```powershell -$env:CPATH = "C:\msys64\ucrt64\include" -``` - -#### MacOS - -Similar prerequisites apply (Flutter, Git, Go, C toolchain, LLVM/libclang), but exact setup has not been fully documented yet. - -### Clone repo - -First, clone and open the repo: - -```sh -git clone --recurse-submodules https://git.federated.nexus/Nexus/nexus -cd nexus -``` +I don't really know. You will need Flutter, Git, Olm, Go, and Visual Studio tools, and otherwise I guess just keep installing stuff until there aren't any errors. I will look into this sometimeTM. ### Set up Flutter @@ -204,23 +153,22 @@ Get dependencies: flutter pub get ``` -Generate Gomuks bindings: +Get dependencies: ```sh -dart scripts/generate.dart +flutter pub get ``` -> [!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" -> ``` +Clone Gomuks and generate bindings: + +```sh +scripts/generate.sh +``` Build generated files, and watch for new changes: ```sh -flutter pub run build_runner watch +flutter pub run build_runner watch --delete-conflicting-outputs ``` Run the app: @@ -229,13 +177,6 @@ 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 deleted file mode 100644 index 874adb1..0000000 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index e74a72b..57feff2 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png deleted file mode 100644 index f65add9..0000000 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png deleted file mode 100644 index f1bd7fc..0000000 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png index 288167d..8bb5bb4 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png deleted file mode 100644 index 624519b..0000000 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png deleted file mode 100644 index 1550061..0000000 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png index d1aaece..df784a1 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png deleted file mode 100644 index 81a2593..0000000 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png deleted file mode 100644 index 6a9cce4..0000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index 3277b43..d732425 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png deleted file mode 100644 index d10b599..0000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png deleted file mode 100644 index 7d9e6cb..0000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png index 9380189..963a077 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png deleted file mode 100644 index 0ffb5be..0000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png and /dev/null 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 854fef6..c79c58a 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,14 +1,9 @@ - + - - - 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 e97fe0e..80efd04 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 4e9192d..b02e5ef 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 f18b718..54aed69 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 2f6a559..eb2221d 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 0118074..c5ac464 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 deleted file mode 100644 index 9f1d8e7..0000000 Binary files a/assets/background.png and /dev/null differ diff --git a/assets/background.svg b/assets/background.svg deleted file mode 100644 index 749e03a..0000000 --- a/assets/background.svg +++ /dev/null @@ -1,257 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/foreground.png b/assets/foreground.png index a98eb11..4249989 100644 Binary files a/assets/foreground.png and b/assets/foreground.png differ diff --git a/assets/foreground.svg b/assets/foreground.svg index 9aad561..4f2f2b2 100644 --- a/assets/foreground.svg +++ b/assets/foreground.svg @@ -1,19 +1,20 @@ + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + inkscape:current-layer="layer1" /> diff --git a/assets/icon.png b/assets/icon.png index d6d4906..04b75cb 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/assets/icon.svg b/assets/icon.svg index b36fa26..0effd9a 100644 --- a/assets/icon.svg +++ b/assets/icon.svg @@ -1,22 +1,21 @@ + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + id="stop11" /> diff --git a/assets/mobile.png b/assets/mobile.png deleted file mode 100644 index 6b1b81c..0000000 Binary files a/assets/mobile.png and /dev/null differ diff --git a/assets/mobile.svg b/assets/mobile.svg deleted file mode 100644 index 7ca0a7d..0000000 --- a/assets/mobile.svg +++ /dev/null @@ -1,156 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/monochrome.png b/assets/monochrome.png deleted file mode 100644 index 941c706..0000000 Binary files a/assets/monochrome.png and /dev/null differ diff --git a/assets/monochrome.svg b/assets/monochrome.svg deleted file mode 100644 index a86f36e..0000000 --- a/assets/monochrome.svg +++ /dev/null @@ -1,178 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/screenshotDark.png b/assets/screenshotDark.png index 322a64e..ae75dc7 100644 Binary files a/assets/screenshotDark.png and b/assets/screenshotDark.png differ diff --git a/assets/screenshotLight.png b/assets/screenshotLight.png index 8772bcf..0b2ce0d 100644 Binary files a/assets/screenshotLight.png and b/assets/screenshotLight.png differ diff --git a/flake.lock b/flake.lock index d6167fb..7826732 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1778716662, - "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", + "lastModified": 1767609335, + "narHash": "sha256-feveD98mQpptwrAEggBQKJTYbvwwglSbOv53uCfH9PY=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", + "rev": "250481aafeb741edfe23d29195671c19b36b6dca", "type": "github" }, "original": { @@ -18,54 +18,17 @@ "type": "github" } }, - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "nix2flatpak": { - "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" - }, - "locked": { - "lastModified": 1774860670, - "narHash": "sha256-YjJkQrvxrErXtfDi3obUn6rNmkA+CIAZ3f5NgL5xuYE=", - "owner": "neobrain", - "repo": "nix2flatpak", - "rev": "61d68e21e3fbc2d57590051f48736bea271f4aba", - "type": "github" - }, - "original": { - "owner": "neobrain", - "repo": "nix2flatpak", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1773389992, - "narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=", - "owner": "NixOS", + "lastModified": 1767640445, + "narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=", + "owner": "nixos", "repo": "nixpkgs", - "rev": "c06b4ae3d6599a672a6210b7021d699c351eebda", + "rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5", "type": "github" }, "original": { - "owner": "NixOS", + "owner": "nixos", "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" @@ -73,11 +36,11 @@ }, "nixpkgs-lib": { "locked": { - "lastModified": 1777168982, - "narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=", + "lastModified": 1765674936, + "narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14", + "rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85", "type": "github" }, "original": { @@ -86,42 +49,10 @@ "type": "github" } }, - "nixpkgs_2": { - "locked": { - "lastModified": 1778869304, - "narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "d233902339c02a9c334e7e593de68855ad26c4cb", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, "root": { "inputs": { "flake-parts": "flake-parts", - "nix2flatpak": "nix2flatpak", - "nixpkgs": "nixpkgs_2" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" + "nixpkgs": "nixpkgs" } } }, diff --git a/flake.nix b/flake.nix index 4c06ac6..4922db7 100644 --- a/flake.nix +++ b/flake.nix @@ -2,10 +2,8 @@ description = "Nexus Flutter Flake"; inputs = { - self.submodules = true; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; - nix2flatpak.url = "github:neobrain/nix2flatpak"; }; outputs = @@ -40,37 +38,29 @@ }; }; - packages = + devShells = let - default = pkgs.callPackage ./linux/nix/pkg { - src = self; + packages = with pkgs; [ + go + git + ]; + + env = { + LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ]; + LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; + CPATH = lib.makeSearchPath "include" [ pkgs.glibc.dev ]; }; in { - inherit default; - - flatpak = inputs.nix2flatpak.lib.${system}.mkFlatpak { - appName = "Nexus"; - developer = "QuadRadical"; - appId = "nexus.federated.Nexus"; - package = default; - runtime = "org.gnome.Platform/49"; - permissions = { - share = [ "network" ]; - sockets = [ - "fallback-x11" - "wayland" - ]; - devices = [ "dri" ]; - }; + default = pkgs.mkShell { + inherit env; + packages = packages ++ [ + pkgs.flutter + ]; }; - gomuks = pkgs.callPackage ./linux/nix/pkg/gomuks.nix { - src = self; - }; + nix = pkgs.mkShell { inherit packages env; }; }; - - devShells.default = pkgs.callPackage ./linux/nix/devshell.nix { }; }; }; } diff --git a/gomuks b/gomuks deleted file mode 160000 index 819f5d6..0000000 --- a/gomuks +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 819f5d69f068ddedb08fe732f50570e33dd19e0f diff --git a/hook/build.dart b/hook/build.dart index 2324736..fca2270 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -3,7 +3,9 @@ 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 buildDir = input.packageRoot.resolve("src/"); + if (await File(buildDir.resolve("lock").toFilePath()).exists()) return; + final codeConfig = input.config.code; final targetOS = codeConfig.targetOS; final targetArch = codeConfig.targetArchitecture; @@ -25,11 +27,10 @@ Future main(List args) => build(args, (input, output) async { final targetNdkApi = codeConfig.android.targetNdkApi; - final ndkHome = - Platform.environment["ANDROID_NDK_HOME"] ?? - Platform.environment["ANDROID_NDK_ROOT"] ?? - Platform.environment["NDK_HOME"] ?? - await _findNdkFromSdk(); + final ndkHome = Platform.environment["ANDROID_NDK_HOME"] + ?? Platform.environment["ANDROID_NDK_ROOT"] + ?? Platform.environment["NDK_HOME"] + ?? await _findNdkFromSdk(); if (ndkHome == null) { throw Exception( "Could not find Android NDK. Set ANDROID_NDK_HOME or install via sdkmanager.", @@ -38,43 +39,37 @@ Future main(List args) => build(args, (input, output) async { final hostTag = _ndkHostTag(); final (goArch, ccTriple) = _androidArch(targetArch); - final cc = - "$ndkHome/toolchains/llvm/prebuilt/$hostTag/bin/$ccTriple$targetNdkApi-clang"; + final cc = "$ndkHome/toolchains/llvm/prebuilt/$hostTag/bin/$ccTriple$targetNdkApi-clang"; - env = {"CGO_ENABLED": "1", "GOOS": "android", "GOARCH": goArch, "CC": cc}; + env = { + "CGO_ENABLED": "1", + "GOOS": "android", + "GOARCH": goArch, + "CC": cc, + }; break; default: throw UnsupportedError("Unsupported OS: $targetOS"); } - var libFile = input.packageRoot.resolve(libFileName); - final gomuksBuildDir = input.packageRoot.resolve("gomuks/"); + final gomuksBuildDir = buildDir.resolve("gomuks/"); + final libFile = gomuksBuildDir.resolve("${targetArch.name}/$libFileName"); - if (!(await File.fromUri(libFile).exists())) { - final buildDir = input.packageRoot.resolve("build/"); - libFile = buildDir.resolve("${targetArch.name}/$libFileName"); + // goheif/dav1d supported on Android would need to fix upstream + final tags = targetOS == OS.android ? "goolm,noheic" : "goolm"; - // goheif/dav1d supported on Android would need to fix upstream - final tags = [ - "sqlite_fts5", - "goolm", - if (targetOS == OS.android) "noheic", - ].join(","); - print( - "Building Gomuks shared library $libFileName (${targetOS.name}/${targetArch.name}) to ${libFile.path}...", - ); - final result = await Process.run( - "go", - ["build", "-tags", tags, "-o", libFile.path, "-buildmode=c-shared"], - workingDirectory: gomuksBuildDir.resolve("pkg/ffi/").toFilePath(), - environment: env.isNotEmpty ? env : null, - ); + print("Building Gomuks shared library $libFileName (${targetOS.name}/${targetArch.name}) from source..."); + final result = await Process.run("go", [ + "build", + "-tags", tags, + "-o", + libFile.path, + "-buildmode=c-shared", + ], workingDirectory: gomuksBuildDir.resolve("source/pkg/ffi/").toFilePath(), + environment: env.isNotEmpty ? env : null); - if (result.exitCode != 0) { - throw Exception( - "Failed to build Gomuks shared library\n${result.stderr}", - ); - } + if (result.exitCode != 0) { + throw Exception("Failed to build Gomuks shared library\n${result.stderr}"); } final generatedFile = "src/third_party/gomuks.g.dart"; @@ -95,9 +90,8 @@ Future main(List args) => build(args, (input, output) async { Future _findNdkFromSdk() async { // pretty sure this wont be needed with nix, i'll get this removed - final androidHome = - Platform.environment["ANDROID_HOME"] ?? - Platform.environment["ANDROID_SDK_ROOT"]; + final androidHome = Platform.environment["ANDROID_HOME"] + ?? Platform.environment["ANDROID_SDK_ROOT"]; if (androidHome == null) return null; final ndkDir = Directory("$androidHome/ndk"); if (!await ndkDir.exists()) return null; diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 44f96da..d4af5a1 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -387,7 +387,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.Nexus; + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -519,7 +519,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.Nexus; + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -545,7 +545,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.Nexus; + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; 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 0d531c4..2b21522 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 da4acee..8471cd6 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 a3cfb1d..c145b15 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 adbdcd5..5da5679 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 fee4302..cd2b74f 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 4d21624..68cbdbf 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 3e7a859..306efe8 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 a3cfb1d..c145b15 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 c11ce99..959cc28 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 25f2b47..d86b69c 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 8f79bb9..3a5c49b 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 c48dec6..e563327 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 99d44e8..30ae8c6 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 6f987f0..2fb68c4 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 25f2b47..d86b69c 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 fcf969a..151862a 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 1e0defa..c5ca065 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 3366fb5..a5880bd 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 e280112..6ea8156 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 efda04b..657cf77 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 3774574..87d1ce7 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 0926e08..125d7cf 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() => .new(); + IMap build() => const IMap.empty(); void update(IMap newData) => - state = .new({...state.unlock, ...newData.unlock}); + state = IMap({...state.unlock, ...newData.unlock}); static final provider = NotifierProvider>( diff --git a/lib/controllers/author_controller.dart b/lib/controllers/author_controller.dart index 77202ca..c7e4e05 100644 --- a/lib/controllers/author_controller.dart +++ b/lib/controllers/author_controller.dart @@ -1,30 +1,44 @@ import "dart:async"; +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/user_controller.dart"; -import "package:nexus/models/content/membership.dart"; -import "package:nexus/models/event.dart"; +import "package:nexus/controllers/members_controller.dart"; +import "package:nexus/models/configs/author_config.dart"; +import "package:nexus/models/membership.dart"; -class AuthorController extends AsyncNotifier { - final Event event; - AuthorController(this.event); +class AuthorController extends AsyncNotifier { + final AuthorConfig config; + AuthorController(this.config); @override - Future build() async { - final member = await ref.watch( - UserController.provider( - .new(roomId: event.roomId, userId: event.sender), - ).future, + Future build() async { + var member = await ref.watch( + MembersController.provider(config.room).selectAsync( + (value) => value.firstWhereOrNull( + (membership) => membership.userId == config.message.authorId, + ), + ), ); - return .new( - status: member.status, - avatarUrl: event.pmp?.avatarUrl ?? member.avatarUrl, - displayName: event.pmp?.displayName ?? member.displayName, + final pmp = config.message.metadata?["pmp"] == null + ? null + : Membership.fromContent( + IMap(config.message.metadata?["pmp"]), + config.message.authorId, + ); + + return Membership( + avatarUrl: pmp?.avatarUrl ?? member?.avatarUrl, + displayName: + pmp?.displayName ?? + member?.displayName ?? + config.message.authorId.substring(1).split(":").first, + userId: config.message.authorId, ); } - static final provider = - AsyncNotifierProvider.family( + static final provider = AsyncNotifierProvider.family + .autoDispose( AuthorController.new, ); } diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index fb57735..02fdbdc 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -1,7 +1,8 @@ +import "dart:developer"; import "dart:ffi"; import "dart:io"; import "dart:isolate"; -import "dart:math"; +import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:ffi/ffi.dart"; import "package:flutter/foundation.dart"; @@ -13,7 +14,7 @@ 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"; @@ -25,11 +26,10 @@ import "package:nexus/models/profile.dart"; import "package:nexus/models/requests/paginate_request.dart"; import "package:nexus/models/requests/redact_event_request.dart"; import "package:nexus/models/requests/report_request.dart"; -import "package:nexus/models/requests/send_event_request.dart"; import "package:nexus/models/requests/send_message_request.dart"; -import "package:nexus/models/requests/set_membership_request.dart"; import "package:nexus/models/room.dart"; import "package:nexus/models/sync_data.dart"; +import "package:nexus/models/sync_status.dart"; import "package:nexus/src/third_party/gomuks.g.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:path_provider/path_provider.dart"; @@ -64,27 +64,15 @@ class ClientController extends AsyncNotifier { case "client_state": ref .watch(ClientStateController.provider.notifier) - .set(.fromJson(decodedMuksEvent)); + .set(ClientState.fromJson(decodedMuksEvent)); break; case "sync_status": ref .watch(SyncStatusController.provider.notifier) - .set(.fromJson(decodedMuksEvent)); + .set(SyncStatus.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(), - ); - break; case "sync_complete": final syncData = SyncData.fromJson(decodedMuksEvent); @@ -124,12 +112,8 @@ class ClientController extends AsyncNotifier { } debugPrint("Finished handling $muksEventType..."); } catch (error, stackTrace) { - if (kDebugMode) { - debugPrintStack(stackTrace: stackTrace, label: error.toString()); - rethrow; - } else { - showError(error, stackTrace); - } + debugger(); + debugPrintStack(stackTrace: stackTrace, label: error.toString()); } }); @@ -166,18 +150,15 @@ class ClientController extends AsyncNotifier { Future redactEvent(RedactEventRequest report) => _sendCommand("redact_event", report.toJson()); - Future sendMessage(SendMessageRequest request) async => - Event.fromJson(await _sendCommand("send_message", request.toJson())); + Future sendMessage(SendMessageRequest request) => + _sendCommand("send_message", request.toJson()); - Future sendEvent(SendEventRequest request) async => - Event.fromJson(await _sendCommand("send_event", request.toJson())); - - Future verify(String recoveryKey) async { + Future verify(String recoveryKey) async { try { await _sendCommand("verify", {"recovery_key": recoveryKey}); - return null; + return true; } catch (error) { - return error.toString(); + return false; } } @@ -202,15 +183,9 @@ class ClientController extends AsyncNotifier { // })); Future> getRoomState(GetRoomStateRequest request) async { - Future getState(GetRoomStateRequest request) async => - (await _sendCommand("get_room_state", request.toJson())) as List?; - final response = await getState(request); - - return .new( - (response ?? await getState(request.copyWith(refetch: true)) ?? []).map( - (event) => .fromJson(event), - ), - ); + final response = + (await _sendCommand("get_room_state", request.toJson())) as List; + return response.map((event) => Event.fromJson(event)).toIList(); } Future?> getRelatedEvents( @@ -218,31 +193,32 @@ class ClientController extends AsyncNotifier { ) async { final response = (await _sendCommand("get_related_events", request.toJson())) as List?; - return .new(response?.map((event) => .fromJson(event))); + return response?.map((event) => Event.fromJson(event)).toIList(); } 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 : .fromJson(json); + return json == null ? null : Event.fromJson(json); } Future paginate(PaginateRequest request) async => - .fromJson(await _sendCommand("paginate", request.toJson())); + Paginate.fromJson(await _sendCommand("paginate", request.toJson())); - Future getProfile(String userId) async { - final json = await _sendCommand("get_profile", {"user_id": userId}); - return .fromJsonWithCatch({...json, "id": userId}); - } + Future getProfile(String userId) async => + Profile.fromJson(await _sendCommand("get_profile", {"user_id": userId})); - Future reportEvent(ReportRequest request) => - _sendCommand("report_event", request.toJson()); - - Future setMembership(SetMembershipRequest request) => - _sendCommand("set_membership", request.toJson()); + Future reportEvent(ReportRequest report) => + _sendCommand("report_event", report.toJson()); Future markRead(Room room) async { - final eventRowId = room.timeline[room.timeline.keys.reduce(max)]; - final event = eventRowId == null ? null : room.events[eventRowId]; + final event = room.events.firstWhereOrNull( + (event) => event.rowId == room.timeline.last.eventRowId, + ); if (event == null || room.metadata == null) return; await _sendCommand("mark_read", { @@ -252,21 +228,21 @@ class ClientController extends AsyncNotifier { }); } - Future login(LoginRequest login) async { + Future login(LoginRequest login) async { try { await _sendCommand("login", login.toJson()); - return null; + return true; } catch (error) { - return error.toString(); + return false; } } - Future discoverHomeserver(Uri homeserver) async { + Future discoverHomeserver(Uri homeserver) async { try { final response = await _sendCommand("discover_homeserver", { - "user_id": "@fake-user:${homeserver.host}", + "user_id": "@fakeuser:${homeserver.host}", }); - return Uri.parse(response["m.homeserver"]?["base_url"]); + return 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 1b77ecb..998d4a1 100644 --- a/lib/controllers/client_state_controller.dart +++ b/lib/controllers/client_state_controller.dart @@ -5,7 +5,9 @@ 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 4d5611a..1d6d4b6 100644 --- a/lib/controllers/cross_cache_controller.dart +++ b/lib/controllers/cross_cache_controller.dart @@ -2,8 +2,11 @@ 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() => .new(); + CrossCache build() => CrossCache(); static final provider = NotifierProvider( CrossCacheController.new, diff --git a/lib/controllers/emoji_controller.dart b/lib/controllers/emoji_controller.dart deleted file mode 100644 index caea3de..0000000 --- a/lib/controllers/emoji_controller.dart +++ /dev/null @@ -1,84 +0,0 @@ -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 94992ca..4f72963 100644 --- a/lib/controllers/event_controller.dart +++ b/lib/controllers/event_controller.dart @@ -1,7 +1,5 @@ -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"; @@ -11,18 +9,8 @@ class EventController extends AsyncNotifier { @override Future build() async { - 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); + final client = ref.watch(ClientController.provider.notifier); + return await client.getEvent(request).onError((_, _) => null); } static final provider = AsyncNotifierProvider.family diff --git a/lib/controllers/key_controller.dart b/lib/controllers/key_controller.dart index 59d49ca..946892e 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? value) async { + Future set(String? id) async { final prefs = ref.watch(SharedPrefsController.provider).requireValue; - state = value; + state = id; - if (value == null) { + if (id == null) { prefs.remove(key); } else { - prefs.setString(key, value); + prefs.setString(key, id); } } diff --git a/lib/controllers/members_by_status_controller.dart b/lib/controllers/members_by_status_controller.dart deleted file mode 100644 index 2b49903..0000000 --- a/lib/controllers/members_by_status_controller.dart +++ /dev/null @@ -1,32 +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/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_controller.dart b/lib/controllers/members_controller.dart index 57fc5be..80e73a0 100644 --- a/lib/controllers/members_controller.dart +++ b/lib/controllers/members_controller.dart @@ -1,46 +1,39 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/models/content/content.dart"; -import "package:nexus/models/event.dart"; +import "package:nexus/models/membership.dart"; import "package:nexus/models/requests/get_room_state_request.dart"; +import "package:nexus/models/room.dart"; -class MembersController extends AsyncNotifier> { - final String roomId; - MembersController(this.roomId); +class MembersController extends AsyncNotifier> { + final Room room; + MembersController(this.room); @override - Future> build() async { - final room = ref.watch( - RoomsController.provider.select((value) => value[roomId]), - ); + Future> build() async { + if (room.metadata == null) return const IList.empty(); - if (room == null) return .new(); + final state = await ref + .watch(ClientController.provider.notifier) + .getRoomState( + GetRoomStateRequest( + roomId: room.metadata!.id, + fetchMembers: room.metadata!.hasMemberList == false, + includeMembers: true, + ), + ); - 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(); + return state.nonNulls + .where((member) => member.content["membership"] == "join") + .map( + (membership) => + Membership.fromContent(membership.content, membership.stateKey!), + ) + .toIList(); } - static final provider = AsyncNotifierProvider.autoDispose - .family, String>(MembersController.new); + static final provider = + AsyncNotifierProvider.family, Room>( + MembersController.new, + ); } diff --git a/lib/controllers/members_grouped_controller.dart b/lib/controllers/members_grouped_controller.dart deleted file mode 100644 index 85fddb0..0000000 --- a/lib/controllers/members_grouped_controller.dart +++ /dev/null @@ -1,64 +0,0 @@ -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/room_creators_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/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 roomCreators = room == null - ? null - : ref.watch((RoomCreatorsController.provider(room))); - - 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 = roomCreators?.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 new file mode 100644 index 0000000..d84aabb --- /dev/null +++ b/lib/controllers/message_controller.dart @@ -0,0 +1,195 @@ +import "package:collection/collection.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/helpers/extensions/mxc_to_https.dart"; +import "package:nexus/models/configs/message_config.dart"; + +class MessageController extends AsyncNotifier { + final MessageConfig config; + MessageController(this.config); + + @override + Future build() async { + try { + if (config.event.relationType == "m.replace" && !config.includeEdits) { + return null; + } + + if (!ref.mounted) return null; + final event = config.event.lastEditRowId == null + ? config.event + : config.room.events.firstWhereOrNull( + (e) => e.rowId == config.event.lastEditRowId, + ) ?? + config.event; + + if (!ref.mounted) return null; + + final content = (event.decrypted ?? event.content); + final type = (config.event.decryptedType ?? config.event.type); + final newContent = content["m.new_content"] as Map?; + + 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 + ? (newContent?["body"] ?? content["body"] ?? "") + : "Deleted Message", + "flashing": false, + "timelineId": event.timelineRowId, + "big": event.localContent?.bigEmoji == true, + "eventType": type, + "pmp": event.content["com.beeper.per_message_profile"], + "editSource": + event.localContent?.editSource ?? + newContent?["body"] ?? + content["body"], + "txnId": config.event.transactionId, + }; + + if (!ref.mounted) return null; + + final editedAt = event.relationType == "m.replace" + ? event.timestamp + : null; + + if ((event.redactedBy != null && !config.alwaysReturn) || + (!config.includeEdits && + (config.event.relationType == "m.replace"))) { + return null; + } + + // TODO: Use server-generated preview if enabled + + // final match = Uri.tryParse( + // RegExp(regexLink, caseSensitive: false).firstMatch(body)?.group(0) ?? "", + // ); + + final replyId = + config.event.content["m.relates_to"]?["m.in_reply_to"]?["event_id"]; + + final asText = + Message.text( + metadata: metadata, + id: config.event.eventId, + authorId: event.authorId, + text: + newContent?["formatted_body"] ?? + newContent?["body"] ?? + content["formatted_body"] ?? + content["body"] ?? + "", + replyToMessageId: replyId, + deliveredAt: config.event.timestamp, + editedAt: editedAt, + ) + as TextMessage; + + 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, + 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, + authorId: event.authorId, + source: source, + replyToMessageId: replyId, + deliveredAt: config.event.timestamp, + ), + _ => asText, + }, + "m.room.member" => + content["membership"] == event.unsigned["prev_content"]?["membership"] + ? null + : Message.system( + metadata: { + ...metadata, + "body": + "${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) { + "invite" => "was invited to", + "join" => "joined", + "leave" => "left", + "knock" => "asked to join", + "ban" => "was banned from", + _ => "did something relating to", + }} the room.", + }, + id: config.event.eventId, + authorId: event.authorId, + deliveredAt: config.event.timestamp, + text: + "${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) { + "invite" => "was invited to", + "join" => "joined", + "leave" => "left", + "knock" => "asked to join", + "ban" => "was banned from", + _ => "did something relating to", + }} the room.", + ), + + "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, + 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 new file mode 100644 index 0000000..28885fb --- /dev/null +++ b/lib/controllers/messages_controller.dart @@ -0,0 +1,27 @@ +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 52dd8d9..e23ecaa 100644 --- a/lib/controllers/multi_provider_controller.dart +++ b/lib/controllers/multi_provider_controller.dart @@ -7,8 +7,9 @@ class MultiProviderController extends AsyncNotifier { final IList providers; @override - Future build() => - .wait(providers.map((provider) => ref.watch(provider.future))); + FutureOr build() async => await Future.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 new file mode 100644 index 0000000..215ebd3 --- /dev/null +++ b/lib/controllers/new_events_controller.dart @@ -0,0 +1,18 @@ +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 deleted file mode 100644 index 94e6bd2..0000000 --- a/lib/controllers/power_level_controller.dart +++ /dev/null @@ -1,81 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/room_creators_controller.dart"; -import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/models/configs/power_level_config.dart"; -import "package:nexus/models/content/content.dart"; -import "package:nexus/models/content/power_levels.dart"; - -class PowerLevelController extends Notifier { - final PowerLevelConfig config; - PowerLevelController(this.config); - - @override - bool build() { - 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 roomCreators = room == null - ? null - : ref.watch(RoomCreatorsController.provider(room)); - - final eventRowId = room?.state[EventType.powerLevels.type]?[""]; - - 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; - - double powerLevelOf(String userId) => roomCreators?.contains(userId) == true - ? double.infinity - : (content.users[userId] ?? content.usersDefault).toDouble(); - - final userLevel = powerLevelOf(user); - - return switch (config) { - EventPowerLevelConfig(:final eventType) => - userLevel >= (content.events[eventType.type] ?? content.eventsDefault), - - MembershipActionPowerLevelConfig(:final action, :final targetUser) => - switch (action) { - .invite => userLevel >= content.invite, - - .kick => - userLevel >= content.kick && userLevel > powerLevelOf(targetUser), - - .ban => - userLevel >= content.ban && userLevel > powerLevelOf(targetUser), - - .unban => userLevel >= content.ban, - }, - - StatePowerLevelConfig(:final eventType) => - userLevel >= (content.events[eventType.type] ?? content.stateDefault), - - RedactionPowerLevelConfig(:final targetUser) => - userLevel >= - (targetUser == user - ? (content.events[EventType.redaction.type] ?? - content.eventsDefault) - : content.redact), - }; - } - - static final provider = NotifierProvider.autoDispose - .family( - PowerLevelController.new, - ); -} diff --git a/lib/controllers/profile_controller.dart b/lib/controllers/profile_controller.dart deleted file mode 100644 index 9aa0e09..0000000 --- a/lib/controllers/profile_controller.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/models/profile.dart"; - -class ProfileController extends AsyncNotifier { - final String userId; - ProfileController(this.userId); - - @override - Future build() { - final client = ref.watch(ClientController.provider.notifier); - return client.getProfile(userId); - } - - static final provider = AsyncNotifierProvider.family - .autoDispose(ProfileController.new); -} diff --git a/lib/controllers/reactions_controller.dart b/lib/controllers/reactions_controller.dart deleted file mode 100644 index db9af9b..0000000 --- a/lib/controllers/reactions_controller.dart +++ /dev/null @@ -1,55 +0,0 @@ -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 07b3650..d737154 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -1,88 +1,182 @@ 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_chat_core/flutter_chat_core.dart" as chat; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:fluttertagger/fluttertagger.dart"; import "package:nexus/controllers/client_controller.dart"; +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/models/content/content.dart"; -import "package:nexus/models/content/reaction.dart"; -import "package:nexus/models/event.dart"; +import "package:nexus/models/configs/messages_config.dart"; +import "package:nexus/models/configs/message_config.dart"; +import "package:nexus/models/requests/get_room_state_request.dart"; +import "package:nexus/models/requests/paginate_request.dart"; import "package:nexus/models/requests/redact_event_request.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/models/requests/send_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); - final room = ref.watch( - RoomsController.provider.select((rooms) => rooms[roomId]), + var room = ref.read(RoomsController.provider)[roomId]; + if (room == null) return InMemoryChatController(); + + final state = await client.getRoomState( + GetRoomStateRequest(roomId: roomId), ); - if (room == null) return null; + 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.hasFetchedState) { - final state = await client.getRoomState(.new(roomId: roomId)); + room = ref.read(RoomsController.provider)[roomId]; + if (room == null) return InMemoryChatController(); - await ref.read(RoomsController.provider.notifier).addState(roomId, state); + 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 { + final controller = await future; + for (final event in next) { + if (event.type == "m.room.redaction") { + final controller = await future; + final message = controller.messages.firstWhereOrNull( + (message) => message.id == event.content["redacts"], + ); + if (message == null || !ref.mounted) return; + + await controller.removeMessage(message); + } 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 && + !controller.messages.any( + (oldMessage) => oldMessage.id == message.id, + ) && + ref.mounted) { + await controller.insertMessage(message); + } + } + } + }, weak: true).close, + ); + + ref.onDispose(controller.dispose); + + // While there are under 20 messages, try up to two times to load more messages. + for (var i = 0; i < 2 && messages.length < 20; i++) { + await loadOlder(controller); } - // While there are under 20 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(); + return controller; } - Future deleteMessage(Event event, {String? reason}) => ref - .watch(ClientController.provider.notifier) - .redactEvent( - RedactEventRequest( - eventId: event.eventId, - roomId: roomId, - reason: reason, - ), - ); + 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"], + ); - Future loadOlder() async { - final timelineKeys = ref - .read(RoomsController.provider.select((value) => value[roomId])) - ?.timeline - .keys; + return oldMessage == null + ? controller.insertMessage(message) + : controller.updateMessage(oldMessage, message); + } + + Future deleteMessage(Message message, {String? reason}) async { + final controller = await future; + await controller.removeMessage(message); + await ref + .watch(ClientController.provider.notifier) + .redactEvent( + RedactEventRequest( + eventId: message.id, + roomId: roomId, + reason: reason, + ), + ); + } + + Future loadOlder([InMemoryChatController? chatController]) async { final response = await ref .watch(ClientController.provider.notifier) .paginate( - .new( + PaginateRequest( roomId: roomId, - maxTimelineId: timelineKeys?.isNotEmpty == true - ? timelineKeys?.reduce(min) - : null, + maxTimelineId: ref + .read(RoomsController.provider)[roomId] + ?.timeline + .firstOrNull + ?.timelineRowId, ), ); @@ -91,33 +185,51 @@ class RoomChatController extends AsyncNotifier?> { .update( IMap({ roomId: Room( - events: IMap.fromIterable( - response.events.addAll(response.relatedEvents), - keyMapper: (event) => event.rowId, - valueMapper: (event) => event, - ), + events: response.events.addAll(response.relatedEvents), hasMore: response.hasMore, - timeline: IMap.fromIterable( - response.events, - keyMapper: (event) => event.timelineRowId, - valueMapper: (event) => event.rowId, - ), + timeline: response.events + .map( + (event) => TimelineRowTuple( + timelineRowId: event.timelineRowId, + eventRowId: event.rowId, + ), + ) + .toIList(), ), }), - .new(), + const ISet.empty(), ); - return response.hasMore; + final room = ref.read(RoomsController.provider)[roomId]; + if (room == null) return; + + final messages = await ref.watch( + MessagesController.provider( + MessagesConfig(room: room, events: response.events.reversed), + ).future, + ); + + final controller = chatController ?? await future; + await controller.insertAllMessages( + messages + .where( + (newMessage) => !controller.messages.any( + (message) => message.id == newMessage.id, + ), + ) + .toList(), + index: 0, + ); } Future send( - String text, { + String message, { bool shouldMention = true, - required IList tags, + required Iterable tags, required RelationType relationType, - Event? relation, + Message? relation, }) async { - var taggedMessage = text; + var taggedMessage = message; for (final tag in tags) { final escaped = RegExp.escape(tag.id); @@ -130,7 +242,7 @@ class RoomChatController extends AsyncNotifier?> { } final client = ref.watch(ClientController.provider.notifier); - final event = await client.sendMessage( + client.sendMessage( SendMessageRequest( roomId: roomId, mentions: Mentions( @@ -138,85 +250,50 @@ class RoomChatController extends AsyncNotifier?> { if (shouldMention == true && relation != null && relationType == RelationType.reply) - relation.sender, + relation.authorId, ].toIList(), room: taggedMessage.contains("@room"), ), text: taggedMessage, relation: relation == null ? null - : .new(eventId: relation.eventId, relationType: relationType), + : Relation(eventId: relation.id, relationType: relationType), ), ); - - ref - .watch(RoomsController.provider.notifier) - .update( - .new({ - roomId: .new( - events: .new({event.rowId: event}), - sticky: .new({event.rowId}), - ), - }), - .new(), - ); } - Future removeReaction( - String reaction, - Event event, - String userId, - ) async { - final client = ref.watch(ClientController.provider.notifier); - final allReactionEvents = await client.getRelatedEvents( - .new( - roomId: roomId, - eventId: event.eventId, - relationType: "m.annotation", - ), + Future resolveUser(String id) async { + final user = await ref + .watch(ClientController.provider.notifier) + .getProfile(id); + return chat.User( + id: id, + name: user.displayName, + // imageSource: user.avatarUrl == null + // ? null + // : (await ref.watch( + // AvatarController.provider(user.avatarUrl!.toString()).future, + // )).toString(), ); - - final reactionEvents = allReactionEvents - ?.where((event) => event.redactedBy == null) - .toIList(); - - final reactionEvent = reactionEvents?.firstWhereOrNull( - (event) => switch (event.content) { - ReactionContent(:final key) => - key == reaction && event.sender == userId, - _ => false, - }, - ); - - if (reactionEvent != null) { - await ref - .watch(ClientController.provider.notifier) - .redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId)); - } } - Future sendReaction(String reaction, Event event) async { - final client = ref.watch(ClientController.provider.notifier); - - await client.sendEvent( - .new( - roomId: roomId, - type: EventType.reaction.type, - content: { - "m.relates_to": { - "event_id": event.eventId, - "rel_type": "m.annotation", - "key": reaction, - }, - }, - synchronous: true, - disableEncryption: true, + Future scrollToMessage(Message message) async { + final controller = await future; + Future setFlashing(bool flashing) => controller.updateMessage( + message, + message.copyWith( + metadata: {...(message.metadata ?? {}), "flashing": flashing}, ), ); + + await setFlashing(true); + Timer(Duration(seconds: 1), () => setFlashing(false)); + + return await controller.scrollToMessage(message.id); } static final provider = AsyncNotifierProvider.family - .autoDispose?, String>( + .autoDispose( RoomChatController.new, ); } diff --git a/lib/controllers/room_creators_controller.dart b/lib/controllers/room_creators_controller.dart deleted file mode 100644 index 7db72c2..0000000 --- a/lib/controllers/room_creators_controller.dart +++ /dev/null @@ -1,33 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/models/content/content.dart"; -import "package:nexus/models/content/create.dart"; -import "package:nexus/models/room.dart"; - -class RoomCreatorsController extends Notifier> { - final Room room; - RoomCreatorsController(this.room); - - @override - IList build() { - final createRowId = room.state[EventType.create.type]?[""]; - final createEvent = createRowId == null ? null : room.events[createRowId]; - - if (createEvent == null) return .new(); - - final createEventContent = switch (createEvent.content) { - CreateContent content => content, - _ => null, - }; - - return switch (createEventContent?.additionalCreatorIds) { - IList creators => creators.add(createEvent.sender), - _ => .new([createEvent.sender]), - }; - } - - static final provider = - NotifierProvider.family, Room>( - RoomCreatorsController.new, - ); -} diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart index d0c6eb9..3c6e287 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -1,40 +1,13 @@ -import "dart:isolate"; +import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/models/event.dart"; +import "package:nexus/controllers/new_events_controller.dart"; +import "package:nexus/models/read_receipt.dart"; import "package:nexus/models/room.dart"; class RoomsController extends Notifier> { @override - IMap build() => .new(); - - 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(), - ); + IMap build() => const IMap.empty(); void update(IMap rooms, ISet leftRooms) { final merged = rooms.entries.fold(state, (acc, entry) { @@ -42,41 +15,55 @@ class RoomsController extends Notifier> { final incoming = entry.value; final existing = acc[roomId]; + final events = existing?.events.updateById( + incoming.events, + (item) => item.eventId, + ); + + 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, - 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), + events: events!, state: incoming.state.entries.fold( existing.state, (previousValue, event) => previousValue.add( event.key, - (previousValue[event.key] ?? .new()).addAll(event.value), + (previousValue[event.key] ?? const IMap.empty()).addAll( + event.value, + ), ), ), - reset: false, - hasFetchedMembers: - incoming.hasFetchedMembers || existing.hasFetchedMembers, - hasFetchedState: - incoming.hasFetchedState || existing.hasFetchedState, - timeline: (incoming.reset - ? incoming.timeline - : existing.timeline.addAll(incoming.timeline)), + timeline: + (incoming.reset + ? incoming.timeline + : existing.timeline.updateById( + incoming.timeline, + (item) => item.timelineRowId, + )) + .sortedBy((element) => element.timelineRowId) + .toIList(), receipts: incoming.receipts.entries.fold( existing.receipts, (receiptAcc, event) => receiptAcc.add( event.key, - (receiptAcc[event.key] ?? .new()).addAll(event.value), + (receiptAcc[event.key] ?? IList()).addAll( + event.value, + ), ), ), ) ?? @@ -88,7 +75,6 @@ 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 new file mode 100644 index 0000000..ffba78c --- /dev/null +++ b/lib/controllers/selected_room_controller.dart @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000..dbeb71f --- /dev/null +++ b/lib/controllers/selected_space_controller.dart @@ -0,0 +1,22 @@ +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 876fc47..f4dcdae 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() async => .getInstance(); + Future build() => SharedPreferences.getInstance(); static final provider = AsyncNotifierProvider( diff --git a/lib/controllers/space_edges_controller.dart b/lib/controllers/space_edges_controller.dart index 81347c5..12694d6 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() => .new(); + IMap> build() => const IMap.empty(); void set(IMap> newEdges) => state = state.addAll(newEdges); diff --git a/lib/controllers/spaces_controller.dart b/lib/controllers/spaces_controller.dart index 03a6b8a..ca217a5 100644 --- a/lib/controllers/spaces_controller.dart +++ b/lib/controllers/spaces_controller.dart @@ -1,4 +1,3 @@ -import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; @@ -6,9 +5,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/room.dart"; import "package:nexus/models/space.dart"; -import "package:nexus/models/subspace.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/models/space_edge.dart"; class SpacesController extends Notifier> { @override @@ -16,133 +15,99 @@ 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 childrenById = { - for (final entry in spaceEdges.entries) - entry.key: entry.value.map((e) => e.childId).toList(), - }; + final childRoomsBySpaceId = IMap.fromEntries( + topLevelSpaceIds.map((spaceId) { + ISet walk(String currentId) { + final children = spaceEdges[currentId] ?? IList(); - Set collectDescendants(String startId) { - final visited = {}; - final stack = [startId]; + return children.fold>(const ISet.empty(), (acc, edge) { + final childId = edge.childId; + final isSpace = spaceEdges.containsKey(childId); - 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 acc + .addAll(!isSpace ? ISet([childId]) : const ISet.empty()) + .addAll(isSpace ? walk(childId) : const ISet.empty()); + }); } - } - return visited; - } + return MapEntry( + spaceId, + walk(spaceId).map((id) => rooms[id]).nonNulls.toIList(), + ); + }), + ); - 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 allNestedRoomIds = childRoomsBySpaceId.values + .expand((l) => l) + .map( + (room) => rooms.entries + .firstWhere( + (entry) => entry.value.metadata?.id == room.metadata?.id, + ) + .key, + ) + .toISet(); final otherRooms = rooms.entries .where( (e) => - !usedRoomIds.contains(e.key) && + !allNestedRoomIds.contains(e.key) && !topLevelSpaceIds.contains(e.key) && - !childrenById.containsKey(e.key), + !spaceEdges.containsKey(e.key), ) - .map((e) => e.value) - .toIList(); + .map((e) => e.value); + + final accountData = ref.watch(AccountDataController.provider); + + final directMessages = IMap( + accountData["m.direct"]?.content ?? {}, + ).values.expand((element) => element); final homeRooms = otherRooms - .where((r) => !directMessages.contains(r.metadata?.id)) + .where( + (room) => + directMessages.any( + (directMessage) => directMessage == room.metadata?.id, + ) == + false, + ) .toIList(); final dmRooms = otherRooms - .where((r) => directMessages.contains(r.metadata?.id)) + .where( + (room) => directMessages.any( + (directMessage) => directMessage == room.metadata?.id, + ), + ) .toIList(); - final allSpaces = [ - .new( - id: "home", - title: "Home", - icon: Icons.home, - children: homeRooms, - subSpaces: .new(), - ), - .new( + final topLevelSpacesList = topLevelSpaceIds + .map((id) { + final room = rooms[id]; + if (room == null) return null; + + 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, - subSpaces: .new(), ), - ...spaces, - ]; - - return allSpaces - .map( - (space) => space.copyWith( - children: .new( - space.children - .sortedBy( - (element) => - element - .metadata - ?.sortingTimestamp - .millisecondsSinceEpoch ?? - 0, - ) - .sortedBy((room) => room.metadata?.unreadMessages ?? 0) - .reversed, - ), - ), - ) - .toIList(); + ...topLevelSpacesList, + ].toIList(); } static final provider = NotifierProvider>( diff --git a/lib/controllers/sync_status_controller.dart b/lib/controllers/sync_status_controller.dart index 256c8e2..fe65732 100644 --- a/lib/controllers/sync_status_controller.dart +++ b/lib/controllers/sync_status_controller.dart @@ -1,17 +1,11 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/main.dart"; import "package:nexus/models/sync_status.dart"; class SyncStatusController extends Notifier { @override Null build() => null; - void set(SyncStatus newStatus) { - if (newStatus.type == .permanentlyFailed) { - showError(newStatus.error ?? "Syncing failed"); - } - state = newStatus; - } + void set(SyncStatus newStatus) => state = newStatus; static final provider = NotifierProvider( SyncStatusController.new, diff --git a/lib/controllers/top_level_spaces_controller.dart b/lib/controllers/top_level_spaces_controller.dart index 321e29d..e1f9c88 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() => .new(); + IList build() => const IList.empty(); void set(IList newSpaces) => state = newSpaces; diff --git a/lib/controllers/url_preview_controller.dart b/lib/controllers/url_preview_controller.dart deleted file mode 100644 index bb5626a..0000000 --- a/lib/controllers/url_preview_controller.dart +++ /dev/null @@ -1,51 +0,0 @@ -import "dart:convert"; -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 { - final String link; - UrlPreviewController(this.link); - - @override - Future build() async { - final homeserver = ref.watch( - ClientStateController.provider.select((value) => value?.homeserverUrl), - ); - - if (homeserver != null && !link.contains("matrix.to")) { - { - final response = await get( - .parse(homeserver) - .resolve("/_matrix/client/v1/media/preview_url") - .replace(queryParameters: {"url": link}), - headers: await ref.watch(HeaderController.provider.future), - ); - - if (response.statusCode == 200) { - final decodedValue = json.decode(response.body); - if (decodedValue is! Map) return null; - - final mxc = decodedValue["og:image"]; - final image = mxc == null - ? null - : Uri.tryParse(mxc)?.mxcToHttps(homeserver); - - return .fromJson(decodedValue).copyWith(imageUrl: image); - } - } - } - - return null; - } - - static final provider = - AsyncNotifierProvider.family< - UrlPreviewController, - OpenGraphData?, - String - >(UrlPreviewController.new); -} diff --git a/lib/controllers/user_controller.dart b/lib/controllers/user_controller.dart deleted file mode 100644 index 7d20e90..0000000 --- a/lib/controllers/user_controller.dart +++ /dev/null @@ -1,47 +0,0 @@ -import "dart:async"; -import "package:collection/collection.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/members_controller.dart"; -import "package:nexus/controllers/profile_controller.dart"; -import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/models/configs/user_config.dart"; -import "package:nexus/models/content/membership.dart"; - -class UserController extends AsyncNotifier { - final UserConfig config; - UserController(this.config); - - @override - 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, - ); - - return .new( - status: .leave, - avatarUrl: profile.avatarUrl, - displayName: profile.displayName ?? config.userId.localpart, - ); - } - - static final provider = - AsyncNotifierProvider.family< - UserController, - MembershipContent, - UserConfig - >(UserController.new); -} diff --git a/lib/controllers/via_controller.dart b/lib/controllers/via_controller.dart deleted file mode 100644 index 0b5890a..0000000 --- a/lib/controllers/via_controller.dart +++ /dev/null @@ -1,63 +0,0 @@ -import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/models/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 { - final Room room; - ViaController(this.room); - - @override - String build() { - final servers = {}; - - void addUserId(String? userId) { - final server = userId?.split(":").lastOrNull; - if (server != null) { - servers.add(server); - } - } - - addUserId(ref.watch(ClientStateController.provider)?.userId); - - final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""]; - final powerLevels = powerLevelsEventId == null - ? null - : room.events[powerLevelsEventId]; - - if (powerLevels?.content case PowerLevelsContent(:final users)) { - for (final userId in users.keys) { - addUserId(userId); - if (servers.length >= 5) break; - } - } - - final members = room.state[EventType.membership.type]?.values.toIList(); - for (var i = 0; servers.length < 5; i++) { - final membershipEventId = members?.getOrNull(i); - final member = membershipEventId == null - ? null - : room.events[membershipEventId]; - - if (member?.content case MembershipContent(:final status)) { - if (status == .join) { - addUserId(member?.stateKey); - } - } - - if (members?.getOrNull(i) == null) break; - } - - return servers.isEmpty - ? "" - : "?${servers.map((server) => "via=$server").join("&")}"; - } - - static final provider = NotifierProvider.family( - ViaController.new, - ); -} diff --git a/lib/helpers/extensions/get_localpart.dart b/lib/helpers/extensions/get_localpart.dart deleted file mode 100644 index fa3d285..0000000 --- a/lib/helpers/extensions/get_localpart.dart +++ /dev/null @@ -1,3 +0,0 @@ -extension GetLocalpart on String { - 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 cc16b46..88cfd5a 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 .new(0); - return .fromList(base.asTypedList(length)); + if (base == nullptr || length <= 0) return Uint8List(0); + return Uint8List.fromList(base.asTypedList(length)); } finally { calloc.free(base); } diff --git a/lib/helpers/extensions/join_room_with_snackbars.dart b/lib/helpers/extensions/join_room_with_snackbars.dart new file mode 100644 index 0000000..05b045d --- /dev/null +++ b/lib/helpers/extensions/join_room_with_snackbars.dart @@ -0,0 +1,90 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/material.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/controllers/key_controller.dart"; +import "package:nexus/controllers/spaces_controller.dart"; +import "package:nexus/helpers/extensions/link_to_mention.dart"; +import "package:nexus/models/requests/join_room_request.dart"; + +extension JoinRoomWithSnackbars on ClientController { + Future joinRoomWithSnackBars( + BuildContext context, + String roomAlias, + WidgetRef ref, + ) async { + final roomIdOrAlias = roomAlias.mention ?? roomAlias; + + final scaffoldMessenger = ScaffoldMessenger.of(context); + + final snackbar = scaffoldMessenger.showSnackBar( + SnackBar( + content: Text("Joining room $roomIdOrAlias."), + duration: Duration(days: 999), + ), + ); + + try { + final id = await joinRoom( + JoinRoomRequest( + roomIdOrAlias: roomIdOrAlias, + via: IList(Uri.tryParse(roomAlias)?.queryParametersAll["via"] ?? []), + ), + ); + + snackbar.close(); + + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text("Room $roomIdOrAlias successfully joined."), + action: SnackBarAction( + label: "Open", + onPressed: () async { + final spaces = ref.watch(SpacesController.provider); + final space = spaces.firstWhereOrNull((space) => space.id == id); + + await ref + .watch( + KeyController.provider(KeyController.spaceKey).notifier, + ) + .set( + space?.id ?? + spaces + .firstWhere( + (space) => space.children.any( + (child) => child.metadata?.id == id, + ), + ) + .id, + ); + + if (space == null) { + await ref + .watch( + KeyController.provider(KeyController.roomKey).notifier, + ) + .set(id); + } + }, + ), + ), + ); + } catch (error) { + snackbar.close(); + if (context.mounted) { + scaffoldMessenger.showSnackBar( + SnackBar( + backgroundColor: Theme.of(context).colorScheme.errorContainer, + content: Text( + error.toString(), + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + ), + ); + } + } + } +} diff --git a/lib/helpers/extensions/link_to_mention.dart b/lib/helpers/extensions/link_to_mention.dart index f4868d3..b0e62aa 100644 --- a/lib/helpers/extensions/link_to_mention.dart +++ b/lib/helpers/extensions/link_to_mention.dart @@ -30,8 +30,7 @@ extension LinkToMention on String { final identifier = uri.pathSegments.last; if (identifier.isNotEmpty) { return "${switch (uri.pathSegments.firstOrNull) { - "r" => "#", - "roomid" => "!", + "r" || "roomid" => "#", "u" => "@", _ => "", }}${Uri.decodeComponent(identifier)}"; diff --git a/lib/helpers/extensions/mxc_to_https.dart b/lib/helpers/extensions/mxc_to_https.dart index b21f056..468da12 100644 --- a/lib/helpers/extensions/mxc_to_https.dart +++ b/lib/helpers/extensions/mxc_to_https.dart @@ -1,4 +1,4 @@ extension MxcToHttps on Uri { Uri mxcToHttps(String homeserver) => - .parse(homeserver).resolve("_matrix/client/v1/media/download/$host$path"); + Uri.parse("${homeserver}_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 b7d7972..aff5d52 100644 --- a/lib/helpers/extensions/scheme_to_theme.dart +++ b/lib/helpers/extensions/scheme_to_theme.dart @@ -1,18 +1,14 @@ import "package:flutter/material.dart"; extension SchemeToTheme on ColorScheme { - ThemeData get theme => .from(colorScheme: this).copyWith( - cardTheme: .new(color: primaryContainer), - popupMenuTheme: .new( - shape: RoundedRectangleBorder(borderRadius: .circular(16)), - color: surfaceContainerHigh, - ), + ThemeData get theme => ThemeData.from(colorScheme: this).copyWith( + cardTheme: CardThemeData(color: primaryContainer), appBarTheme: AppBarTheme( titleSpacing: 0, backgroundColor: surfaceContainerLow, ), textTheme: ThemeData( - fontFamilyFallback: ["sans", "emoji"], + fontFamilyFallback: ["sans"], brightness: brightness, ).textTheme, inputDecorationTheme: const InputDecorationTheme( diff --git a/lib/helpers/extensions/show_context_menu.dart b/lib/helpers/extensions/show_context_menu.dart index c860115..f4762c3 100644 --- a/lib/helpers/extensions/show_context_menu.dart +++ b/lib/helpers/extensions/show_context_menu.dart @@ -9,13 +9,13 @@ extension ShowContextMenu on BuildContext { showMenu( context: this, - constraints: .loose(Size.infinite), - position: .fromLTRB( + position: RelativeRect.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 deleted file mode 100644 index 1ea3015..0000000 --- a/lib/helpers/extensions/show_user_popover.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:flutter/material.dart"; -import "package:nexus/models/content/membership.dart"; -import "package:nexus/widgets/user_bottom_sheet.dart"; - -extension ShowUserPopover on BuildContext { - 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 deleted file mode 100644 index a9db345..0000000 --- a/lib/helpers/extensions/size_to_string.dart +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index eaa7714..0000000 --- a/lib/helpers/extensions/string_to_color.dart +++ /dev/null @@ -1,6 +0,0 @@ -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 575395f..f872ef7 100644 --- a/lib/helpers/launch_helper.dart +++ b/lib/helpers/launch_helper.dart @@ -10,7 +10,9 @@ class LaunchHelper { try { return await ul.launchUrl( url, - mode: useWebview ? .inAppBrowserView : .externalApplication, + mode: useWebview + ? ul.LaunchMode.inAppBrowserView + : ul.LaunchMode.externalApplication, ); } on PlatformException catch (_) { return false; diff --git a/lib/helpers/required_validator_helper.dart b/lib/helpers/required_validator_helper.dart deleted file mode 100644 index d243684..0000000 --- a/lib/helpers/required_validator_helper.dart +++ /dev/null @@ -1,2 +0,0 @@ -String? requiredValidator(String? value) => - value == null || value.isEmpty ? "This field is required" : null; diff --git a/lib/main.dart b/lib/main.dart index a8d8499..ddba498 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,23 +1,24 @@ 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"; +import "package:nexus/controllers/init_complete_controller.dart"; import "package:nexus/controllers/multi_provider_controller.dart"; import "package:nexus/controllers/shared_prefs_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/scheme_to_theme.dart"; import "package:nexus/pages/chat_page.dart"; -import "package:nexus/pages/select_server_page.dart"; +import "package:nexus/pages/login_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"; +import "package:window_size/window_size.dart"; final GlobalKey navigatorKey = GlobalKey(); @@ -32,7 +33,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]) { @@ -57,14 +58,18 @@ void showError(Object error, [StackTrace? stackTrace]) { void main() async { WidgetsFlutterBinding.ensureInitialized(); - MediaKit.ensureInitialized(); if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { await windowManager.ensureInitialized(); await windowManager.waitUntilReadyToShow( WindowOptions(titleBarStyle: TitleBarStyle.hidden), ); - await windowManager.setMinimumSize(Size.square(500)); + + if (Platform.isLinux) { + setWindowMinSize(const Size.square(500)); + } else { + await windowManager.setMinimumSize(Size.square(500)); + } } FlutterError.onError = (FlutterErrorDetails details) => @@ -124,11 +129,13 @@ class App extends StatelessWidget { } if (!clientState.isLoggedIn) { - return SelectServerPage(); + return LoginPage(); } else if (!clientState.isVerified) { return VerifyPage(); } else { - return ChatPage(); + return ref.watch(InitCompleteController.provider) + ? ChatPage() + : Loading(); } }, ), diff --git a/lib/models/configs/author_config.dart b/lib/models/configs/author_config.dart new file mode 100644 index 0000000..af63c63 --- /dev/null +++ b/lib/models/configs/author_config.dart @@ -0,0 +1,14 @@ +import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/room.dart"; +part "author_config.freezed.dart"; +part "author_config.g.dart"; + +@freezed +abstract class AuthorConfig with _$AuthorConfig { + const factory AuthorConfig({required Message message, required Room room}) = + _AuthorConfig; + + factory AuthorConfig.fromJson(Map json) => + _$AuthorConfigFromJson(json); +} diff --git a/lib/models/configs/members_by_status_config.dart b/lib/models/configs/members_by_status_config.dart deleted file mode 100644 index 8aef586..0000000 --- a/lib/models/configs/members_by_status_config.dart +++ /dev/null @@ -1,15 +0,0 @@ -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 new file mode 100644 index 0000000..9020f78 --- /dev/null +++ b/lib/models/configs/message_config.dart @@ -0,0 +1,28 @@ +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.eventId == event.eventId; + + @override + int get hashCode => Object.hash(runtimeType, event.eventId); + + factory MessageConfig.fromJson(Map json) => + _$MessageConfigFromJson(json); +} diff --git a/lib/models/configs/messages_config.dart b/lib/models/configs/messages_config.dart new file mode 100644 index 0000000..b33a71c --- /dev/null +++ b/lib/models/configs/messages_config.dart @@ -0,0 +1,17 @@ +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 deleted file mode 100644 index 197e171..0000000 --- a/lib/models/configs/power_level_config.dart +++ /dev/null @@ -1,28 +0,0 @@ -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"; - -@freezed -sealed class PowerLevelConfig with _$PowerLevelConfig { - const factory PowerLevelConfig({ - required EventType eventType, - required String roomId, - }) = EventPowerLevelConfig; - - const factory PowerLevelConfig.membershipAction({ - required MembershipAction action, - required String targetUser, - required String roomId, - }) = MembershipActionPowerLevelConfig; - - const factory PowerLevelConfig.state({ - required EventType eventType, - 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 deleted file mode 100644 index 5cae859..0000000 --- a/lib/models/configs/reactions_config.dart +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 4f3f8ff..0000000 --- a/lib/models/configs/user_config.dart +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 66d4c47..0000000 --- a/lib/models/content/avatar.dart +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 636be13..0000000 --- a/lib/models/content/canonical_alias.dart +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index e7b1141..0000000 --- a/lib/models/content/content.dart +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index c534558..0000000 --- a/lib/models/content/create.dart +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index b33a440..0000000 --- a/lib/models/content/encrypted.dart +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 3380632..0000000 --- a/lib/models/content/encryption.dart +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 707805c..0000000 --- a/lib/models/content/history_visibility.dart +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 1d14eee..0000000 --- a/lib/models/content/join_rules.dart +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index dbbd123..0000000 --- a/lib/models/content/membership.dart +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index b5e308c..0000000 --- a/lib/models/content/message.dart +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index 205f6bb..0000000 --- a/lib/models/content/name.dart +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index d17a0de..0000000 --- a/lib/models/content/pinned_events.dart +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 3709c38..0000000 --- a/lib/models/content/power_levels.dart +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 3115ae0..0000000 --- a/lib/models/content/reaction.dart +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index e9c1a90..0000000 --- a/lib/models/content/redaction.dart +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 1e50988..0000000 --- a/lib/models/content/server_acl.dart +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 89d9332..0000000 --- a/lib/models/content/sticker.dart +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 8fa5229..0000000 --- a/lib/models/content/topic.dart +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 8e4eac6..0000000 --- a/lib/models/emoji.dart +++ /dev/null @@ -1,17 +0,0 @@ -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 c54dbc5..734f667 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -1,69 +1,37 @@ 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, - required String sender, - @JsonKey(readValue: Event.typeJsonFromJson) required String type, + @JsonKey(name: "sender") required String authorId, + 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") @Default(0) int lastEditRowId, + @JsonKey(name: "last_edit_rowid") int? lastEditRowId, @UnreadTypeConverter() UnreadType? unreadType, - Profile? pmp, - required Content content, - required Content? previousContent, }) = _Event; - 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"], - ), - ); + factory Event.fromJson(Map json) => _$EventFromJson(json); } @freezed diff --git a/lib/models/info/audio.dart b/lib/models/info/audio.dart deleted file mode 100644 index ccfcf7a..0000000 --- a/lib/models/info/audio.dart +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 1509c99..0000000 --- a/lib/models/info/file.dart +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 9833016..0000000 --- a/lib/models/info/image.dart +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 6ff3547..0000000 --- a/lib/models/info/video.dart +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 3fade23..0000000 --- a/lib/models/join_rule.dart +++ /dev/null @@ -1,4 +0,0 @@ -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 new file mode 100644 index 0000000..ec18be7 --- /dev/null +++ b/lib/models/membership.dart @@ -0,0 +1,22 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +part "membership.freezed.dart"; + +@freezed +abstract class Membership with _$Membership { + const Membership._(); + const factory Membership({ + required Uri? avatarUrl, + required String displayName, + required String userId, + }) = _Membership; + + factory Membership.fromContent( + IMap content, + String userId, + ) => Membership( + avatarUrl: Uri.tryParse(content["avatar_url"] ?? ""), + userId: userId, + displayName: content["displayname"] ?? userId.substring(1).split(":").first, + ); +} diff --git a/lib/models/membership_status.dart b/lib/models/membership_status.dart deleted file mode 100644 index ba7a241..0000000 --- a/lib/models/membership_status.dart +++ /dev/null @@ -1,4 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -@JsonEnum() -enum MembershipStatus { leave, invite, ban, join, knock } diff --git a/lib/models/ms_duration.dart b/lib/models/ms_duration.dart deleted file mode 100644 index de12943..0000000 --- a/lib/models/ms_duration.dart +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index d7e840d..0000000 --- a/lib/models/open_graph_data.dart +++ /dev/null @@ -1,17 +0,0 @@ -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 6ae1e94..d92b4f6 100644 --- a/lib/models/profile.dart +++ b/lib/models/profile.dart @@ -1,45 +1,22 @@ 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"; @freezed abstract class Profile with _$Profile { - static Object? readPronouns(Map map, _) => - map["m.pronouns"] ?? map["io.fsky.nyx.pronouns"]; - - 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, + String? avatarUrl, + @JsonKey(name: "displayname") String? displayName, + @JsonKey(name: "us.cloke.msc4175.tz") String? timezone, @Default(IList.empty()) - @JsonKey(readValue: Profile.readPronouns, name: "io.fsky.nyx.pronouns") + @JsonKey(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 4fcf7b6..9374f3a 100644 --- a/lib/models/requests/get_event_request.dart +++ b/lib/models/requests/get_event_request.dart @@ -1,16 +1,32 @@ 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() +@Freezed(toJson: false) abstract class GetEventRequest with _$GetEventRequest { const GetEventRequest._(); const factory GetEventRequest({ - required String roomId, + required Room room, 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/requests/get_room_state_request.dart b/lib/models/requests/get_room_state_request.dart index 8ee05f0..de66b72 100644 --- a/lib/models/requests/get_room_state_request.dart +++ b/lib/models/requests/get_room_state_request.dart @@ -6,7 +6,6 @@ part "get_room_state_request.g.dart"; abstract class GetRoomStateRequest with _$GetRoomStateRequest { const factory GetRoomStateRequest({ required String roomId, - @Default(false) bool refetch, @Default(false) bool fetchMembers, @Default(false) bool includeMembers, }) = _GetRoomStateRequest; diff --git a/lib/models/requests/membership_action.dart b/lib/models/requests/membership_action.dart deleted file mode 100644 index d852164..0000000 --- a/lib/models/requests/membership_action.dart +++ /dev/null @@ -1,4 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -@JsonEnum() -enum MembershipAction { ban, kick, unban, invite } diff --git a/lib/models/requests/send_event_request.dart b/lib/models/requests/send_event_request.dart deleted file mode 100644 index da5de32..0000000 --- a/lib/models/requests/send_event_request.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "send_event_request.freezed.dart"; -part "send_event_request.g.dart"; - -@freezed -abstract class SendEventRequest with _$SendEventRequest { - const factory SendEventRequest({ - required String roomId, - required String type, - required Map content, - @Default(false) bool synchronous, - @Default(false) bool disableEncryption, - }) = _SendEventRequest; - - factory SendEventRequest.fromJson(Map json) => - _$SendEventRequestFromJson(json); -} diff --git a/lib/models/requests/set_membership_request.dart b/lib/models/requests/set_membership_request.dart deleted file mode 100644 index dd0e1f2..0000000 --- a/lib/models/requests/set_membership_request.dart +++ /dev/null @@ -1,19 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/requests/membership_action.dart"; -part "set_membership_request.freezed.dart"; -part "set_membership_request.g.dart"; - -@freezed -abstract class SetMembershipRequest with _$SetMembershipRequest { - const factory SetMembershipRequest({ - required String userId, - required String roomId, - - String? reason, - @JsonKey(name: "action") required MembershipAction action, - @Default(false) @JsonKey(name: "msc4293_redact_events") bool redact, - }) = _SetMembershipRequest; - - factory SetMembershipRequest.fromJson(Map json) => - _$SetMembershipRequestFromJson(json); -} diff --git a/lib/models/room.dart b/lib/models/room.dart index fb21a55..3c3eec0 100644 --- a/lib/models/room.dart +++ b/lib/models/room.dart @@ -8,50 +8,29 @@ 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(IMap.empty()) - @JsonKey(fromJson: Room.timelineTupleJsonToIMap) - IMap timeline, - @Default(ISet.empty()) ISet sticky, - - @Default(IMap.empty()) - @JsonKey(fromJson: Room.eventsJsonToIMap) - IMap events, - + @Default(IList.empty()) IList timeline, @Default(false) bool reset, - @Default(false) bool hasFetchedState, - @Default(false) bool hasFetchedMembers, @Default(IMap.empty()) IMap> state, - + // required IMap accountData, + @Default(IList.empty()) IList events, @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 73fbbc6..631759a 100644 --- a/lib/models/space.dart +++ b/lib/models/space.dart @@ -2,7 +2,6 @@ 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 @@ -13,6 +12,5 @@ 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 deleted file mode 100644 index 1a1879c..0000000 --- a/lib/models/subspace.dart +++ /dev/null @@ -1,10 +0,0 @@ -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/models/sync_status.dart b/lib/models/sync_status.dart index 7848fbe..42c5f2a 100644 --- a/lib/models/sync_status.dart +++ b/lib/models/sync_status.dart @@ -14,5 +14,5 @@ abstract class SyncStatus with _$SyncStatus { _$SyncStatusFromJson(json); } -@JsonEnum(fieldRename: FieldRename.kebab) +@JsonEnum(fieldRename: FieldRename.snake) enum SyncStatusType { ok, waiting, erroring, permanentlyFailed } diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index 2c0ef97..ee2f4d0 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -1,12 +1,7 @@ import "package:flutter/material.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/sidebar.dart"; -import "package:nexus/widgets/room_chat.dart"; -import "package:nexus/widgets/loading.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/widgets/chat_page/sidebar.dart"; +import "package:nexus/widgets/chat_page/room_chat.dart"; class ChatPage extends ConsumerWidget { const ChatPage({super.key}); @@ -16,34 +11,22 @@ class ChatPage extends ConsumerWidget { builder: (context, constraints) { final isDesktop = constraints.maxWidth > 650; final showMembersByDefault = constraints.maxWidth > 1000; - final initComplete = ref.watch(InitCompleteController.provider); - final roomId = ref.watch(KeyController.provider(KeyController.roomKey)); - ref.read(EmojiController.provider); return Scaffold( - appBar: initComplete ? null : Appbar(), - body: initComplete - ? Row( - children: [ - if (isDesktop) Sidebar(isDesktop: isDesktop), - Expanded( - child: RoomChat( - roomId: roomId, - isDesktop: isDesktop, - showMembersByDefault: showMembersByDefault, - ), - ), - ], - ) - : Center( - child: Column( - mainAxisSize: .min, - children: [Loading(), Text("Syncing...")], + body: Builder( + builder: (context) => Row( + children: [ + if (isDesktop) Sidebar(), + Expanded( + child: RoomChat( + isDesktop: isDesktop, + showMembersByDefault: showMembersByDefault, ), ), - drawer: isDesktop || !initComplete - ? null - : Sidebar(isDesktop: isDesktop), + ], + ), + ), + drawer: isDesktop ? null : Sidebar(), ); }, ); diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index f7a3f1a..bd41d51 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -1,99 +1,210 @@ 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/helpers/required_validator_helper.dart"; +import "package:nexus/widgets/divider_text.dart"; +import "package:nexus/widgets/loading.dart"; class LoginPage extends HookConsumerWidget { - final Uri homeserver; - const LoginPage({super.key, required this.homeserver}); + const LoginPage({super.key}); @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( - 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, + appBar: Appbar(), + body: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 600), + child: ListView( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 64), children: [ - TextFormField( - autofocus: true, - textInputAction: .next, - autovalidateMode: .onUserInteraction, - validator: requiredValidator, - decoration: .new(label: Text("Username")), - controller: username, + 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, + ), + ], + ), + ), + ], ), - SizedBox(height: 12), - TextFormField( - textInputAction: .done, - decoration: .new( - label: Text("Password"), - errorText: inputError.value, - errorMaxLines: 5, + Padding( + padding: EdgeInsetsGeometry.symmetric(vertical: 12), + child: Divider(), + ), + + 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", ), - autovalidateMode: .onUserInteraction, - validator: requiredValidator, - controller: password, - obscureText: true, - onFieldSubmitted: (_) => tryLogin(), - // Don't defocus on submit - onEditingComplete: () {}, + 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 succeeded = await client.login( + LoginRequest( + username: username.text, + password: password.text, + homeserverUrl: homeserver.value!, + ), + ); + + if (!succeeded && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Login failed. Is your password right?", + 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"), - ), - ], ), ); } -} \ No newline at end of file +} diff --git a/lib/pages/select_server_page.dart b/lib/pages/select_server_page.dart deleted file mode 100644 index f0e7dff..0000000 --- a/lib/pages/select_server_page.dart +++ /dev/null @@ -1,169 +0,0 @@ -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 bf5c9f3..1011f80 100644 --- a/lib/pages/verify_page.dart +++ b/lib/pages/verify_page.dart @@ -2,8 +2,7 @@ import "package:flutter/material.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/widgets/appbar.dart"; -import "package:nexus/helpers/required_validator_helper.dart"; +import "package:nexus/widgets/form_text_input.dart"; class VerifyPage extends HookConsumerWidget { const VerifyPage({super.key}); @@ -11,65 +10,73 @@ class VerifyPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final passphraseController = useTextEditingController(); - 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: 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: () {}, - ), - ], + final isVerifying = useState(false); + return 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.", ), - ), - actions: [ - TextButton( - onPressed: isLoading.value ? null : verify, - child: Text("Verify"), + SizedBox(height: 12), + FormTextInput( + required: false, + autofocus: true, + capitalize: true, + controller: passphraseController, + obscure: true, + title: "Recovery Key or Passphrase", ), ], ), + 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 success = await ref + .watch(ClientController.provider.notifier) + .verify(passphraseController.text); + + snackbar.close(); + if (!success) { + isVerifying.value = false; + if (context.mounted) { + scaffoldMessenger.showSnackBar( + SnackBar( + backgroundColor: Theme.of( + context, + ).colorScheme.errorContainer, + content: Text( + "Verification failed. Is your passphrase correct?", + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onErrorContainer, + ), + ), + ), + ); + } + } + }, + child: Text("Verify"), + ), + ], ); } } diff --git a/lib/widgets/appbar.dart b/lib/widgets/appbar.dart index 811788a..5b14244 100644 --- a/lib/widgets/appbar.dart +++ b/lib/widgets/appbar.dart @@ -9,20 +9,18 @@ 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 .empty(), + this.actions = const IList.empty(), }); @override - Size get preferredSize => const .fromHeight(kToolbarHeight); + Size get preferredSize => const Size.fromHeight(kToolbarHeight); @override Widget build(BuildContext context) { @@ -37,17 +35,15 @@ class Appbar extends StatelessWidget implements PreferredSizeWidget { } return GestureDetector( + behavior: HitTestBehavior.translucent, + onDoubleTap: maximize, onPanStart: (_) => windowManager.startDragging(), child: AppBar( - leading: InkWell(onTap: onTap, child: leading), + leading: leading, backgroundColor: backgroundColor, scrolledUnderElevation: scrolledUnderElevation, - actionsPadding: const .symmetric(horizontal: 8), - title: InkWell( - onTap: onTap, - child: IgnorePointer(child: title), - ), - flexibleSpace: GestureDetector(onDoubleTap: maximize), + actionsPadding: const EdgeInsets.symmetric(horizontal: 8), + title: title, actions: [ ...actions, if (!(Platform.isAndroid || Platform.isIOS)) ...[ diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatar_or_hash.dart index 20f4eac..8e93b6b 100644 --- a/lib/widgets/avatar_or_hash.dart +++ b/lib/widgets/avatar_or_hash.dart @@ -11,11 +11,15 @@ 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, }); @@ -24,40 +28,43 @@ class AvatarOrHash extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final box = ColoredBox( color: ColorHash(title).color, - child: Center(child: Icon(Icons.person, size: height / 2)), + child: Center(child: Text(title.isEmpty ? "" : title[0])), ); - - final parsedAvatar = avatar?.mxcToHttps( - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - "", - ); - return SizedBox( width: height, height: height, child: Center( - 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, + 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! + .mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value?.homeserverUrl, + ), + ) ?? + "", + ) + .toString(), + ref.watch(CrossCacheController.provider), + headers: ref.headers, + ), + fit: BoxFit.contain, + errorBuilder: (_, _, _) => box, ), - 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 new file mode 100644 index 0000000..7f07de2 --- /dev/null +++ b/lib/widgets/chat_page/composer/chat_box.dart @@ -0,0 +1,179 @@ +import "dart:io"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:fluttertagger/fluttertagger.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/room_chat_controller.dart"; +import "package:nexus/models/relation_type.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/widgets/chat_page/composer/mention_overlay.dart"; +import "package:nexus/widgets/chat_page/composer/relation_preview.dart"; + +class ChatBox extends HookConsumerWidget { + final Message? relatedMessage; + final RelationType relationType; + final VoidCallback onDismiss; + final Room room; + const ChatBox({ + required this.relatedMessage, + required this.relationType, + required this.onDismiss, + required this.room, + 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.trim().isEmpty || room.metadata == null) return; + ref + .watch(RoomChatController.provider(room.metadata!.id).notifier) + .send( + controller.value.formattedText, + shouldMention: shouldMention.value, + relation: relatedMessage, + relationType: relationType, + tags: controller.value.tags, + ); + onDismiss(); + controller.value.text = ""; + } + + final node = useFocusNode( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && !Platform.isAndroid && !Platform.isIOS) { + if (event.logicalKey == LogicalKeyboardKey.enter && + !HardwareKeyboard.instance.isShiftPressed) { + send(); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.escape) { + onDismiss(); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + }, + )..requestFocus(); + + final style = TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ); + + 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, + room: room, + 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: [ + 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), + // enabled: room.canSendDefaultMessages, TODO: Permissions check + ), + Expanded( + child: FlutterTagger( + triggerStrategy: TriggerStrategy.eager, + overlay: MentionOverlay( + room, + query: query.value, + triggerCharacter: triggerCharacter.value, + addTag: ({required id, required name}) { + controller.value.addTag(id: id, name: name); + node.requestFocus(); + }, + ), + controller: controller.value, + onSearch: (newQuery, newTriggerCharacter) { + triggerCharacter.value = newTriggerCharacter; + query.value = newQuery; + }, + triggerCharacterAndStyles: {"@": style, "#": style}, + builder: (context, key) => TextFormField( + // enabled: room.canSendDefaultMessages, + maxLines: 12, + minLines: 1, + decoration: InputDecoration( + hintText: + true // TODO: room.canSendDefaultMessages + ? "Your message here..." + : "You don't have permission to send messages in this room...", + border: InputBorder.none, + ), + controller: controller.value, + key: key, + autofocus: true, + focusNode: node, + ), + ), + ), + IconButton( + onPressed: send, + // onPressed: room.canSendDefaultMessages ? send : null, + icon: Icon(Icons.send), + tooltip: "Send message", + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/chat_page/composer/mention_overlay.dart b/lib/widgets/chat_page/composer/mention_overlay.dart new file mode 100644 index 0000000..d95253d --- /dev/null +++ b/lib/widgets/chat_page/composer/mention_overlay.dart @@ -0,0 +1,117 @@ +import "package:flutter/material.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/members_controller.dart"; +import "package:nexus/controllers/rooms_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/loading.dart"; + +class MentionOverlay extends ConsumerWidget { + final String? triggerCharacter; + final String query; + final Room room; + final void Function({required String id, required String name}) addTag; + const MentionOverlay( + this.room, { + required this.query, + required this.addTag, + required this.triggerCharacter, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final rooms = ref.watch(RoomsController.provider); + + return Padding( + padding: EdgeInsets.all(8), + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(12)), + child: Container( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + padding: EdgeInsets.all(8), + child: switch (triggerCharacter) { + "@" => + ref + .watch(MembersController.provider(room)) + .betterWhen( + data: (members) => ListView( + children: + (query.isEmpty + ? members + : members.where( + (member) => + member.userId.toLowerCase().contains( + query.toLowerCase(), + ) == + true || + member.displayName + .toLowerCase() + .contains( + query.toLowerCase(), + ) == + true, + )) + .map( + (member) => ListTile( + leading: AvatarOrHash( + member.avatarUrl, + member.displayName, + ), + title: Text(member.displayName), + subtitle: Text(member.userId), + onTap: () => addTag( + id: "[@${member.displayName}](https://matrix.to/#/${member.userId})", + name: member.userId + .substring(1) + .split(":") + .first, + ), + ), + ) + .toList(), + ), + ), + "#" => ListView( + children: + (query.isEmpty + ? rooms.values + : rooms.values.where( + (room) => (room.metadata?.name ?? "Unnamed Room") + .toLowerCase() + .contains(query.toLowerCase()), + )) + .map( + (room) => ListTile( + leading: AvatarOrHash( + room.metadata?.avatar, + room.metadata?.name ?? "Unnamed Room", + fallback: Icon(Icons.numbers), + ), + title: Text(room.metadata?.name ?? "Unnamed Room"), + subtitle: room.metadata?.topic == null + ? null + : Text(room.metadata!.topic!, maxLines: 1), + onTap: () => addTag( + id: "[#${room.metadata?.name ?? "Unnamed Room"}](https://matrix.to/#/${room.metadata?.id})", + name: + (room.metadata?.canonicalAlias ?? + room.metadata?.id) + ?.substring(1) + .split(":") + .first ?? + "", + ), + ), + ) + .toList(), + ), + + _ => Loading(), + }, + ), + ), + ); + } +} diff --git a/lib/widgets/chat_page/composer/relation_preview.dart b/lib/widgets/chat_page/composer/relation_preview.dart new file mode 100644 index 0000000..7fded20 --- /dev/null +++ b/lib/widgets/chat_page/composer/relation_preview.dart @@ -0,0 +1,84 @@ +import "package:flutter/material.dart"; +import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/models/relation_type.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; +import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; + +class RelationPreview extends ConsumerWidget { + final Message? relatedMessage; + final RelationType relationType; + final VoidCallback onDismiss; + final bool shouldMention; + final VoidCallback toggleShouldMention; + final Room room; + + const RelationPreview( + this.relatedMessage, { + required this.room, + required this.relationType, + required this.onDismiss, + required this.shouldMention, + 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: [ + SizedBox(width: 4), + if (relationType == RelationType.edit) + Text( + "Editing message:", + style: TextStyle(fontWeight: FontWeight.bold), + ), + MessageAvatar(relatedMessage!, room), + MessageDisplayname( + relatedMessage!, + room, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Expanded( + child: Text( + relatedMessage?.metadata?["body"] ?? + relatedMessage?.metadata?["eventType"], + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium, + maxLines: 1, + ), + ), + + 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: Icon(Icons.close), + iconSize: 20, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/html/code_block.dart b/lib/widgets/chat_page/html/code_block.dart similarity index 75% rename from lib/widgets/html/code_block.dart rename to lib/widgets/chat_page/html/code_block.dart index a5c3dee..80950ce 100644 --- a/lib/widgets/html/code_block.dart +++ b/lib/widgets/chat_page/html/code_block.dart @@ -11,20 +11,20 @@ class CodeBlock extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); return ClipRRect( - borderRadius: .all(.circular(16)), + borderRadius: BorderRadius.all(Radius.circular(16)), child: ColoredBox( color: theme.colorScheme.surfaceContainerHighest, child: IntrinsicWidth( child: Column( children: [ Row( - mainAxisAlignment: .spaceBetween, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( - padding: .symmetric(horizontal: 8), + padding: EdgeInsets.symmetric(horizontal: 8), child: Text( lang.substring(0, min(lang.length, 15)), - style: .new(fontFamily: "monospace"), + style: TextStyle(fontFamily: "monospace"), ), ), TextButton.icon( @@ -37,13 +37,13 @@ class CodeBlock extends StatelessWidget { ColoredBox( color: theme.colorScheme.surfaceContainerHigh, child: Container( - constraints: .new(minWidth: 250), - padding: .all(8), + constraints: BoxConstraints(minWidth: 250), + padding: EdgeInsets.all(8), child: SelectableText( code, minLines: 1, maxLines: 99, - style: .new(fontFamily: "monospace"), + style: TextStyle(fontFamily: "monospace"), ), ), ), diff --git a/lib/widgets/html/html.dart b/lib/widgets/chat_page/html/html.dart similarity index 52% rename from lib/widgets/html/html.dart rename to lib/widgets/chat_page/html/html.dart index 85ec9ae..dcc1d49 100644 --- a/lib/widgets/html/html.dart +++ b/lib/widgets/chat_page/html/html.dart @@ -1,30 +1,25 @@ -import "package:cross_cache/cross_cache.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart"; import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/link_to_mention.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/helpers/launch_helper.dart"; -import "package:nexus/widgets/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"; +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"; class Html extends ConsumerWidget { final String html; - final String? roomId; final TextStyle? textStyle; - const Html(this.html, {this.roomId, this.textStyle, super.key}); + const Html(this.html, {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")) { @@ -35,70 +30,60 @@ class Html extends ConsumerWidget { return InlineCustomWidget(child: SpoilerText(text: element.text)); } - final height = - int.tryParse(element.attributes["height"] ?? "") ?? - (element.attributes.keys.contains("data-mx-emoticon") ? 32 : null) ?? - 300; + final height = int.tryParse(element.attributes["height"] ?? "") ?? 300; final width = int.tryParse(element.attributes["width"] ?? ""); - final src = Uri.tryParse(element.attributes["src"] ?? "") - ?.mxcToHttps( - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - "", - ) - .toString(); return switch (element.localName) { "code" => element.parent?.localName == "pre" - ? CodeBlock( - element.text, - lang: element.className.replaceAll("language-", ""), - ) + ? element.outerHtml.contains("
") + ? Html( + """
${element.outerHtml.replaceAll("
", "\n")}
""", + ) + : CodeBlock( + element.text, + lang: element.className.replaceAll("language-", ""), + ) : null, - "blockquote" => Quoted( - Html(element.innerHtml, textStyle: textStyle, roomId: roomId), - ), + "blockquote" => Quoted(Html(element.innerHtml)), "a" => element.attributes["href"]?.mention == null ? null - : InlineCustomWidget( - child: MentionChip(element.attributes["href"]!, roomId), - ), + : InlineCustomWidget(child: MentionChip(element.text)), "img" => - src == null + element.attributes["src"] == null ? SizedBox.shrink() : InlineCustomWidget( alignment: PlaceholderAlignment.middle, - child: ExpandableImage( - src, - child: Image( - image: CachedNetworkImage( - src, - ref.watch(CrossCacheController.provider), - headers: ref.headers, + child: Image.network( + Uri.parse(element.attributes["src"]!) + .mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value?.homeserverUrl, + ), + ) ?? + "", + ) + .toString(), + headers: ref.headers, + errorBuilder: (_, error, _) => Text( + "Image Failed to Load", + style: TextStyle( + color: Theme.of(context).colorScheme.error, ), - errorBuilder: (_, error, _) => Text( - "Image Failed to Load", - style: .new(color: Theme.of(context).colorScheme.error), - ), - height: height.toDouble(), - width: width?.toDouble(), - loadingBuilder: (_, child, loadingProgress) => - loadingProgress == null - ? child - : CircularProgressIndicator(), ), + height: height.toDouble(), + width: width?.toDouble(), + loadingBuilder: (_, child, loadingProgress) => + loadingProgress == null + ? child + : CircularProgressIndicator(), ), ), - - // Allowed elements list ("del" || "h1" || "h2" || @@ -144,14 +129,15 @@ class Html extends ConsumerWidget { element.attributes .mapTo?>( (key, value) => switch (key) { - "data-mx-color" => .new("color", value), - "data-mx-bg-color" => .new("background-color", value), + "data-mx-color" => MapEntry("color", value), + "data-mx-bg-color" => MapEntry("background-color", value), _ => null, }, ) .nonNulls, ), }, - onTapUrl: (url) => ref.watch(LaunchHelper.provider).launchUrl(.parse(url)), + onTapUrl: (url) => + ref.watch(LaunchHelper.provider).launchUrl(Uri.parse(url)), ); } diff --git a/lib/widgets/chat_page/html/mention_chip.dart b/lib/widgets/chat_page/html/mention_chip.dart new file mode 100644 index 0000000..c2b832d --- /dev/null +++ b/lib/widgets/chat_page/html/mention_chip.dart @@ -0,0 +1,25 @@ +import "package:flutter/material.dart"; +import "package:nexus/helpers/extensions/link_to_mention.dart"; + +class MentionChip extends StatelessWidget { + final String label; + const MentionChip(this.label, {super.key}); + + @override + Widget build(BuildContext context) => ActionChip( + label: Text( + label.mention ?? label, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + backgroundColor: Theme.of(context).colorScheme.primary, + onPressed: () => showDialog( + context: context, + builder: (_) => Dialog( + child: Text("TODO: Open room or join room dialog, or user popover"), + ), + ), + ); +} diff --git a/lib/widgets/html/quoted.dart b/lib/widgets/chat_page/html/quoted.dart similarity index 66% rename from lib/widgets/html/quoted.dart rename to lib/widgets/chat_page/html/quoted.dart index e582b06..6640118 100644 --- a/lib/widgets/html/quoted.dart +++ b/lib/widgets/chat_page/html/quoted.dart @@ -8,9 +8,9 @@ class Quoted extends StatelessWidget { Widget build(BuildContext context) => Container( decoration: BoxDecoration( border: Border( - left: .new(width: 4, color: Theme.of(context).dividerColor), + left: BorderSide(width: 4, color: Theme.of(context).dividerColor), ), ), - child: Padding(padding: .only(left: 8), child: child), + child: Padding(padding: EdgeInsets.only(left: 8), child: child), ); } diff --git a/lib/widgets/html/spoiler_text.dart b/lib/widgets/chat_page/html/spoiler_text.dart similarity index 69% rename from lib/widgets/html/spoiler_text.dart rename to lib/widgets/chat_page/html/spoiler_text.dart index a7a457b..9a42bff 100644 --- a/lib/widgets/html/spoiler_text.dart +++ b/lib/widgets/chat_page/html/spoiler_text.dart @@ -13,15 +13,15 @@ class SpoilerText extends HookWidget { return InkWell( onTap: () => revealed.value = !revealed.value, child: AnimatedContainer( - duration: const .new(milliseconds: 100), - padding: const .symmetric(horizontal: 4, vertical: 2), + duration: const Duration(milliseconds: 100), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), decoration: BoxDecoration( color: revealed.value ? Colors.transparent : Colors.blueGrey, - borderRadius: .circular(4), + borderRadius: BorderRadius.circular(4), ), child: Text( text, - style: .new(color: revealed.value ? null : Colors.transparent), + style: TextStyle(color: revealed.value ? null : Colors.transparent), ), ), ); diff --git a/lib/widgets/chat_page/image_message.dart b/lib/widgets/chat_page/image_message.dart new file mode 100644 index 0000000..103fdd2 --- /dev/null +++ b/lib/widgets/chat_page/image_message.dart @@ -0,0 +1,58 @@ +import "dart:math"; +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"; + +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) => InkWell( + onTap: () => 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, + image: CachedNetworkImage( + message.source, + ref.watch(CrossCacheController.provider), + headers: ref.headers, + ), + ), + ), + ), + ), + ), + ), + 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/lazy_loading/message_avatar.dart b/lib/widgets/chat_page/lazy_loading/message_avatar.dart new file mode 100644 index 0000000..71fcf84 --- /dev/null +++ b/lib/widgets/chat_page/lazy_loading/message_avatar.dart @@ -0,0 +1,30 @@ +import "package:flutter/widgets.dart"; +import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/author_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/models/configs/author_config.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; + +class MessageAvatar extends ConsumerWidget { + final Message message; + final Room room; + final double height; + const MessageAvatar(this.message, this.room, {this.height = 16, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) => ref + .watch( + AuthorController.provider(AuthorConfig(room: room, message: message)), + ) + .betterWhen( + data: (membership) => AvatarOrHash( + membership.avatarUrl, + membership.displayName, + height: height, + ), + loading: () => + AvatarOrHash(null, message.authorId.substring(1), height: height), + ); +} diff --git a/lib/widgets/chat_page/lazy_loading/message_displayname.dart b/lib/widgets/chat_page/lazy_loading/message_displayname.dart new file mode 100644 index 0000000..7c10df3 --- /dev/null +++ b/lib/widgets/chat_page/lazy_loading/message_displayname.dart @@ -0,0 +1,28 @@ +import "package:flutter/widgets.dart"; +import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/author_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/models/configs/author_config.dart"; +import "package:nexus/models/room.dart"; + +class MessageDisplayname extends ConsumerWidget { + final Message message; + final Room room; + final TextStyle? style; + const MessageDisplayname(this.message, this.room, {this.style, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) => ref + .watch( + AuthorController.provider(AuthorConfig(room: room, message: message)), + ) + .betterWhen( + data: (membership) => Text( + "${membership.displayName} ${message.metadata?["pmp"] == null ? "" : "(via ${message.authorId})"}", + style: style, + overflow: TextOverflow.ellipsis, + ), + loading: () => Text(""), + ); +} diff --git a/lib/widgets/chat_page/member_list.dart b/lib/widgets/chat_page/member_list.dart new file mode 100644 index 0000000..8cdbbb9 --- /dev/null +++ b/lib/widgets/chat_page/member_list.dart @@ -0,0 +1,68 @@ +import "package:flutter/material.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/members_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; + +class MemberList extends ConsumerWidget { + final Room room; + const MemberList(this.room, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final membersProvider = ref.watch(MembersController.provider(room)); + return Drawer( + shape: Border(), + child: Column( + children: [ + AppBar( + scrolledUnderElevation: 0, + leading: Icon(Icons.people), + title: Text( + "Members ${membersProvider.when(data: (members) => "${members.length}", error: (_, _) => "", loading: () => "")}", + ), + 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", + ), + ], + ), + membersProvider.betterWhen( + data: (members) => Expanded( + child: ListView( + children: members + .map( + (member) => ListTile( + onTap: () => showDialog( + context: context, + builder: (context) => + Dialog(child: Text("TODO: Open member popover")), + ), + 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 new file mode 100644 index 0000000..b9fa2e1 --- /dev/null +++ b/lib/widgets/chat_page/reply_widget.dart @@ -0,0 +1,101 @@ +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/helpers/extensions/better_when.dart"; +import "package:nexus/models/configs/message_config.dart"; +import "package:nexus/models/requests/get_event_request.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/widgets/chat_page/html/quoted.dart"; +import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; +import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; + +typedef OnTapReply = void Function(Message message)?; + +class ReplyWidget extends ConsumerWidget { + final Message message; + final bool alwaysShow; + final Room room; + final MessageGroupStatus? groupStatus; + final OnTapReply onTapReply; + const ReplyWidget( + this.message, { + required this.room, + required this.groupStatus, + this.onTapReply, + this.alwaysShow = false, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) => + message.replyToMessageId == null + ? SizedBox.shrink() + : Padding( + padding: EdgeInsets.only(bottom: 12), + child: Quoted( + ref + .watch( + EventController.provider( + GetEventRequest( + room: room, + eventId: message.replyToMessageId!, + ), + ), + ) + .betterWhen( + loading: () => Text("Fetching event..."), + data: (event) => event == null + ? SizedBox.shrink() + : ref + .watch( + MessageController.provider( + MessageConfig(room: room, event: event), + ), + ) + .betterWhen( + loading: () => Text("Parsing message..."), + data: (replyMessage) { + if (replyMessage == null) { + return SizedBox.shrink(); + } + + return InkWell( + onTap: () => onTapReply?.call(replyMessage), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + MessageAvatar(replyMessage, room), + Flexible( + child: MessageDisplayname( + replyMessage, + room, + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Flexible( + child: Text( + replyMessage.metadata!["body"], + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.labelMedium, + maxLines: 1, + ), + ), + ], + ), + ); + }, + ), + ), + ), + ); +} diff --git a/lib/widgets/chat_page/room_appbar.dart b/lib/widgets/chat_page/room_appbar.dart new file mode 100644 index 0000000..436bcb9 --- /dev/null +++ b/lib/widgets/chat_page/room_appbar.dart @@ -0,0 +1,68 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/material.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/widgets/appbar.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/chat_page/room_menu.dart"; + +class RoomAppbar extends StatelessWidget implements PreferredSizeWidget { + final bool isDesktop; + final Room room; + final void Function(BuildContext context) onOpenMemberList; + final void Function(BuildContext context) onOpenDrawer; + const RoomAppbar( + this.room, { + required this.isDesktop, + required this.onOpenMemberList, + required this.onOpenDrawer, + super.key, + }); + + @override + Size get preferredSize => AppBar().preferredSize; + + @override + Widget build(BuildContext context) => Appbar( + leading: isDesktop + ? AvatarOrHash( + room.metadata?.avatar, + room.metadata?.name ?? "Unnamed Rooms", + height: 24, + fallback: Icon(Icons.numbers), + ) + : DrawerButton(onPressed: () => onOpenDrawer(context)), + scrolledUnderElevation: 0, + title: 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(context), + tooltip: "Open member list", + icon: Icon(Icons.people), + ), + RoomMenu(room), + ].toIList(), + ); +} diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart new file mode 100644 index 0000000..6b3839a --- /dev/null +++ b/lib/widgets/chat_page/room_chat.dart @@ -0,0 +1,368 @@ +import "package:flutter/material.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/client_controller.dart"; +import "package:nexus/controllers/client_state_controller.dart"; +import "package:nexus/controllers/selected_room_controller.dart"; +import "package:nexus/controllers/room_chat_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/show_context_menu.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/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:dynamic_polls/dynamic_polls.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 replyToMessage = useState(null); + final memberListOpened = useState(showMembersByDefault); + final relationType = useState(RelationType.reply); + final room = ref.watch(SelectedRoomController.provider); + final userId = ref.watch(ClientStateController.provider)?.userId; + + final theme = Theme.of(context); + final danger = theme.colorScheme.error; + + if (room == null || userId == null || room.metadata?.id == null) { + return Center( + child: Text( + "Nothing to see here...", + style: theme.textTheme.headlineMedium, + ), + ); + } + + final controllerProvider = RoomChatController.provider(room.metadata!.id); + final notifier = ref.watch(controllerProvider.notifier); + + List getMessageOptions(Message message) { + final isSentByMe = message.authorId == userId; + return [ + PopupMenuItem( + onTap: () { + replyToMessage.value = message; + relationType.value = RelationType.reply; + }, + child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")), + ), + if (message is TextMessage && isSentByMe) + PopupMenuItem( + onTap: () { + replyToMessage.value = message; + relationType.value = RelationType.edit; + }, + child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")), + ), + if (isSentByMe) // TODO: Or if user has permission to redact others' messages + 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 { + notifier.deleteMessage( + message, + reason: deleteReasonController.text, + ); + Navigator.of(context).pop(); + }, + child: Text("Delete"), + ), + ], + ); + }, + ), + ), + child: ListTile(leading: Icon(Icons.delete), title: Text("Delete")), + ), + 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: () { + if (room.metadata == null) return; + client.reportEvent( + ReportRequest( + roomId: room.metadata!.id, + 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( + room, + 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: room.hasMore + ? notifier.loadOlder + : null, + onStartReached: () => client.markRead(room), + bottomPadding: 72, + ), + + composerBuilder: (_) => ChatBox( + relationType: relationType.value, + relatedMessage: replyToMessage.value, + onDismiss: () => replyToMessage.value = null, + room: room, + ), + + textMessageBuilder: + ( + context, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => TextMessageWrapper( + room: room, + 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, + room: room, + 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( + room: room, + message, + onTapReply: notifier.scrollToMessage, + groupStatus: groupStatus, + ), + message: message, + index: index, + ), + ), + groupStatus, + room, + ), + + 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: notifier.resolveUser, + chatController: controller, + ), + ), + ), + ], + ), + ), + + if (memberListOpened.value == true && showMembersByDefault) + MemberList(room), + ], + ), + + endDrawer: showMembersByDefault ? null : MemberList(room), + ); + } +} diff --git a/lib/widgets/room_menu.dart b/lib/widgets/chat_page/room_menu.dart similarity index 59% rename from lib/widgets/room_menu.dart rename to lib/widgets/chat_page/room_menu.dart index f4313fa..2687bc8 100644 --- a/lib/widgets/room_menu.dart +++ b/lib/widgets/chat_page/room_menu.dart @@ -1,13 +1,11 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; -import "package:flutter/services.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/via_controller.dart"; import "package:nexus/models/room.dart"; class RoomMenu extends ConsumerWidget { - final Room? room; + final Room room; final IList children; const RoomMenu(this.room, {this.children = const IList.empty(), super.key}); @@ -18,9 +16,16 @@ class RoomMenu extends ConsumerWidget { return PopupMenuButton( itemBuilder: (_) => [ + // PopupMenuItem( + // onTap: () async { + // final link = await room.matrixToInviteLink(); + // await Clipboard.setData(ClipboardData(text: link.toString())); + // }, + // child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), + // ), PopupMenuItem( onTap: () async { - if (room != null) await client.markRead(room!); + await client.markRead(room); await Future.wait(children.map((child) => client.markRead(child))); }, child: ListTile( @@ -28,61 +33,41 @@ class RoomMenu extends ConsumerWidget { title: Text("Mark as Read"), ), ), - if (room != null) ...[ - PopupMenuItem( - onTap: () async { - final vias = ref.watch(ViaController.provider(room!)); - - await Clipboard.setData( - .new( - 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"), - ), - 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"), - ), - ], + 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"}\"?", ), - ), - child: ListTile( - leading: Icon(Icons.logout, color: danger), - title: Text("Leave", style: TextStyle(color: danger)), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + 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.logout, color: danger), + title: Text("Leave", style: TextStyle(color: danger)), + ), + ), // PopupMenuItem( // onTap: () => showDialog( // context: context, diff --git a/lib/widgets/chat_page/sidebar.dart b/lib/widgets/chat_page/sidebar.dart new file mode 100644 index 0000000..4642a58 --- /dev/null +++ b/lib/widgets/chat_page/sidebar.dart @@ -0,0 +1,233 @@ +import "package:flutter/material.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/controllers/key_controller.dart"; +import "package:nexus/controllers/selected_space_controller.dart"; +import "package:nexus/controllers/spaces_controller.dart"; +import "package:nexus/helpers/extensions/join_room_with_snackbars.dart"; +import "package:nexus/pages/settings_page.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/chat_page/room_menu.dart"; +import "package:nexus/widgets/form_text_input.dart"; + +class Sidebar extends HookConsumerWidget { + const Sidebar({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: (alertContext) => HookBuilder( + builder: (_) { + final roomAlias = useTextEditingController(); + return AlertDialog( + title: Text("Join a Room"), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Enter the room alias, ID, or a Matrix.to link.", + ), + SizedBox(height: 12), + FormTextInput( + required: false, + capitalize: true, + controller: roomAlias, + title: "#room:server", + ), + ], + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () async { + Navigator.of(alertContext).pop(); + + final client = ref.watch( + ClientController.provider.notifier, + ); + if (context.mounted) { + client.joinRoomWithSnackBars( + context, + roomAlias.text, + ref, + ); + } + }, + child: Text("Join"), + ), + ], + ); + }, + ), + ), + child: ListTile( + title: Text("Join an existing room (or space)"), + leading: Icon(Icons.numbers), + ), + ), + PopupMenuItem( + onTap: () {}, + child: ListTile( + title: Text("Create a new room"), + leading: Icon(Icons.add), + ), + ), + ], + icon: Icon(Icons.add), + ), + IconButton( + tooltip: "Explore other rooms", + onPressed: () => showDialog( + context: context, + builder: (context) => AlertDialog(title: Text("To-do")), + ), + icon: Icon(Icons.explore), + ), + IconButton( + tooltip: "Open settings", + onPressed: () => 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, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/chat_page/wrappers/message_wrapper.dart b/lib/widgets/chat_page/wrappers/message_wrapper.dart new file mode 100644 index 0000000..1be6c2b --- /dev/null +++ b/lib/widgets/chat_page/wrappers/message_wrapper.dart @@ -0,0 +1,59 @@ +import "package:flutter/material.dart"; +import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; +import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; + +class MessageWrapper extends StatelessWidget { + final Message message; + final Widget child; + final Room room; + final MessageGroupStatus? groupStatus; + const MessageWrapper( + this.message, + this.child, + this.groupStatus, + this.room, { + super.key, + }); + + @override + Widget build(BuildContext context) => ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(12)), + child: AnimatedContainer( + padding: message.metadata?["flashing"] == true + ? EdgeInsets.all(8) + : EdgeInsets.all(0), + color: message.metadata?["flashing"] == true + ? Theme.of(context).colorScheme.onSurface.withAlpha(50) + : Colors.transparent, + duration: Duration(milliseconds: 250), + child: Row( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + groupStatus?.isFirst != false + ? MessageAvatar(message, room, height: 40) + : SizedBox(width: 40), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + if (groupStatus?.isFirst != false) + MessageDisplayname( + message, + room, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + child, + ], + ), + ), + ], + ), + ), + ); +} diff --git a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart b/lib/widgets/chat_page/wrappers/text_message_wrapper.dart new file mode 100644 index 0000000..41bc01e --- /dev/null +++ b/lib/widgets/chat_page/wrappers/text_message_wrapper.dart @@ -0,0 +1,115 @@ +import "package:flutter/material.dart"; +import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:flutter_link_previewer/flutter_link_previewer.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/widgets/chat_page/html/html.dart"; +import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart"; +import "package:nexus/widgets/chat_page/reply_widget.dart"; + +class TextMessageWrapper extends StatelessWidget { + final Message message; + final String? content; + final Room room; + 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.room, + required this.updateMessage, + required this.groupStatus, + required this.isSentByMe, + this.extra, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final textMessage = message is TextMessage ? message as TextMessage : null; + + return MessageWrapper( + message, + ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(8)), + child: Container( + padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: BoxDecoration( + color: isSentByMe + ? colorScheme.primaryContainer + : colorScheme.surfaceContainer, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ReplyWidget( + message, + room: room, + groupStatus: groupStatus, + onTapReply: onTapReply, + ), + if (content != null) + Html( + textStyle: message.metadata?["big"] == true + ? TextStyle(fontSize: 32) + : null, + content! + .replaceAllMapped( + RegExp( + "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", + caseSensitive: false, + ), + (m) { + // If it's already an tag, leave it unchanged + if (m.group(1) != null) { + return m.group(1)!; + } + + // Otherwise, wrap the bare URL + final url = m.group(2)!; + return "$url"; + }, + ) + .replaceAll("\n", "
"), + ), + if (textMessage?.editedAt != null) + Text("(edited)", style: theme.textTheme.labelSmall), + if (textMessage != null) + LinkPreview( + text: textMessage.text, + backgroundColor: isSentByMe + ? colorScheme.inversePrimary + : colorScheme.surfaceContainerLow, + outsidePadding: EdgeInsets.only(top: 4), + insidePadding: EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + linkPreviewData: message.metadata?["linkPreviewData"], + onLinkPreviewDataFetched: (linkPreviewData) => updateMessage( + message, + message.copyWith( + metadata: { + ...(message.metadata ?? {}), + "linkPreviewData": linkPreviewData, + }, + ), + ), + ), + if (extra != null) extra!, + ], + ), + ), + ), + groupStatus, + room, + ); + } +} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart deleted file mode 100644 index 618d9ee..0000000 --- a/lib/widgets/composer/composer.dart +++ /dev/null @@ -1,193 +0,0 @@ -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/composer/mention_overlay.dart b/lib/widgets/composer/mention_overlay.dart deleted file mode 100644 index ea5dc6a..0000000 --- a/lib/widgets/composer/mention_overlay.dart +++ /dev/null @@ -1,149 +0,0 @@ -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/members_by_status_controller.dart"; -import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/controllers/via_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/models/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( - this.roomId, { - required this.query, - required this.addTag, - required this.triggerCharacter, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final rooms = ref.watch(RoomsController.provider); - - return Padding( - padding: .all(8), - child: ClipRRect( - borderRadius: .all(.circular(12)), - child: Container( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - padding: .all(8), - child: switch (triggerCharacter) { - "@" => - ref - .watch( - MembersByStatusController.provider( - .new(roomId: roomId, status: .join), - ), - ) - .betterWhen( - data: (members) => ListView( - children: - (query.isEmpty - ? members - : members.where( - (member) => - member.stateKey - ?.toLowerCase() - .contains( - query.toLowerCase(), - ) == - true || - switch (member.content) { - MembershipContent( - :final displayName, - ) => - displayName - ?.toLowerCase() - .contains( - query.toLowerCase(), - ) == - true, - _ => false, - }, - )) - .map( - (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(), - ), - ), - "#" => ListView( - children: - (query.isEmpty - ? rooms.values - : rooms.values.where( - (room) => - (room.metadata?.name ?? room.metadata!.id) - .toLowerCase() - .contains(query.toLowerCase()), - )) - .map((room) { - final name = - room.metadata?.name ?? - room.metadata!.canonicalAlias ?? - room.metadata!.id; - return ListTile( - leading: AvatarOrHash( - room.metadata?.avatar, - name, - fallback: Icon(Icons.numbers), - ), - title: Text(name), - subtitle: room.metadata?.topic == null - ? null - : Text(room.metadata!.topic!, maxLines: 1), - onTap: () { - final vias = ref.watch( - ViaController.provider(room), - ); - addTag( - id: "[#$name](matrix:roomid/${room.metadata?.id.substring(1)}$vias)", - name: - (room.metadata?.canonicalAlias ?? - room.metadata?.id) - ?.substring(1) - .split(":") - .first ?? - "", - ); - }, - ); - }) - .toList(), - ), - - _ => Loading(), - }, - ), - ), - ); - } -} diff --git a/lib/widgets/composer/relation_preview.dart b/lib/widgets/composer/relation_preview.dart deleted file mode 100644 index c9cc271..0000000 --- a/lib/widgets/composer/relation_preview.dart +++ /dev/null @@ -1,66 +0,0 @@ -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 2b0f9bd..ca78844 100644 --- a/lib/widgets/divider_text.dart +++ b/lib/widgets/divider_text.dart @@ -1,5 +1,4 @@ import "package:flutter/material.dart"; -import "package:nexus/widgets/divider_widget.dart"; class DividerText extends StatelessWidget { final String text; @@ -7,6 +6,24 @@ class DividerText extends StatelessWidget { const DividerText(this.text, {super.key}); @override - Widget build(BuildContext context) => - DividerWidget(Text(text, style: Theme.of(context).textTheme.labelLarge)); + 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), + ), + ], + ), + ); } diff --git a/lib/widgets/divider_widget.dart b/lib/widgets/divider_widget.dart deleted file mode 100644 index 6f13bd4..0000000 --- a/lib/widgets/divider_widget.dart +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index bbe1cdc..0000000 --- a/lib/widgets/emoji_picker_button.dart +++ /dev/null @@ -1,52 +0,0 @@ -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 9b62200..b016a8b 100644 --- a/lib/widgets/error_dialog.dart +++ b/lib/widgets/error_dialog.dart @@ -21,12 +21,11 @@ class ErrorDialog extends ConsumerWidget { onPressed: () => ref.invalidate(provider!), child: const Text("Try Again"), ), - if (Navigator.of(context).canPop()) - TextButton( - onPressed: () => - Navigator.of(context).popUntil((route) => route.isFirst), - child: const Text("Go Back"), - ), + 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 deleted file mode 100644 index 7a40a75..0000000 --- a/lib/widgets/event_preview.dart +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index fdfabff..0000000 --- a/lib/widgets/expandable_image.dart +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index afdad89..0000000 --- a/lib/widgets/file_card.dart +++ /dev/null @@ -1,25 +0,0 @@ -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 new file mode 100644 index 0000000..21b2e5c --- /dev/null +++ b/lib/widgets/form_text_input.dart @@ -0,0 +1,83 @@ +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 deleted file mode 100644 index 920db9e..0000000 --- a/lib/widgets/highlight_wrapper.dart +++ /dev/null @@ -1,20 +0,0 @@ -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/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart deleted file mode 100644 index 8e0ce11..0000000 --- a/lib/widgets/html/mention_chip.dart +++ /dev/null @@ -1,48 +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? 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/join_dialog.dart b/lib/widgets/join_dialog.dart deleted file mode 100644 index d420dea..0000000 --- a/lib/widgets/join_dialog.dart +++ /dev/null @@ -1,138 +0,0 @@ -import "package:collection/collection.dart"; -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/spaces_controller.dart"; -import "package:nexus/helpers/extensions/link_to_mention.dart"; - -class JoinDialog extends HookWidget { - final WidgetRef ref; - const JoinDialog(this.ref, {super.key}); - - @override - Widget build(BuildContext context) { - final roomAlias = useTextEditingController(); - return AlertDialog( - title: Text("Join a Room"), - content: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text("Enter the room alias, Matrix URI, or Matrix.to link."), - SizedBox(height: 12), - TextField( - controller: roomAlias, - decoration: .new(hintText: "#room:server"), - ), - ], - ), - actions: [ - TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - - if (context.mounted) { - final roomIdOrAlias = roomAlias.text.mention ?? roomAlias.text; - - final scaffoldMessenger = ScaffoldMessenger.of(context); - - final snackbar = scaffoldMessenger.showSnackBar( - .new( - content: Text("Joining room $roomIdOrAlias."), - duration: Duration(days: 999), - ), - ); - - try { - final id = await ref - .watch(ClientController.provider.notifier) - .joinRoom( - .new( - roomIdOrAlias: roomIdOrAlias, - via: .new( - Uri.tryParse( - roomAlias.text.replaceAll("/#", ""), - )?.queryParametersAll["via"] ?? - [], - ), - ), - ); - - snackbar.close(); - - scaffoldMessenger.showSnackBar( - .new( - content: Text("Room $roomIdOrAlias successfully joined."), - action: .new( - label: "Open", - onPressed: () async { - final spaces = ref.watch(SpacesController.provider); - final space = spaces.firstWhereOrNull( - (space) => space.id == id, - ); - - await ref - .watch( - KeyController.provider( - KeyController.spaceKey, - ).notifier, - ) - .set( - space?.id ?? - spaces - .firstWhere( - (space) => - space.children.any( - (child) => - child.metadata?.id == id, - ) || - space.subSpaces.any( - (child) => - child.room.metadata?.id == id, - ), - ) - .id, - ); - - if (space == null) { - await ref - .watch( - KeyController.provider( - KeyController.roomKey, - ).notifier, - ) - .set(id); - } - }, - ), - ), - ); - } catch (error) { - snackbar.close(); - if (context.mounted) { - scaffoldMessenger.showSnackBar( - .new( - backgroundColor: Theme.of( - context, - ).colorScheme.errorContainer, - content: Text( - error.toString(), - style: .new( - color: Theme.of(context).colorScheme.onErrorContainer, - ), - ), - ), - ); - } - } - } - }, - child: Text("Join"), - ), - ], - ); - } -} diff --git a/lib/widgets/lazy_loading/message_avatar.dart b/lib/widgets/lazy_loading/message_avatar.dart deleted file mode 100644 index b65e377..0000000 --- a/lib/widgets/lazy_loading/message_avatar.dart +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index a817122..0000000 --- a/lib/widgets/lazy_loading/message_displayname.dart +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 653248c..0000000 --- a/lib/widgets/linkified_text.dart +++ /dev/null @@ -1,23 +0,0 @@ -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 fc84563..9bb2858 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: .all(16), + padding: EdgeInsets.all(16), child: SizedBox(height: height, child: CircularProgressIndicator()), ), ); diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart deleted file mode 100644 index 6326421..0000000 --- a/lib/widgets/member_list.dart +++ /dev/null @@ -1,185 +0,0 @@ -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 deleted file mode 100644 index 654ac27..0000000 --- a/lib/widgets/message_image.dart +++ /dev/null @@ -1,57 +0,0 @@ -import "package:cross_cache/cross_cache.dart"; -import "package:flutter/material.dart"; -import "package:flutter_blurhash/flutter_blurhash.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/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 ?? 1) / (info?.height ?? 1), - 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 deleted file mode 100644 index 0c96579..0000000 --- a/lib/widgets/players/audio.dart +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index 8083860..0000000 --- a/lib/widgets/players/video.dart +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index e41c2eb..0000000 --- a/lib/widgets/reaction_row.dart +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index bcca961..0000000 --- a/lib/widgets/renderers/event.dart +++ /dev/null @@ -1,232 +0,0 @@ -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 deleted file mode 100644 index 4f4380a..0000000 --- a/lib/widgets/renderers/generic_event.dart +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index b2835b4..0000000 --- a/lib/widgets/renderers/membership.dart +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 3470246..0000000 --- a/lib/widgets/renderers/message.dart +++ /dev/null @@ -1,299 +0,0 @@ -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 deleted file mode 100644 index e1d5708..0000000 --- a/lib/widgets/room_appbar.dart +++ /dev/null @@ -1,144 +0,0 @@ -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 deleted file mode 100644 index 2817172..0000000 --- a/lib/widgets/room_chat.dart +++ /dev/null @@ -1,515 +0,0 @@ -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/sidebar.dart b/lib/widgets/sidebar.dart deleted file mode 100644 index 56f8d57..0000000 --- a/lib/widgets/sidebar.dart +++ /dev/null @@ -1,290 +0,0 @@ -import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package: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 deleted file mode 100644 index 669c756..0000000 --- a/lib/widgets/url_preview.dart +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index d422e01..0000000 --- a/lib/widgets/user_bottom_sheet.dart +++ /dev/null @@ -1,264 +0,0 @@ -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/CMakeLists.txt b/linux/CMakeLists.txt index fee47c5..2e0c766 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -7,7 +7,7 @@ project(runner LANGUAGES CXX) set(BINARY_NAME "nexus") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "nexus.federated.Nexus") +set(APPLICATION_ID "nexus.federated.nexus") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 755a54e..f70fb6e 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,27 +6,20 @@ #include "generated_plugin_registrant.h" -#include +#include #include -#include -#include #include #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) dynamic_color_registrar = + g_autoptr(FlPluginRegistrar) dynamic_system_colors_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin"); - dynamic_color_plugin_register_with_registrar(dynamic_color_registrar); + dynamic_color_plugin_register_with_registrar(dynamic_system_colors_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); @@ -36,4 +29,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); + g_autoptr(FlPluginRegistrar) window_size_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowSizePlugin"); + window_size_plugin_register_with_registrar(window_size_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index b9ca03c..78dcf40 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,13 +3,12 @@ # list(APPEND FLUTTER_PLUGIN_LIST - dynamic_color + dynamic_system_colors file_selector_linux - media_kit_libs_linux - media_kit_video screen_retriever_linux url_launcher_linux window_manager + window_size ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/linux/nexus.federated.Nexus.desktop b/linux/nexus.federated.Nexus.desktop deleted file mode 100644 index d3fa575..0000000 --- a/linux/nexus.federated.Nexus.desktop +++ /dev/null @@ -1,9 +0,0 @@ -[Desktop Entry] -Name=Nexus -GenericName=Matrix Client -Comment=A simple and user-friendly Matrix client -Exec=nexus -Icon=nexus -Terminal=false -Type=Application -Categories=Chat;Network;InstantMessaging; \ No newline at end of file diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix deleted file mode 100644 index ae77467..0000000 --- a/linux/nix/devshell.nix +++ /dev/null @@ -1,48 +0,0 @@ -{ pkgs, lib }: -let - android = pkgs.androidenv.composeAndroidPackages { - toolsVersion = "26.1.1"; - platformToolsVersion = "36.0.1"; - buildToolsVersions = [ - "35.0.0" - "36.0.0" - ]; - cmakeVersions = [ "3.22.1" ]; - platformVersions = [ "36" ]; - abiVersions = [ - "armeabi-v7a" - "arm64-v8a" - ]; - includeNDK = true; - ndkVersions = [ "28.2.13676358" ]; - }; -in -pkgs.mkShell { - packages = with pkgs; [ - go - git - jdk17 - libGL - wayland - (flutter.override { - extraPkgConfigPackages = [ - mpv-unwrapped - libass - ]; - }) - android.platform-tools - ]; - - env = rec { - LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ]; - LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; - CPATH = lib.makeSearchPath "include" [ pkgs.glibc.dev ]; - - ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk"; - ANDROID_SDK_ROOT = ANDROID_HOME; - JAVA_HOME = pkgs.jdk17; - - TOOLS = "${ANDROID_HOME}/build-tools/${"36.0.0"}"; - GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${TOOLS}/aapt2"; - }; -} diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix deleted file mode 100644 index c879f24..0000000 --- a/linux/nix/pkg/default.nix +++ /dev/null @@ -1,51 +0,0 @@ -{ - lib, - callPackage, - mpv-unwrapped, - libass, - libclang, - flutter, - src, -}: - -flutter.buildFlutterApplication { - pname = "nexus"; - version = "0.1.0"; - inherit src; - - preBuild = '' - cp ${callPackage ./gomuks.nix { inherit src; }}/lib/* . - packageRunCustom nexus generate source/scripts test - packageRun build_runner build - ''; - - buildInputs = [ - mpv-unwrapped - libass - ]; - - env.LIBCLANG_PATH = lib.makeLibraryPath [ libclang ]; - - autoPubspecLock = src + "/pubspec.lock"; - - gitHashes = { - window_size = "sha256-XelNtp7tpZ91QCEcvewVphNUtgQX7xrp5QP0oFo6DgM="; - 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 \ - --suffix LD_LIBRARY_PATH : $out/app/nexus/lib - ''; - - meta = { - description = "A simple and user-friendly Matrix client"; - mainProgram = "nexus"; - platforms = lib.platforms.linux; - maintainers = with lib.maintainers; [ quadradical ]; - }; -} diff --git a/linux/nix/pkg/gomuks.nix b/linux/nix/pkg/gomuks.nix deleted file mode 100644 index fbe402b..0000000 --- a/linux/nix/pkg/gomuks.nix +++ /dev/null @@ -1,31 +0,0 @@ -{ - src, - buildGoModule, -}: - -buildGoModule (finalAttrs: { - pname = "gomuks-ffi"; - version = "submodule"; - - doCheck = false; - - src = "${src}/gomuks"; - - vendorHash = "sha256-/Wvx5WjnlPpQILpNqo9075F3nox0Dm8PfqGgck4CifQ="; - - buildPhase = '' - runHook preBuild - - go build -buildmode=c-shared -o libgomuks.so -tags goolm,noheic,sqlite_fts5 ./pkg/ffi - - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - - install -Dm0644 libgomuks.so -t $out/lib - - runHook postInstall - ''; -}) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index abf5dc5..58cd859 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -43,7 +43,6 @@ static void my_application_activate(GApplication* application) { } } #endif - gtk_widget_set_size_request(GTK_WIDGET(window), 250, -1); if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); diff --git a/nix/android.nix b/nix/android.nix new file mode 100644 index 0000000..f373968 --- /dev/null +++ b/nix/android.nix @@ -0,0 +1,20 @@ +{ + androidenv, +}: +androidenv.composeAndroidPackages { + toolsVersion = "26.1.1"; + platformToolsVersion = "36.0.1"; + buildToolsVersions = [ + "35.0.0" + "36.0.0" + ]; + cmakeVersions = [ "3.22.1" ]; + platformVersions = [ "36" ]; + abiVersions = [ + "armeabi-v7a" + "arm64-v8a" + ]; + includeNDK = true; + ndkVersions = [ "27.0.12077973" ]; + +} diff --git a/pubspec.lock b/pubspec.lock index 4e30e17..af73796 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d url: "https://pub.dev" source: hosted - version: "92.0.0" + version: "91.0.0" analysis_server_plugin: dependency: transitive description: @@ -18,21 +18,21 @@ packages: source: hosted version: "0.3.4" analyzer: - dependency: transitive + dependency: "direct overridden" description: name: analyzer - sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 url: "https://pub.dev" source: hosted - version: "9.0.0" + version: "8.4.1" analyzer_buffer: dependency: transitive description: name: analyzer_buffer - sha256: ff4bd291778c7417fe53fe24ee0d0a1f1ffe281a2d4ea887e7094f16e36eace7 + sha256: "5fcd06b0715ebeee99f03e3f437b3412249969d8d12b191ea8a1d76e42a4e4a1" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.3.1" analyzer_plugin: dependency: transitive description: @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" url: "https://pub.dev" source: hosted - version: "4.0.9" + version: "4.0.7" args: dependency: transitive description: @@ -57,14 +57,30 @@ 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: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.13.1" + version: "2.13.0" + blurhash_dart: + dependency: transitive + description: + name: blurhash_dart + sha256: "43955b6c2e30a7d440028d1af0fa185852f3534b795cc6eb81fbf397b464409f" + url: "https://pub.dev" + source: hosted + version: "1.2.1" boolean_selector: dependency: transitive description: @@ -77,18 +93,18 @@ packages: dependency: transitive description: name: build - sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413 url: "https://pub.dev" source: hosted - version: "4.0.6" + version: "4.0.3" build_config: dependency: transitive description: name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.2.0" build_daemon: dependency: transitive description: @@ -101,10 +117,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057" url: "https://pub.dev" source: hosted - version: "2.15.0" + version: "2.10.4" built_collection: dependency: transitive description: @@ -117,26 +133,18 @@ packages: dependency: transitive description: name: built_value - sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139" url: "https://pub.dev" source: hosted - version: "8.12.6" - button_m3e: - dependency: transitive - description: - name: button_m3e - sha256: "6754ddeb9068ad2005bd26d5ceabc41268029465095686d7d228296c2e706909" - url: "https://pub.dev" - source: hosted - version: "0.1.2" + version: "8.12.1" characters: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" charcode: dependency: transitive description: @@ -153,6 +161,14 @@ 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: @@ -185,6 +201,14 @@ 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: @@ -229,10 +253,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" url: "https://pub.dev" source: hosted - version: "0.3.5+2" + version: "0.3.5+1" crypto: dependency: transitive description: @@ -249,6 +273,30 @@ 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: @@ -261,67 +309,58 @@ packages: dependency: transitive description: name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" + diffutil_dart: + dependency: transitive + description: + name: diffutil_dart + sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81" + url: "https://pub.dev" + source: hosted + version: "4.0.1" dio: dependency: transitive description: name: dio - sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 url: "https://pub.dev" source: hosted - version: "5.9.2" + version: "5.9.0" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" url: "https://pub.dev" source: hosted - 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" + version: "2.1.1" dynamic_polls: dependency: "direct main" description: name: dynamic_polls - sha256: "72ff19cdf041ad8dcfa76adaebb216d005f40b278d955e6e0c7bcb769215fabe" + sha256: fba71ee6fb0ae8f3bebf7d07b3f2a79347d496956de88fb24d3daa32d47e0774 url: "https://pub.dev" source: hosted - version: "0.0.7" - emoji_text_field: + version: "0.0.6" + dynamic_system_colors: dependency: "direct main" description: - path: "." - ref: HEAD - resolved-ref: "5f7baaf8a6f059ec3ab8ff0f5d02339b00bf6997" - url: "https://github.com/Henry-Hiles/emoji_text_field" - source: git - version: "1.0.0" - equatable: - dependency: transitive - description: - name: equatable - sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + name: dynamic_system_colors + sha256: "43794e658fa88cbdec9f397dd1afd2eb69b6c9717e99b93b16ba37c3aa3b3a8c" url: "https://pub.dev" source: hosted - version: "2.0.8" - fab_m3e: + version: "1.8.0" + encrypt: dependency: transitive description: - name: fab_m3e - sha256: e4f5abfa3c8c092005449d56dcac45b85e2dbe9c32789d672c5ed71428e43b59 + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "5.0.3" fake_async: dependency: transitive description: @@ -334,18 +373,18 @@ packages: dependency: "direct main" description: name: fast_immutable_collections - sha256: "58cec99fc068427c71901e82d4b31b232240ebe6e61200993c2cb91bcada0ff6" + sha256: "19f70498af299cbce5ff919dbbecd5abfd9d0c28139004f68d3810ce23dedfb3" url: "https://pub.dev" source: hosted - version: "11.2.0" + version: "11.1.0" ffi: dependency: "direct main" description: name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.5" ffigen: dependency: "direct main" description: @@ -366,10 +405,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 + sha256: d974b6ba2606371ac71dd94254beefb6fa81185bde0b59bdc1df09885da85fde url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "10.3.8" file_selector_linux: dependency: transitive description: @@ -415,14 +454,23 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_blurhash: + flutter_chat_core: dependency: "direct main" description: - name: flutter_blurhash - sha256: e97b9aff13b9930bbaa74d0d899fec76e3f320aba3190322dcc5d32104e3d25d + name: flutter_chat_core + sha256: "8c46790f64f106bf6e610e2a7324b3844320e9e295867c06d45d9deb134d848d" url: "https://pub.dev" source: hosted - version: "0.9.1" + version: "2.9.0" + flutter_chat_ui: + dependency: "direct main" + description: + path: "packages/flutter_chat_ui" + ref: HEAD + resolved-ref: "03be67c8c81c8f637672ee03dd8f082d2c223627" + url: "https://github.com/Henry-Hiles/flutter_chat_ui" + source: git + version: "2.11.1" flutter_hooks: dependency: "direct main" description: @@ -439,14 +487,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.14.4" - flutter_linkify: + flutter_link_previewer: dependency: "direct main" description: - name: flutter_linkify - sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073" - url: "https://pub.dev" - source: hosted - version: "6.0.0" + path: "packages/flutter_link_previewer" + ref: HEAD + resolved-ref: "03be67c8c81c8f637672ee03dd8f082d2c223627" + url: "https://github.com/Henry-Hiles/flutter_chat_ui" + source: git + version: "4.2.0" flutter_lints: dependency: "direct dev" description: @@ -464,10 +513,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 url: "https://pub.dev" source: hosted - version: "2.0.34" + version: "2.0.33" flutter_riverpod: dependency: "direct main" description: @@ -480,10 +529,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.2.3" flutter_test: dependency: transitive description: flutter @@ -498,26 +547,50 @@ packages: dependency: "direct main" description: name: flutter_widget_from_html_core - sha256: "7ff010b116f6abc16429923e616fbc727f3f65ef4cee12ffdb280aeecbc21e7f" + sha256: "1120ee6ed3509ceff2d55aa6c6cbc7b6b1291434422de2411b5a59364dd6ff03" url: "https://pub.dev" source: hosted - version: "0.17.2" + version: "0.17.0" fluttertagger: dependency: "direct main" description: name: fluttertagger - sha256: "04514674b41a063b97901aedf6970d0675b828bd723a0fb9f9dba89b91953382" + sha256: "3df0132bdd431a7279da78ea70500ea1e767fa093f43f32785b757c10c6a0fcc" url: "https://pub.dev" source: hosted - version: "2.3.2" + 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" freezed: dependency: "direct dev" description: name: freezed - sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131 + sha256: "03dd9b7423ff0e31b7e01b2204593e5e1ac5ee553b6ea9d8184dff4a26b9fb07" url: "https://pub.dev" source: hosted - version: "3.2.5" + version: "3.2.4" freezed_annotation: dependency: "direct main" description: @@ -538,10 +611,10 @@ packages: dependency: transitive description: name: get_x_storage - sha256: "69e4412dd70e25a4991623c10bf72e3b12106f2cb4353a2d167353947597f3aa" + sha256: c9c65de2baa228783f46a55137538dc599a3c9b1834130cfd3b417ec3b643813 url: "https://pub.dev" source: hosted - version: "0.0.9" + version: "0.0.8" glob: dependency: transitive description: @@ -562,10 +635,10 @@ packages: dependency: "direct main" description: name: hooks - sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.0" hooks_riverpod: dependency: "direct main" description: @@ -583,7 +656,7 @@ packages: source: hosted version: "0.15.6" http: - dependency: "direct main" + dependency: transitive description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -606,46 +679,38 @@ 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: d46b09e116508e817f5ea2d8e1f6f55fb98bf7966175152809fd29791bfba3b8 + sha256: b26b2ad126be411d0072d1dfc4d97ebe02121a863e4eadc635b511b9bc138489 url: "https://pub.dev" source: hosted - version: "2.9.1" + version: "2.7.1+2" image: dependency: transitive description: name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.7.2" image_picker: dependency: "direct main" description: name: image_picker - sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac" + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.2.1" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f + sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" url: "https://pub.dev" source: hosted - version: "0.8.13+17" + version: "0.8.13+10" image_picker_for_web: dependency: transitive description: @@ -658,10 +723,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" url: "https://pub.dev" source: hosted - version: "0.8.13+6" + version: "0.8.13+3" image_picker_linux: dependency: transitive description: @@ -710,22 +775,30 @@ 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: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted - version: "4.11.0" + version: "4.9.0" json_serializable: dependency: "direct dev" description: name: json_serializable - sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" + sha256: "6b253f7851cf1626a05c8b49c792e04a14897349798c03798137f2b5f7e0b5b1" url: "https://pub.dev" source: hosted - version: "6.13.0" + version: "6.11.3" leak_tracker: dependency: transitive description: @@ -750,23 +823,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" - linkify: - dependency: "direct main" - description: - 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: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.0.0" logging: dependency: transitive description: @@ -775,118 +839,22 @@ 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: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - 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" + version: "0.11.1" meta: dependency: transitive description: @@ -903,31 +871,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - motor: + nested: dependency: transitive description: - name: motor - sha256: cbd49f21b00e568c2b1a55f134ed803614a107782f4fea7769693bca32940c58 + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" url: "https://pub.dev" source: hosted - 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" + version: "1.0.0" node_preamble: dependency: transitive description: @@ -936,14 +887,6 @@ 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: @@ -952,22 +895,6 @@ 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: @@ -996,18 +923,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e url: "https://pub.dev" source: hosted - version: "2.2.23" + version: "2.2.22" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.5.1" path_provider_linux: dependency: transitive description: @@ -1036,10 +963,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" url: "https://pub.dev" source: hosted - version: "7.0.2" + version: "7.0.1" platform: dependency: transitive description: @@ -1056,6 +983,14 @@ 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: @@ -1068,10 +1003,18 @@ packages: dependency: transitive description: name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.0.3" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" pub_semver: dependency: transitive description: @@ -1088,6 +1031,14 @@ 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: @@ -1096,14 +1047,6 @@ 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: @@ -1136,14 +1079,6 @@ 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: @@ -1184,30 +1119,38 @@ 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: "93654267ad36e72ef130ffc05970287f42955b40f07d0efd264e64f7215fa1de" + sha256: "139cf71496105de32e7a08a4e3a1ead0f81c4a616ec9703ed07e8f0d10cdd505" url: "https://pub.dev" source: hosted - version: "3.8.7" + version: "3.8.6" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" url: "https://pub.dev" source: hosted - version: "2.5.5" + version: "2.5.4" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" url: "https://pub.dev" source: hosted - version: "2.4.23" + version: "2.4.18" shared_preferences_foundation: dependency: transitive description: @@ -1228,10 +1171,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.1" shared_preferences_web: dependency: transitive description: @@ -1286,21 +1229,21 @@ packages: source: sdk version: "0.0.0" source_gen: - dependency: transitive + dependency: "direct overridden" description: name: source_gen - sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75" url: "https://pub.dev" source: hosted - version: "4.2.3" + version: "4.1.1" source_helper: dependency: transitive description: name: source_helper - sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" + sha256: e82b1996c63da42aa3e6a34cc1ec17427728a1baf72ed017717a5669a7123f0d url: "https://pub.dev" source: hosted - version: "1.3.12" + version: "1.3.9" source_map_stack_trace: dependency: transitive description: @@ -1321,10 +1264,10 @@ packages: dependency: transitive description: name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.2" + version: "1.10.1" stack_trace: dependency: transitive description: @@ -1365,22 +1308,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - super_sliver_list: - dependency: "direct main" - description: - name: super_sliver_list - sha256: b1e1e64d08ce40e459b9bb5d9f8e361617c26b8c9f3bb967760b0f436b6e3f56 - url: "https://pub.dev" - source: hosted - version: "0.4.1" synchronized: dependency: transitive description: name: synchronized - sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 url: "https://pub.dev" source: hosted - version: "3.4.0+1" + version: "3.4.0" term_glyph: dependency: transitive description: @@ -1393,34 +1328,34 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.16" - timeago: - dependency: "direct main" + version: "0.6.12" + thumbhash: + dependency: transitive description: - name: timeago - sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e + name: thumbhash + sha256: "5f6d31c5279ca0b5caa81ec10aae8dcaab098d82cb699ea66ada4ed09c794a37" url: "https://pub.dev" source: hosted - version: "3.7.1" + version: "0.1.0+1" typed_data: dependency: transitive description: @@ -1445,22 +1380,6 @@ 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: @@ -1473,18 +1392,18 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" url: "https://pub.dev" source: hosted - version: "6.3.29" + version: "6.3.28" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "6.3.6" url_launcher_linux: dependency: transitive description: @@ -1513,10 +1432,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "2.4.1" url_launcher_windows: dependency: transitive description: @@ -1529,18 +1448,18 @@ packages: dependency: transitive description: name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 url: "https://pub.dev" source: hosted - version: "4.5.3" + version: "4.5.2" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: "4d35a36400983c3457c289d4d553b5308f506ea84f7e51c7a564651b5525209a" + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.1.19" vector_graphics_codec: dependency: transitive description: @@ -1553,10 +1472,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "98e7e94de127b46a86ef46197fff84ff99f3d3b80a708390d717ad731efef598" + sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.1.19" vector_math: dependency: transitive description: @@ -1569,26 +1488,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "15.2.0" - wakelock_plus: - dependency: transitive - description: - name: wakelock_plus - sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 - url: "https://pub.dev" - source: hosted - version: "1.5.2" - wakelock_plus_platform_interface: - dependency: transitive - description: - name: wakelock_plus_platform_interface - sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b - url: "https://pub.dev" - source: hosted - version: "1.5.1" + version: "15.0.2" watcher: dependency: transitive description: @@ -1645,6 +1548,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.1" + window_size: + dependency: "direct main" + description: + path: "plugins/window_size" + ref: HEAD + resolved-ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601 + url: "https://github.com/google/flutter-desktop-embedding" + source: git + version: "0.1.0" xdg_directories: dependency: transitive description: @@ -1673,10 +1585,10 @@ packages: dependency: transitive description: name: yaml_edit - sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" + sha256: ec709065bb2c911b336853b67f3732dd13e0336bd065cc2f1061d7610ddf45e3 url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.2.3" sdks: - dart: ">=3.11.5 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.10.4 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index da8b1ec..ec69ae9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: nexus description: "Yet another Matrix client" -version: 0.1.0 +version: 1.0.0 publish_to: none flutter: @@ -9,80 +9,76 @@ flutter: uses-material-design: true environment: - sdk: "^3.11.5" + sdk: "^3.9.2" dependency_overrides: - linkify: - git: - url: https://github.com/appelladev/linkify - ref: fix/consecutive-periods-loose-url + analyzer: ^8.4.0 + source_gen: ^4.0.2 + flutter_hooks: ^0.21.2 dependencies: flutter: sdk: flutter flutter_localizations: sdk: flutter - 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: + 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: ^1.8.0 + collection: ^1.19.1 + window_manager: ^0.5.1 + window_size: 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: + url: https://github.com/google/flutter-desktop-embedding + path: plugins/window_size + 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: git: - url: https://github.com/Henry-Hiles/material_3_expressive - path: packages/navigation_rail_m3e - m3e_card_list: ^0.1.0 + url: https://github.com/Henry-Hiles/flutter_chat_ui + path: packages/flutter_chat_ui + flutter_link_previewer: + git: + url: https://github.com/Henry-Hiles/flutter_chat_ui + path: packages/flutter_link_previewer + color_hash: ^1.0.1 + flutter_widget_from_html_core: ^0.17.0 + flutter_svg: ^2.2.2 + 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 dev_dependencies: - 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 + 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 flutter_launcher_icons: ios: true android: true image_path: assets/icon.png - adaptive_icon_background: assets/background.png + adaptive_icon_background: "#000000" 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/scripts/generate.dart b/scripts/generate.dart index 446a469..b240d98 100644 --- a/scripts/generate.dart +++ b/scripts/generate.dart @@ -3,7 +3,26 @@ import "package:ffigen/ffigen.dart"; import "package:path/path.dart"; void main(List args) async { - final repoDir = Directory.fromUri(Platform.script.resolve("../gomuks")); + final repoDir = Directory.fromUri( + Platform.script.resolve("../src/gomuks/source"), + ); + if (await repoDir.exists()) await repoDir.delete(recursive: true); + await repoDir.create(recursive: true); + + print("Cloning Gomuks repository..."); + final cloneResult = await Process.run("git", [ + "clone", + "--depth", + "1", + "https://mau.dev/gomuks/gomuks", + repoDir.path, + ]); + + if (cloneResult.exitCode != 0) { + throw Exception( + "Failed to clone Gomuks repository: \n${cloneResult.stderr}", + ); + } print("Generating FFI Bindings..."); diff --git a/scripts/generate.sh b/scripts/generate.sh new file mode 100755 index 0000000..6076ab8 --- /dev/null +++ b/scripts/generate.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +pushd "$(dirname "$(readlink -f "$0")")"/.. > /dev/null || exit + +mkdir -p build +touch build/lock +dart scripts/generate.dart +rm build/lock + +popd > /dev/null || exit \ No newline at end of file diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 7938787..55fb066 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,27 +6,24 @@ #include "generated_plugin_registrant.h" -#include +#include #include -#include -#include #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { DynamicColorPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); - MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); - MediaKitVideoPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); + WindowSizePluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowSizePlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 64ef5b5..9333a2f 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,13 +3,12 @@ # list(APPEND FLUTTER_PLUGIN_LIST - dynamic_color + dynamic_system_colors file_selector_windows - media_kit_libs_windows_video - media_kit_video screen_retriever_windows url_launcher_windows window_manager + window_size ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 3583d23..24405eb 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -89,11 +89,11 @@ BEGIN BEGIN BLOCK "040904e4" BEGIN - VALUE "CompanyName", "nexus.federated.Nexus" "\0" + VALUE "CompanyName", "nexus.federated.nexus" "\0" VALUE "FileDescription", "nexus" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "nexus" "\0" - VALUE "LegalCopyright", "Copyright (C) 2025 nexus.federated.Nexus. All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 nexus.federated.nexus. All rights reserved." "\0" VALUE "OriginalFilename", "nexus.exe" "\0" VALUE "ProductName", "nexus" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index f8a91f7..e3c83c9 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ