diff --git a/.forgejo/workflows/e2e.yml b/.forgejo/workflows/e2e.yml new file mode 100644 index 0000000..134143b --- /dev/null +++ b/.forgejo/workflows/e2e.yml @@ -0,0 +1,28 @@ +name: "End-to-end tests" + +on: + push: + branches: ["main"] + pull_request: + workflow_dispatch: + +jobs: + e2e: + runs-on: ["large", "debian-13-x86_64"] + + 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: Generate code + run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build" + + - name: Run end-to-end tests + run: nix run .#e2e-headless diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..cf64422 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,39 @@ +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 --target-platform android-arm64 --split-per-abi --dart-define=GIT_COMMIT=${{ github.sha }}" + 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-arm64-v8a-release.apk diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml new file mode 100644 index 0000000..c255c8d --- /dev/null +++ b/.github/workflows/flatpak.yml @@ -0,0 +1,73 @@ +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 + + publish: + needs: build-flatpak + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Install flatpak tooling + run: sudo apt-get update && sudo apt-get install -y flatpak ostree + + - name: Download bundles + uses: actions/download-artifact@v7 + with: + pattern: flatpak-* + path: bundles + + - name: Build OSTree repo + run: | + ostree init --repo=repo --mode=archive-z2 + for bundle in bundles/*/nexus.federated.nexus.flatpak; do + flatpak build-import-bundle repo "$bundle" + done + flatpak build-update-repo --prune repo + + - uses: actions/upload-pages-artifact@v4 + with: + path: repo + + - name: Deploy to Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..28344ea --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,48 @@ +name: "Build iOS App" + +on: + push: + branches: ["main"] + tags: ["*"] + workflow_dispatch: + +jobs: + build-app: + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: 3.47.4 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: gomuks/go.mod + + - name: Build app + run: | + flutter pub get + dart scripts/generate.dart + flutter pub run build_runner build + flutter build ios --no-codesign --dart-define=GIT_COMMIT=${{ github.sha }} + + # Unsigned IPA: SideStore/AltStore re-sign this on-device with the + # user's own Apple ID at install time, so no certificate is needed here. + - name: Package unsigned IPA + run: | + mkdir Payload + cp -r build/ios/iphoneos/Runner.app Payload/ + zip -r Nexus.ipa Payload + + - name: Upload IPA artifact + uses: actions/upload-artifact@v6 + with: + name: Nexus.ipa + path: Nexus.ipa diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000..b56f16f --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,47 @@ +name: "Build MacOS App" + +on: + push: + branches: ["main"] + tags: ["*"] + workflow_dispatch: + +jobs: + build-app: + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: 3.47.4 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: gomuks/go.mod + + - name: Build App + run: | + flutter pub get + dart scripts/generate.dart + flutter pub run build_runner build + flutter build macos --dart-define=GIT_COMMIT=${{ github.sha }} + + - name: Create DMG + id: create-dmg + uses: L-Super/create-dmg-actions@28511e988b13ca34096d439159a66d468b8e724f + with: + dmg_name: nexus + src_dir: build/macos/Build/Products/Release/Nexus.app + + - name: Upload DMG + uses: actions/upload-artifact@v6 + with: + name: nexus.dmg + path: ${{ steps.create-dmg.outputs.dmg_path }} diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index c8099d1..db339b9 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -1,46 +1,74 @@ -name: "Build Windows Version" +name: "Build EXE" on: - workflow_dispatch: + push: + branches: ["main"] + tags: ["*"] + workflow_dispatch: jobs: - build-windows: - runs-on: "windows-latest" + build-exe: + runs-on: windows-latest - steps: - - name: "Checkout repository" - uses: "actions/checkout@v4" + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + submodules: recursive - - name: "Set up Flutter" - uses: "subosito/flutter-action@v2" + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: 3.47.4 - - name: "Set up Rust" - uses: "dtolnay/rust-toolchain@stable" - with: - targets: "x86_64-pc-windows-msvc" + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: gomuks/go.mod - - name: "Install Flutter dependencies" - run: flutter pub get + - name: Setup MSYS2 + uses: msys2/setup-msys2@v2 + with: + msystem: CLANG64 + install: >- + mingw-w64-x86_64-gcc - - name: "Run build_runner & build Windows EXE" - run: | - flutter pub run build_runner build --delete-conflicting-outputs - flutter build windows --release + - name: Go build + run: | + cd gomuks/pkg/ffi + go build -tags goolm,sqlite_fts5 -o ../../../libgomuks.dll -buildmode=c-shared - - name: "Upload exe zip" - uses: "actions/upload-artifact@v4" - with: - name: "windows-portable" - path: "build/windows/x64/runner/Release/" + - name: Build with Flutter + run: | + flutter pub get + dart scripts/generate.dart + flutter pub run build_runner build + flutter build windows --dart-define=GIT_COMMIT=${{ github.sha }} - - name: "Install Inno Setup" - run: choco install innosetup -y + - 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: "Build Inno Setup installer" - run: iscc windows/installer.iss + - name: Copy MSVC runtime DLLs + shell: powershell + run: | + $release = "build/windows/x64/runner/Release" - - name: "Upload installer artifact" - uses: "actions/upload-artifact@v4" - with: - name: "windows-installer" - path: "windows/dist/Nexus-Setup.exe" + Copy-Item C:\Windows\System32\MSVCP140.dll $release -Force + Copy-Item C:\Windows\System32\VCRUNTIME140.dll $release -Force + Copy-Item C:\Windows\System32\VCRUNTIME140_1.dll $release -Force + + - name: Install Inno Setup + run: choco install innosetup -y + + - name: Build Inno Setup installer + run: iscc windows/installer.iss + + - name: Upload installer artifact + uses: actions/upload-artifact@v6 + with: + name: windows-installer + path: windows/dist/Nexus-Setup.exe diff --git a/.gitignore b/.gitignore index d6616e1..912ced3 100644 --- a/.gitignore +++ b/.gitignore @@ -36,7 +36,11 @@ key.properties # Generated Files *.g.dart *.freezed.dart -src/ # Devel Password -password.txt \ No newline at end of file +password.txt + +# Nix +/result +# Widget Preview related +.widget_preview/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b300a1f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "gomuks"] + path = gomuks + url = https://github.com/gomuks/gomuks + branch = quad/nexus diff --git a/.metadata b/.metadata index 6651909..f12453b 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "nixpkgs000000000000000000000000000000000" + revision: "6b182d2c7585eba26d4edce0f97630effd256c33" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: nixpkgs000000000000000000000000000000000 - base_revision: nixpkgs000000000000000000000000000000000 - - platform: windows - create_revision: nixpkgs000000000000000000000000000000000 - base_revision: nixpkgs000000000000000000000000000000000 + create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + - platform: ios + create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 + base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33 # User provided section diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b83039c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + "configurations": [ + { + "name": "Flutter", + "type": "dart", + "request": "launch", + "program": "lib/main.dart" + }, + { + "name": "Flutter (Headless)", + "type": "dart", + "request": "launch", + "program": "lib/main.dart", + "env": { "FLUTTER_HEADLESS": "1" } + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 25ea52b..2d6d7e3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,8 +2,19 @@ "cSpell.words": [ "Appbar", "Displayname", + "fluttertagger", + "Gomuks", "Homeserver", + "Linkified", + "localpart", + "msgtype", + "muks", "prefs", - "vodozemac" + "recents", + "unban", + "underride", + "unifiedpush", + "unredact", + "webpush" ] } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..fa4a0a1 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,104 @@ +# 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.read(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(final SomeInputType input) + extends AsyncNotifier { + @override + Future build() async { + return input.foo; + } + + static final provider = + AsyncNotifierProvider.family( + MyController.new, + ); +} +``` + +#### `watch` vs `read` + +Only use `ref.watch` in a `build` method, where you want to rebuild when the value changes. Everywhere else, like callbacks, `useEffect`, and methods on a controller, use `ref.read`. + +Use `.select` to narrow down what you depend on, e.g. `ref.watch(RoomsController.provider.select((rooms) => rooms[roomId]))`. + +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. + +I'm also not a big fan of unneeded private methods or variables, but this is contextual. + +### Modern Dart syntax + +We try to use newer Dart language features where they make code shorter: + +- Primary constructors for classes, including widgets, e.g. `class const MemberList(final String roomId, {super.key}) extends HookConsumerWidget`. +- Dot shorthands wherever the type can be inferred, e.g. `.new(...)`, `padding: .all(8)`, `relationType == .reply`. +- Patterns and switch expressions over null checks and casts, e.g. `if (event.content case MessageContent(:final body?))` rather than checking `is` then using `!`. + +### Prefer `map` over inline `for` loops + +When building a list (or set, or map) from another collection, use `.map`, `.expand` and friends rather than a `for` inside the collection literal: + +```dart +// Prefer +children: rooms.map((room) => RoomTile(room)).toList(), + +// Over +children: [for (final room in rooms) RoomTile(room)], +``` + +Regular `for` statements are fine for logic that doesn't just build a collection. + +### Async values and errors + +When rendering an `AsyncValue`, use the `betterWhen` extension, which shows our standard loading and error widgets, or a switch expression if you need more control (e.g. `AsyncData(:final value) || AsyncLoading(:final value?)` to keep showing old data while reloading). + +For futures started from the UI that nothing awaits, like button presses, add `.onError(showError)` so failures are shown to the user instead of being lost. + +## LLM/AI Assisted Contributions + +LLM generated code is NOT allowed. All contributions should be written by humans. Using LLMs for interacting with others, e.g. for Comments, PRs, etc, is also not allowed. + +## 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 1299c71..5d3090f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,8 @@ # Nexus Client -> [!WARNING] -> 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 the Matrix Dart SDK. +A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. ## Screenshots @@ -13,135 +10,109 @@ A simple and user-friendly Matrix client made with Flutter and the Matrix Dart S | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------: | | ![Screenshot of Nexus Client in dark mode, showing users talking, with a sidebar showing rooms and spaces, and another sidebar showing members](./assets/screenshotDark.png) | ![The same screenshot as above, but in light mode](./assets/screenshotLight.png) | -## Progress +## Try it 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 - - [ ] 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] 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 +If you want to try out Nexus, download the [latest release](https://git.federated.nexus/Nexus/nexus/releases/latest). -## Build Instructions +### Nightly Builds -First, clone and open the repo: +For the newest changes, grab one of the following nightly artifacts, built by CI from the latest commit on `main`. -```sh -git clone https://git.federated.nexus/Henry-Hiles/nexus -cd nexus +- [Android APK](https://nightly.link/Henry-Hiles/nexus/workflows/android/main/APK.zip) +- [Unsigned iOS IPA](https://nightly.link/Henry-Hiles/nexus/workflows/ios/main/Nexus.ipa.zip) +- [Unsigned MacOS DMG](https://nightly.link/Henry-Hiles/nexus/workflows/macos/main/nexus.dmg.zip) +- [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip) + +Or, use one of the below methods: + +### Nightly Flatpak Repo + +Make sure Flatpak is set up on your system, then run: + +```bash +flatpak install --user https://nexus.federated.nexus/flatpak/nexus.flatpakref ``` +This adds the Nexus remote and installs the app for your user only, so no root access is needed (x86_64 and aarch64 are supported). To install system-wide instead, use `--system` in place of `--user`. Updates arrive through `flatpak update` or your software center. + +### NixOS Module + +Add Nexus as a flake input, following `nixpkgs` for less fetches: + +```nix +nexus = { + url = "git+https://git.federated.nexus/Nexus/nexus"; + inputs.nixpkgs.follows = "nixpkgs"; +}; +``` + +This tracks the latest commit on `main`. To stay on a release instead, pin the input to its tag: + +```nix +url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.1"; +``` + +Then import the module and enable it in your NixOS configuration: + +```nix +{ inputs, ... }: +{ + imports = [ inputs.nexus.nixosModules.default ]; + + programs.nexus = { + enable = true; + enableNotifications = true; # optional: push notifications via UnifiedPush + }; +} +``` + +This installs Nexus system-wide. With `enableNotifications`, it also installs KUnifiedPush and registers its systemd and D-Bus services so notifications work. The `package` option lets you override the package if you need to. + +## Build it yourself + ### Prerequisites #### Linux -- With Nix: Either use direnv, or `nix flake develop` -- Without Nix: Install Flutter, Go, Olm, Git, Clang, and GLibc. +- On NixOS: Either use direnv and `direnv allow`, or `nix develop` +- On other distros: Install Flutter, Go, Git, Libclang, Libass, MPV, and Glibc. Do not use any Snap packages, they cause various compilation issues. -#### Windows / MacOS +#### Windows -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. +You will need: + +- Flutter +- Git +- Libass +- MPV +- 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 +``` ### Set up Flutter @@ -151,22 +122,23 @@ Get dependencies: flutter pub get ``` -Get dependencies: +Generate Gomuks bindings: ```sh -flutter pub get +dart scripts/generate.dart ``` -Clone Gomuks and generate bindings: - -```sh -scripts/generate.sh -``` +> [!NOTE] +> If you are having issues with `stddef.h` not being found, try setting CPATH manually: +> +> ```sh +> export CPATH="$(clang -v 2>&1 | grep "Selected GCC installation" | rev | cut -d' ' -f1 | rev)/include" +> ``` Build generated files, and watch for new changes: ```sh -flutter pub run build_runner watch --delete-conflicting-outputs +flutter pub run build_runner watch ``` Run the app: @@ -175,6 +147,13 @@ Run the app: flutter run ``` +Development instructions can be found in [DEVELOPMENT.md](./DEVELOPMENT.md). + ## Community -Join the [Nexus Client Matrix Room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client. +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/analysis_options.yaml b/analysis_options.yaml index a8b1078..00df0c4 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,12 +1,18 @@ analyzer: - errors: - invalid_annotation_target: ignore - avoid_print: ignore - exclude: - - "build/**" - - "**/*.g.dart" - - "**/*.freezed.dart" + errors: + invalid_annotation_target: ignore + avoid_print: ignore + exclude: + - "build/**" + - "**/*.g.dart" + - "**/*.freezed.dart" + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: - rules: - prefer_double_quotes: true \ No newline at end of file + rules: + prefer_double_quotes: true diff --git a/android/app/build.gradle b/android/app/build.gradle deleted file mode 100644 index ce5f465..0000000 --- a/android/app/build.gradle +++ /dev/null @@ -1,72 +0,0 @@ -plugins { - id "com.android.application" - id "kotlin-android" - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id "dev.flutter.flutter-gradle-plugin" -} - -def localProperties = new Properties() -def localPropertiesFile = rootProject.file("local.properties") -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader("UTF-8") { reader -> - localProperties.load(reader) - } -} - -def flutterVersionCode = localProperties.getProperty("flutter.versionCode") -if (flutterVersionCode == null) { - flutterVersionCode = "1" -} - -def flutterVersionName = localProperties.getProperty("flutter.versionName") -if (flutterVersionName == null) { - flutterVersionName = "1.0" -} - -def keystoreProperties = new Properties() -def keystorePropertiesFile = rootProject.file('key.properties') -if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) -} - -android { - namespace = "nexus.federated.Nexus" - ndkVersion = flutter.ndkVersion - compileSdk = 34 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - defaultConfig { - applicationId = "nexus.federated.Nexus" - minSdk = 29 - targetSdkVersion flutter.targetSdkVersion - versionCode = flutterVersionCode.toInteger() - versionName = flutterVersionName - } - - signingConfigs { - release { - keyAlias "key" - storeFile keystoreProperties['path'] ? file(keystoreProperties['path']) : file(System.getenv("KEYSTORE_PATH")) - keyPassword keystoreProperties['password'] ?: System.getenv("KEYSTORE_PASSWORD") - storePassword keystoreProperties['password'] ?: System.getenv("KEYSTORE_PASSWORD") - } - } - - buildTypes { - release { - signingConfig signingConfigs.release - } - - debug { - applicationIdSuffix = ".debug" - } - } -} - -flutter { - source = "../.." -} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..9c0d96c --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,84 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("dev.flutter.flutter-gradle-plugin") +} + +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") + +if (keystorePropertiesFile.exists()) { + keystorePropertiesFile.inputStream().use { + keystoreProperties.load(it) + } +} + +android { + namespace = "nexus.federated.nexus" + compileSdk = 36 + ndkVersion = flutter.ndkVersion + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + defaultConfig { + applicationId = "nexus.federated.nexus" + + minSdk = 29 + targetSdk = flutter.targetSdkVersion + + versionCode = flutter.versionCode + versionName = flutter.versionName + + multiDexEnabled = true + } + + signingConfigs { + create("release") { + keyAlias = "key" + + val storePath = + keystoreProperties["path"]?.toString() + ?: System.getenv("KEYSTORE_PATH") + + storeFile = storePath?.let { file(it) } + + keyPassword = + keystoreProperties["password"]?.toString() + ?: System.getenv("KEYSTORE_PASSWORD") + + storePassword = + keystoreProperties["password"]?.toString() + ?: System.getenv("KEYSTORE_PASSWORD") + } + } + + buildTypes { + release { + signingConfig = signingConfigs.getByName("release") + } + + debug { + applicationIdSuffix = ".debug" + } + } +} + +dependencies { + implementation("org.unifiedpush.android:embedded-fcm-distributor:3.1.0") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 + } +} + +flutter { + source = "../.." +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1c369c9..7378547 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,16 +1,20 @@ + + + + + + + + + + + + + + + + + + + + + + - + + inkscape:current-layer="svg11" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icon.png b/assets/icon.png index 04b75cb..d6d4906 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/assets/icon.svg b/assets/icon.svg deleted file mode 100644 index 0effd9a..0000000 --- a/assets/icon.svg +++ /dev/null @@ -1,150 +0,0 @@ - - - - diff --git a/assets/mobile.png b/assets/mobile.png new file mode 100644 index 0000000..6b1b81c Binary files /dev/null and b/assets/mobile.png differ diff --git a/assets/mobile.svg b/assets/mobile.svg new file mode 100644 index 0000000..7ca0a7d --- /dev/null +++ b/assets/mobile.svg @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/monochrome.png b/assets/monochrome.png new file mode 100644 index 0000000..941c706 Binary files /dev/null and b/assets/monochrome.png differ diff --git a/assets/monochrome.svg b/assets/monochrome.svg new file mode 100644 index 0000000..a86f36e --- /dev/null +++ b/assets/monochrome.svg @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/reply-preview.png b/assets/reply-preview.png deleted file mode 100644 index 3c4cc3e..0000000 Binary files a/assets/reply-preview.png and /dev/null differ diff --git a/assets/reply.webp b/assets/reply.webp deleted file mode 100644 index e8f139e..0000000 Binary files a/assets/reply.webp and /dev/null differ diff --git a/assets/screenshotDark.png b/assets/screenshotDark.png index ae75dc7..48be6e5 100644 Binary files a/assets/screenshotDark.png and b/assets/screenshotDark.png differ diff --git a/assets/screenshotLight.png b/assets/screenshotLight.png index 0b2ce0d..5aae7a0 100644 Binary files a/assets/screenshotLight.png and b/assets/screenshotLight.png differ diff --git a/assets/twim/.gitkeep b/assets/twim/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/flake.lock b/flake.lock index 7826732..13a4db7 100644 --- a/flake.lock +++ b/flake.lock @@ -1,15 +1,112 @@ { "nodes": { + "advisory-db": { + "flake": false, + "locked": { + "lastModified": 1793979272, + "narHash": "sha256-oUcBtPbtS7PlEMuSrgTAN4wv49cXYbwKbQqhH2pah5w=", + "owner": "rustsec", + "repo": "advisory-db", + "rev": "309392b18f6b17022f82c701f300433126901068", + "type": "github" + }, + "original": { + "owner": "rustsec", + "repo": "advisory-db", + "type": "github" + } + }, + "continuwuity": { + "inputs": { + "advisory-db": "advisory-db", + "crane": "crane", + "fenix": "fenix", + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "nixpkgs": [ + "nixpkgs" + ], + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1794353021, + "narHash": "sha256-9eu1PH9znI34Sn5c0YCUMFUyU+D4BL+6+VqwgaU4YAI=", + "ref": "v26.11.0-alpha.1", + "rev": "38fb7897bb53805414f09e5262e297d1e800f01c", + "revCount": 8131, + "type": "git", + "url": "https://forgejo.ellis.link/Continuwuation/Continuwuity" + }, + "original": { + "ref": "v26.11.0-alpha.1", + "type": "git", + "url": "https://forgejo.ellis.link/Continuwuation/Continuwuity" + } + }, + "crane": { + "locked": { + "lastModified": 1793836618, + "narHash": "sha256-TREenflwEjxOSq292FYwbc3kEm7TDRtXCZRNWEdjBmg=", + "owner": "ipetkov", + "repo": "crane", + "rev": "79fd63528501446749c09f08cad486a58c7d173e", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "fenix": { + "inputs": { + "nixpkgs": [ + "continuwuity", + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1794142885, + "narHash": "sha256-xWFSr7q8IerDAVRk4W7/CgTmi05hMy2qjZ0z3VYpOVk=", + "owner": "nix-community", + "repo": "fenix", + "rev": "f7d416b5073152c7f2e4de1a28d1a4330dd30a26", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "edolstra", + "ref": "master", + "repo": "flake-compat", + "type": "github" + } + }, "flake-parts": { "inputs": { "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1767609335, - "narHash": "sha256-feveD98mQpptwrAEggBQKJTYbvwwglSbOv53uCfH9PY=", + "lastModified": 1793576659, + "narHash": "sha256-V1h85lUaJBQoHFrB2vpXZOE0Ju1MYSnIpsehoVZYlDo=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "250481aafeb741edfe23d29195671c19b36b6dca", + "rev": "ea2630b7470978622c7689a1ff75d2b389175399", "type": "github" }, "original": { @@ -18,13 +115,70 @@ "type": "github" } }, + "flake-parts_2": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib_2" + }, + "locked": { + "lastModified": 1793576659, + "narHash": "sha256-V1h85lUaJBQoHFrB2vpXZOE0Ju1MYSnIpsehoVZYlDo=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "ea2630b7470978622c7689a1ff75d2b389175399", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "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": 1791751882, + "narHash": "sha256-DWcMlQFQMEqQGAHU6rAbl4uKWmlJMR1HZG0BvH0H2fk=", + "owner": "neobrain", + "repo": "nix2flatpak", + "rev": "7731e8b3fca73879f8217de5a919c0a5d17303de", + "type": "github" + }, + "original": { + "owner": "neobrain", + "repo": "nix2flatpak", + "type": "github" + } + }, "nixpkgs": { "locked": { - "lastModified": 1767640445, - "narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=", + "lastModified": 1793540411, + "narHash": "sha256-fiPJ88izTVucjeo2EQ3jk4aWmLg03I1D4vxnX2QjEgE=", "owner": "nixos", "repo": "nixpkgs", - "rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5", + "rev": "0bb2eedc20e4739f29e8045034f2828ad7054f8f", "type": "github" }, "original": { @@ -36,11 +190,26 @@ }, "nixpkgs-lib": { "locked": { - "lastModified": 1765674936, - "narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=", + "lastModified": 1793504054, + "narHash": "sha256-ThkfL3D/FhCiZFc9+3BtxwuMmpuK+/bGtXCSo1+yVew=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85", + "rev": "7851ba61fd862b277cb6a9cef37394bc18bb94e4", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs-lib_2": { + "locked": { + "lastModified": 1793504054, + "narHash": "sha256-ThkfL3D/FhCiZFc9+3BtxwuMmpuK+/bGtXCSo1+yVew=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "7851ba61fd862b277cb6a9cef37394bc18bb94e4", "type": "github" }, "original": { @@ -51,9 +220,64 @@ }, "root": { "inputs": { - "flake-parts": "flake-parts", + "continuwuity": "continuwuity", + "flake-parts": "flake-parts_2", + "nix2flatpak": "nix2flatpak", "nixpkgs": "nixpkgs" } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1794096810, + "narHash": "sha256-dWC+YtMX+cwH3e9ndiFB77ijTsKtO5I2OxfcvUY4j3k=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "03ae6be57aa063bb373f69eead3b8e15168a45d2", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "continuwuity", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1791885023, + "narHash": "sha256-C3alQ2MEt0zVrUSho5NUDo3jmhbff72++bfwoqT9Omo=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "0d6d739a833ba9a9c93034862e4a7b061a062a1c", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index de21b13..f5a2651 100644 --- a/flake.nix +++ b/flake.nix @@ -2,8 +2,18 @@ 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"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + # TEMPORARY: the e2e tests against Continuwuity from the forge, till the update gets into unstable + continuwuity = { + url = "git+https://forgejo.ellis.link/Continuwuation/Continuwuity?ref=v26.11.0-alpha.1"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = @@ -14,55 +24,12 @@ ... }@inputs: flake-parts.lib.mkFlake { inherit inputs; } { + imports = [ ./linux/nix ]; systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ]; - - perSystem = - { - lib, - pkgs, - system, - ... - }: - - { - _module.args.pkgs = import nixpkgs { - inherit system; - config = { - permittedInsecurePackages = [ "olm-3.2.16" ]; - android_sdk.accept_license = true; - allowUnfree = true; - }; - }; - - devShells = - let - packages = with pkgs; [ - go - olm - 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 - { - default = pkgs.mkShell { - inherit env; - packages = packages ++ [ - pkgs.flutter - ]; - }; - - nix = pkgs.mkShell { inherit packages env; }; - }; - }; }; } diff --git a/gomuks b/gomuks new file mode 160000 index 0000000..94d91fb --- /dev/null +++ b/gomuks @@ -0,0 +1 @@ +Subproject commit 94d91fbbb70b1519f545f3ec1ae523a6ee965e23 diff --git a/hook/build.dart b/hook/build.dart index 4cb2f91..8726642 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -1,40 +1,196 @@ import "dart:io"; + +import "package:collection/collection.dart"; import "package:hooks/hooks.dart"; import "package:code_assets/code_assets.dart"; +import "package:nexus/helpers/extensions/get_xcode_sdk.dart"; +import "package:path/path.dart"; Future main(List args) => build(args, (input, output) async { - final buildDir = input.packageRoot.resolve("src/"); - if (await File(buildDir.resolve("lock").toFilePath()).exists()) return; + if (!input.config.buildCodeAssets) return; + final codeConfig = input.config.code; + final targetOS = codeConfig.targetOS; + final targetArch = codeConfig.targetArchitecture; - final targetOS = input.config.code.targetOS; String libFileName; + Map extraEnv = {}; + IOSSdk? iosSdk; + String? iosSdkPath; + String? iosTargetTriple; switch (targetOS) { case OS.linux: libFileName = "libgomuks.so"; break; + case OS.iOS: + // Go only supports buildmode=c-archive for ios, not c-shared, so the + // archive built below gets linked into a dylib via a separate clang + // invocation before being registered as a normal asset like macOS. + libFileName = "libgomuks.dylib"; + final iosConfig = codeConfig.iOS; + iosSdk = iosConfig.targetSdk; + final minVersion = iosConfig.targetVersion; + iosSdkPath = await getXCodeTool(sdkType: iosSdk.type); + + final archTriple = switch (targetArch) { + Architecture.arm64 => "arm64", + Architecture.x64 => "x86_64", + _ => throw UnsupportedError( + "Unsupported iOS architecture: $targetArch", + ), + }; + + iosTargetTriple = iosSdk == IOSSdk.iPhoneSimulator + ? "$archTriple-apple-ios$minVersion.0-simulator" + : "$archTriple-apple-ios$minVersion.0"; + extraEnv = { + "GOOS": "ios", + "CC": await getXCodeTool(sdkType: iosSdk.type, findTool: "clang"), + "CGO_CFLAGS": "-isysroot $iosSdkPath -target $iosTargetTriple", + "CGO_LDFLAGS": "-isysroot $iosSdkPath -target $iosTargetTriple", + }; + break; case OS.macOS: libFileName = "libgomuks.dylib"; + extraEnv = { + "SDKROOT": await getXCodeTool(), + "MACOSX_DEPLOYMENT_TARGET": codeConfig.macOS.targetVersion.toString(), + }; break; case OS.windows: libFileName = "libgomuks.dll"; break; + case OS.android: + libFileName = "libgomuks.so"; + + final targetNdkApi = codeConfig.android.targetNdkApi; + + Future findNdkFromSdk() async { + final androidHome = + Platform.environment["ANDROID_HOME"] ?? + Platform.environment["ANDROID_SDK_ROOT"]; + if (androidHome == null) return null; + + final ndkDir = Directory(join(androidHome, "ndk")); + if (!await ndkDir.exists()) return null; + + final versions = await ndkDir.list().toList(); + return versions.sortedBy((file) => file.path).lastOrNull?.path; + } + + 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.", + ); + } + + // TODO: Someone please give me a way to detect host architecture so I can change this + final hostTag = + Platform.environment["ANDROID_HOST_TAG"] ?? + "${Platform.operatingSystem}-x86_64"; + final ccTriple = switch (targetArch) { + Architecture.arm64 => "aarch64-linux-android", + Architecture.arm => "armv7a-linux-androideabi", + Architecture.x64 => "x86_64-linux-android", + Architecture.ia32 => "i686-linux-android", + _ => throw UnsupportedError( + "Unsupported Android architecture: $targetArch", + ), + }; + final cc = + "$ndkHome/toolchains/llvm/prebuilt/$hostTag/bin/$ccTriple$targetNdkApi-clang"; + + extraEnv = {"GOOS": "android", "CC": cc}; + break; default: throw UnsupportedError("Unsupported OS: $targetOS"); } - final gomuksBuildDir = buildDir.resolve("gomuks/"); - final libFile = gomuksBuildDir.resolve(libFileName); + var libFile = input.packageRoot.resolve(libFileName); + final gomuksBuildDir = input.packageRoot.resolve("gomuks/"); - print("Building Gomuks shared library $libFileName from source..."); - final result = await Process.run("go", [ - "build", - "-o", - libFile.path, - "-buildmode=c-shared", - ], workingDirectory: gomuksBuildDir.resolve("source/pkg/ffi/").toFilePath()); + if (!(await File.fromUri(libFile).exists())) { + final buildDir = input.packageRoot.resolve("build/"); + libFile = buildDir.resolve("${targetArch.name}/$libFileName"); - if (result.exitCode != 0) { - throw Exception("Failed to build Gomuks shared library\n${result.stderr}"); + final tags = [ + "sqlite_fts5", + "goolm", + // goheif/dav1d is not supported on Android or iOS, would need to be fixed upstream + "noheic", + ].join(","); + + final archiveFile = targetOS == OS.iOS + ? buildDir.resolve("${targetArch.name}/libgomuks.a") + : libFile; + print( + "Building Gomuks shared library $libFileName (${targetOS.name}/${targetArch.name}) to ${archiveFile.path}...", + ); + final result = await Process.run( + "go", + [ + "build", + "-trimpath", + "-ldflags=-s -w", + "-tags", + tags, + "-o", + archiveFile.path, + "-buildmode=${targetOS == OS.iOS ? "c-archive" : "c-shared"}", + ], + workingDirectory: gomuksBuildDir.resolve("pkg/ffi/").toFilePath(), + environment: { + "CGO_ENABLED": "1", + "GOARCH": switch (targetArch) { + Architecture.arm64 => "arm64", + Architecture.arm => "arm", + Architecture.x64 => "amd64", + Architecture.ia32 => "386", + _ => throw UnsupportedError("Unsupported architecture: $targetArch"), + }, + ...extraEnv, + }, + ); + + if (result.exitCode != 0) { + throw Exception( + "Failed to build Gomuks shared library\n${result.stderr}", + ); + } + + if (targetOS == OS.iOS) { + print("Linking $archiveFile into $libFile..."); + final linkResult = await Process.run("xcrun", [ + "--sdk", + iosSdk!.type, + "clang", + "-dynamiclib", + "-isysroot", + iosSdkPath!, + "-target", + iosTargetTriple!, + "-install_name", + "@rpath/libgomuks.dylib", + "-framework", + "Security", + "-framework", + "CoreFoundation", + "-framework", + "SystemConfiguration", + "-force_load", + archiveFile.path, + "-o", + libFile.path, + ]); + if (linkResult.exitCode != 0) { + throw Exception("Failed to link Gomuks dylib\n${linkResult.stderr}"); + } + } } final generatedFile = "src/third_party/gomuks.g.dart"; diff --git a/integration_test/helpers/fakes.dart b/integration_test/helpers/fakes.dart new file mode 100644 index 0000000..050eb99 --- /dev/null +++ b/integration_test/helpers/fakes.dart @@ -0,0 +1,47 @@ +import "dart:async"; + +import "package:app_links_platform_interface/app_links_platform_interface.dart"; +import "package:url_launcher_platform_interface/link.dart"; +import "package:url_launcher_platform_interface/url_launcher_platform_interface.dart"; + +/// Delivers links to the app as if the OS had opened them. +class FakeAppLinks extends AppLinksPlatform { + final _links = StreamController.broadcast(); + + void open(Uri uri) => _links.add(uri); + + @override + Future getInitialLink() async => null; + + @override + Future getInitialLinkString() async => null; + + @override + Future getLatestLink() async => null; + + @override + Future getLatestLinkString() async => null; + + @override + Stream get uriLinkStream => _links.stream; + + @override + Stream get stringLinkStream => + _links.stream.map((uri) => uri.toString()); +} + +/// Hands launched URLs to [onLaunch] instead of opening a browser. +class FakeUrlLauncher(final void Function(Uri url) onLaunch) + extends UrlLauncherPlatform { + @override + LinkDelegate? get linkDelegate => null; + + @override + Future canLaunch(String url) async => true; + + @override + Future launchUrl(String url, LaunchOptions options) async { + onLaunch(.parse(url)); + return true; + } +} diff --git a/integration_test/helpers/matrix_user.dart b/integration_test/helpers/matrix_user.dart new file mode 100644 index 0000000..bd2692c --- /dev/null +++ b/integration_test/helpers/matrix_user.dart @@ -0,0 +1,174 @@ +import "dart:convert"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:http/http.dart" as http; + +const homeserver = String.fromEnvironment("NEXUS_E2E_HOMESERVER"); + +/// A user acting over the client-server API directly, as a control for the +/// tests: it doesn't go through gomuks or any other Nexus code, so it sets +/// things up and checks the server's side independently of what's being +/// tested. That includes Nexus' models, so the JSON it sends is written out +/// here rather than built the way Nexus builds it. +class const MatrixUser(final String userId, final String accessToken) { + static Future login(String username, String password) async { + final json = await request( + "POST", + "/_matrix/client/v3/login", + body: { + "type": "m.login.password", + "identifier": {"type": "m.id.user", "user": username}, + "password": password, + }, + ); + return MatrixUser(json["user_id"], json["access_token"]); + } + + static Future> request( + String method, + String path, { + String? accessToken, + Object? body, + }) async { + final httpRequest = + http.Request(method, Uri.parse(homeserver).resolve(path)) + ..headers.addAll({ + "Content-Type": "application/json", + if (accessToken != null) "Authorization": "Bearer $accessToken", + }); + if (method != "GET") httpRequest.body = jsonEncode(body ?? {}); + final response = await http.Response.fromStream(await httpRequest.send()); + + if (response.statusCode == 429) { + final retryAfter = jsonDecode(response.body)["retry_after_ms"] ?? 1000; + await Future.delayed(.new(milliseconds: retryAfter)); + return request(method, path, accessToken: accessToken, body: body); + } + if (response.statusCode >= 300) { + throw StateError( + "$method $path failed with ${response.statusCode}: ${response.body}", + ); + } + return jsonDecode(response.body); + } + + Future createRoom(String name) async => (await request( + "POST", + "/_matrix/client/v3/createRoom", + accessToken: accessToken, + body: {"name": name, "preset": "private_chat"}, + ))["room_id"]; + + Future invite(String roomId, String userId) => request( + "POST", + "/_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/invite", + accessToken: accessToken, + body: {"user_id": userId}, + ); + + Future join(String roomId) => request( + "POST", + "/_matrix/client/v3/join/${Uri.encodeComponent(roomId)}", + accessToken: accessToken, + ); + + Future logout() => + request("POST", "/_matrix/client/v3/logout", accessToken: accessToken); + + Future sendEvent( + String roomId, + String type, + Map content, + ) async => (await request( + "PUT", + "/_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/send/$type/" + "${DateTime.now().microsecondsSinceEpoch}", + accessToken: accessToken, + body: content, + ))["event_id"]; + + /// Sends a text message. With [threadRoot] it's a reply in that thread, and + /// otherwise with [replyTo] it's a reply to that event. + Future send( + String roomId, + String body, { + String? threadRoot, + String? replyTo, + }) => sendEvent(roomId, "m.room.message", { + "msgtype": "m.text", + "body": body, + "m.relates_to": ?switch ((threadRoot, replyTo)) { + (final threadRoot?, _) => { + "rel_type": "m.thread", + "event_id": threadRoot, + "is_falling_back": true, + "m.in_reply_to": {"event_id": threadRoot}, + }, + (null, final replyTo?) => { + "m.in_reply_to": {"event_id": replyTo}, + }, + (null, null) => null, + }, + }); + + /// Sends [rootBody], then [replies] in a thread from it, oldest first. + Future<({String rootId, IList replyIds})> sendThread( + String roomId, + String rootBody, + IList replies, + ) async { + final rootId = await send(roomId, rootBody); + // One at a time, so they're in order (oops earlier tests weren't reproducable) + final replyIds = await Stream.fromIterable(replies) + .asyncMap((body) => send(roomId, body, threadRoot: rootId)) + .toList(); + return (rootId: rootId, replyIds: replyIds.toIList()); + } + + /// Edits the text message [eventId] to say [body]. + Future edit(String roomId, String eventId, String body) => + sendEvent(roomId, "m.room.message", { + "msgtype": "m.text", + "body": "* $body", + "m.new_content": {"msgtype": "m.text", "body": body}, + "m.relates_to": {"rel_type": "m.replace", "event_id": eventId}, + }); + + Future redact(String roomId, String eventId) => request( + "PUT", + "/_matrix/client/v3/rooms/${Uri.encodeComponent(roomId)}/redact/" + "${Uri.encodeComponent(eventId)}/${DateTime.now().microsecondsSinceEpoch}", + accessToken: accessToken, + ); + + /// The bodies of the replies in the thread from [threadRoot], oldest first. + Future> threadReplies(String roomId, String threadRoot) async { + final json = await request( + "GET", + "/_matrix/client/v1/rooms/${Uri.encodeComponent(roomId)}/relations/" + "${Uri.encodeComponent(threadRoot)}/m.thread?dir=f&limit=100", + accessToken: accessToken, + ); + return (json["chunk"] as List) + .map((event) => event["content"]["body"] as String) + .toIList(); + } + + Future> threadRepliesIncluding( + String roomId, + String threadRoot, + String body, { + int attempts = 20, + }) async { + final replies = await threadReplies(roomId, threadRoot); + if (replies.contains(body) || attempts <= 1) return replies; + + await Future.delayed(const .new(milliseconds: 500)); + return threadRepliesIncluding( + roomId, + threadRoot, + body, + attempts: attempts - 1, + ); + } +} diff --git a/integration_test/helpers/oauth.dart b/integration_test/helpers/oauth.dart new file mode 100644 index 0000000..be652d7 --- /dev/null +++ b/integration_test/helpers/oauth.dart @@ -0,0 +1,68 @@ +import "package:http/http.dart" as http; + +import "fakes.dart"; + +/// Fake platforms that do the browser's part of OAuth on Continuwuity, logging +/// in as [username], then hand the redirect back to Nexus. +class OAuthFakes(final String username, final String password) { + final links = FakeAppLinks(); + late final launcher = FakeUrlLauncher(onLaunch); + + Future onLaunch(Uri url) async { + if (!url.queryParameters.containsKey("code_challenge")) return; + + final redirect = await authorize(url); + // Nexus listens for the redirect after the launch returns + await Future.delayed(const .new(milliseconds: 500)); + links.open(redirect); + } + + /// Logs in on [authorizationUrl], then confirms the grant, returning the URI + /// Continuwuity redirects back to the app with. + Future authorize(Uri authorizationUrl) async { + final client = http.Client(); + String? cookie; + + Future followRedirect( + String method, + Uri url, [ + Map? form, + ]) async { + final request = http.Request(method, url) + ..followRedirects = false + ..headers.addAll({ + // Continuwuity only accepts its forms from top-level navigations + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + "Cookie": ?cookie, + }); + if (form != null) request.bodyFields = form; + + final response = await http.Response.fromStream( + await client.send(request), + ); + if (response.headers["set-cookie"] case final setCookie?) { + cookie = setCookie.split(";").first; + } + + return switch ((response.statusCode, response.headers["location"])) { + (302 || 303 || 307, final location?) => url.resolve(location), + (final status, _) => throw StateError( + "Expected $method $url to redirect, got $status: ${response.body}", + ), + }; + } + + try { + final loginUrl = await followRedirect("GET", authorizationUrl); + final grantUrl = await followRedirect("POST", loginUrl, { + "identifier": username, + "password": password, + }); + return await followRedirect("POST", grantUrl, {"stage": "confirm"}); + } finally { + client.close(); + } + } +} diff --git a/integration_test/helpers/pump_until.dart b/integration_test/helpers/pump_until.dart new file mode 100644 index 0000000..5ae8358 --- /dev/null +++ b/integration_test/helpers/pump_until.dart @@ -0,0 +1,56 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; + +// Custom because `patrol_finder` gives me jQuery vibes, and that's Not GoodTM +extension PumpUntil on WidgetTester { + /// Pumps until [finder] finds something, failing if it doesn't within 30 + /// seconds, or if the app shows an error. + Future pumpUntil(Finder finder) async { + const timeout = Duration(seconds: 30); + + String textsIn(Finder finder) => widgetList( + find.descendant(of: finder, matching: find.byType(Text)), + ).map((text) => text.data).nonNulls.join("\n"); + + final stopwatch = Stopwatch()..start(); + while (stopwatch.elapsed < timeout) { + await pump(const .new(milliseconds: 100)); + + if (finder.evaluate().isNotEmpty) return; + + final errorDialog = find.byType(ErrorDialog); + if (errorDialog.evaluate().isNotEmpty) { + fail( + "The app showed an error while waiting for $finder:\n" + "${textsIn(errorDialog)}", + ); + } + } + + final snackBar = find.byType(SnackBar); + final shown = find + .byType(RichText) + .hitTestable() + .evaluate() + .map((element) => (element.widget as RichText).text.toPlainText()) + .where((text) => text.trim().isNotEmpty) + .toIList() + .removeDuplicates() + .join(" | "); + fail( + "Timed out after $timeout waiting for $finder" + "${snackBar.evaluate().isEmpty ? "" : "\nSnack bar: ${textsIn(snackBar)}"}" + "\nShown: $shown", + ); + } + + /// Taps [finder] once it can be tapped, like after animating in. + Future tapWhenShown(Finder finder) async { + final tappable = finder.hitTestable(); + await pumpUntil(tappable); + await tap(tappable.first); + await pump(); + } +} diff --git a/integration_test/helpers/test_app.dart b/integration_test/helpers/test_app.dart new file mode 100644 index 0000000..d6724c0 --- /dev/null +++ b/integration_test/helpers/test_app.dart @@ -0,0 +1,115 @@ +import "package:app_links_platform_interface/app_links_platform_interface.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:media_kit/media_kit.dart"; +import "package:nexus/controllers/active_account.dart"; +import "package:nexus/main.dart" as nexus; +import "package:nexus/widgets/account_scope.dart"; +import "package:nexus/widgets/pages/chat.dart"; +import "package:nexus/widgets/pages/select_server.dart"; +import "package:nexus/widgets/pages/verify.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:url_launcher_platform_interface/url_launcher_platform_interface.dart"; + +import "matrix_user.dart"; +import "oauth.dart"; +import "pump_until.dart"; + +/// Nexus, run as the account [username], which is set up through the UI the +/// first time it's opened. +class TestApp(final String username, final String password) { + late final fakes = OAuthFakes(username, password); + + void setUp() { + nexus.isInBackground = false; + MediaKit.ensureInitialized(); + AppLinksPlatform.instance = fakes.links; + UrlLauncherPlatform.instance = fakes.launcher; + + // A tap that misses would otherwise only warn + WidgetController.hitTestWarningShouldBeFatal = true; + } + + Future join(MatrixUser inviter, String roomId) async { + final user = await MatrixUser.login(username, password); + await inviter.invite(roomId, user.userId); + await user.join(roomId); + await user.logout(); + } + + /// Starts Nexus like [open], then opens [roomName]. + Future openRoom(WidgetTester tester, String roomName) async { + await open(tester); + await selectRoom(tester, roomName); + } + + /// Taps [roomName] in the room list, opening the drawer it's in first when + /// the window is narrow enough for the mobile layout. + Future selectRoom(WidgetTester tester, String roomName) async { + // Shown once the first sync is done, as is the drawer button on mobile + await tester.pumpUntil(find.byType(RoomChat)); + + final drawerButton = find + .byTooltip(const DefaultMaterialLocalizations().openAppDrawerTooltip) + .hitTestable(); + if (drawerButton.evaluate().isNotEmpty) await tester.tap(drawerButton); + await tester.tapWhenShown(find.text(roomName)); + } + + /// Starts Nexus, logged in and verified, logging in and setting up + /// encryption through the UI first if it isn't. + Future open(WidgetTester tester) async { + await tester.pumpWidget( + AccountScope( + initialAccountId: await ActiveAccountController.stored(), + retry: (_, _) => null, + child: nexus.App(), + ), + ); + + await tester.pumpUntil( + find.byWidgetPredicate( + (widget) => + widget is SelectServerPage || + widget is VerifyPage || + widget is ChatPage, + ), + ); + if (find.byType(SelectServerPage).evaluate().isNotEmpty) { + await logIn(tester); + } + if (find.byType(VerifyPage).evaluate().isNotEmpty) { + await setUpEncryption(tester); + } + await tester.pumpUntil(find.byType(ChatPage)); + } + + Future logIn(WidgetTester tester) async { + final homeserverField = find.widgetWithText(TextField, "Homeserver URL"); + await tester.pumpUntil(homeserverField); + await tester.enterText(homeserverField, homeserver); + await tester.testTextInput.receiveAction(.done); + + // Discovery only uses HTTPS, which the test homeserver doesn't serve, so + // Nexus offers to log in with the URL as typed + await tester.tapWhenShown(find.text("Attempt log in anyways")); + + // Nexus opens the authorization page, which fakes authorizes + await tester.pumpUntil( + find.byWidgetPredicate( + (widget) => widget is VerifyPage || widget is ChatPage, + ), + ); + } + + /// Sets up encryption from the verify page, for an account without it. + Future setUpEncryption(WidgetTester tester) async { + await tester.pumpUntil(find.text("Set Up Encryption")); + await tester.tapWhenShown(find.widgetWithText(TextButton, "Set Up")); + + await tester.tapWhenShown(find.text("I've saved my recovery key")); + await tester.tapWhenShown(find.widgetWithText(TextButton, "Set Up")); + + await tester.tapWhenShown(find.widgetWithText(TextButton, "Done")); + } +} diff --git a/integration_test/helpers/threads.dart b/integration_test/helpers/threads.dart new file mode 100644 index 0000000..d2eee0d --- /dev/null +++ b/integration_test/helpers/threads.dart @@ -0,0 +1,34 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:material_ui/material_ui.dart"; + +import "pump_until.dart"; + +extension MessageFinders on CommonFinders { + Finder message(String body) => textContaining(body, findRichText: true); +} + +extension RelativeFinders on Finder { + Finder inside(Type type) => + find.descendant(of: find.byType(type), matching: this); + + /// The [InkWell] ancestor around what this finds + Finder get inkWell => find.ancestor(of: this, matching: find.byType(InkWell)); +} + +extension ReplyCounts on WidgetTester { + /// Waits for a reply count in widgets of [type], then checks it, so a wrong + /// count fails straight away with what was shown. + Future expectReplyCount(Type type, int expected) async { + final repliesLabel = RegExp(r"^(\d+) repl(?:y|ies)$"); + final labels = find.textContaining(repliesLabel).inside(type); + await pumpUntil(labels); + + final shown = widgetList(labels) + .map((label) => label.data) + .nonNulls + .map(repliesLabel.firstMatch) + .nonNulls + .map((match) => int.parse(match[1]!)); + expect(shown, everyElement(expected), reason: "Reply count in $type"); + } +} diff --git a/integration_test/links_test.dart b/integration_test/links_test.dart new file mode 100644 index 0000000..4137fff --- /dev/null +++ b/integration_test/links_test.dart @@ -0,0 +1,56 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; +import "package:nexus/widgets/users/user_bottom_sheet.dart"; + +import "helpers/matrix_user.dart"; +import "helpers/test_app.dart"; +import "helpers/pump_until.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final app = TestApp("dave", "davepass"); + setUpAll(app.setUp); + final run = DateTime.now().millisecondsSinceEpoch; + Future bob() => MatrixUser.login("bob", "bobpass"); + + Future openLink(WidgetTester tester, String link) async { + await app.open(tester); + app.fakes.links.open(.parse(link)); + } + + group("a link opened by the OS", () { + testWidgets("to a user opens their popover", (tester) async { + await openLink(tester, "matrix:u/bob:localhost"); + + await tester.pumpUntil(find.byType(UserBottomSheet)); + }); + + testWidgets("to a joined room opens it", (tester) async { + final inviter = await bob(); + final roomId = await inviter.createRoom("Linked $run"); + // Joined before Nexus starts, so the room is there once it syncs + await app.join(inviter, roomId); + + await openLink(tester, "matrix:roomid/${roomId.substring(1)}"); + + await tester.pumpUntil( + find.byWidgetPredicate( + (widget) => widget is RoomChat && widget.roomId == roomId, + ), + ); + }); + + testWidgets("to a room that isn't joined shows its summary", ( + tester, + ) async { + final roomId = await (await bob()).createRoom("Unjoined $run"); + + await openLink(tester, "matrix:roomid/${roomId.substring(1)}"); + + await tester.pumpUntil(find.byType(RoomSummarySheet)); + }); + }); +} diff --git a/integration_test/long_thread_test.dart b/integration_test/long_thread_test.dart new file mode 100644 index 0000000..49e450a --- /dev/null +++ b/integration_test/long_thread_test.dart @@ -0,0 +1,115 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:nexus/widgets/threads/thread_drawer.dart"; +import "package:nexus/widgets/threads/threads_drawer.dart"; + +import "helpers/matrix_user.dart"; +import "helpers/test_app.dart"; +import "helpers/pump_until.dart"; +import "helpers/threads.dart"; + +/// A thread longer than gomuks' initial sync, which gets the latest 100 +/// events, so its start only reaches Nexus through `/relations`. That needs +/// an account whose first sync is after the thread exists, so it's not the one +/// other tests use. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final app = TestApp("carol", "carolpass"); + setUpAll(app.setUp); + final run = DateTime.now().millisecondsSinceEpoch; + final roomName = "Long thread $run"; + final rootBody = "Long thread root $run"; + const replyCount = 120; + // Among the first replies, which aren't synced + const jumpedTo = 10; + // Padded, so no reply's text contains another's (oops those failures were confusing when I didn't do that) + String reply(int number) => "Long reply ${number.toString().padLeft(3, "0")}"; + + setUpAll(() async { + final bob = await MatrixUser.login("bob", "bobpass"); + final roomId = await bob.createRoom(roomName); + await app.join(bob, roomId); + + final thread = await bob.sendThread( + roomId, + rootBody, + Iterable.generate(replyCount, (index) => reply(index + 1)).toIList(), + ); + // In the room's timeline, so there's something to jump from, to a reply + // with others on both sides that aren't synced either + await bob.send( + roomId, + "Quoting an old reply", + replyTo: thread.replyIds[jumpedTo - 1], + ); + }); + + /// Drags the open thread by [offset] until [finder] is shown in it, loading + /// more replies as it goes, for up to 30 seconds. + Future scrollThread( + WidgetTester tester, + Finder finder, + Offset offset, + ) => tester.dragUntilVisible( + finder.inside(ThreadDrawer), + find.byType(CustomScrollView).inside(ThreadDrawer).first, + offset, + maxIteration: 60, + duration: const .new(milliseconds: 500), + ); + + Future openThreadsDrawer(WidgetTester tester) async { + await app.openRoom(tester, roomName); + await tester.tapWhenShown(find.byTooltip("Open threads")); + await tester.pumpUntil(find.message(rootBody).inside(ThreadsDrawer)); + } + + testWidgets("lists the thread with all of its replies", (tester) async { + await openThreadsDrawer(tester); + + await tester.expectReplyCount(ThreadsDrawer, replyCount); + }); + + // Before the whole thread is loaded by scrolling through it below + testWidgets("jumps to a reply in the thread that isn't loaded", ( + tester, + ) async { + await app.openRoom(tester, roomName); + + // The quote of the reply is fetched on its own, then tapped + await tester.tapWhenShown( + find.message(reply(jumpedTo)).inside(RoomChat).inkWell, + ); + + // The thread opens around the reply, with replies on both sides + await tester.pumpUntil( + find.message(reply(jumpedTo)).inside(ThreadDrawer).hitTestable(), + ); + await tester.pumpUntil( + find.message(reply(jumpedTo - 1)).inside(ThreadDrawer), + ); + await tester.pumpUntil( + find.message(reply(jumpedTo + 1)).inside(ThreadDrawer), + ); + + await scrollThread(tester, find.message(reply(60)), const .new(0, -400)); + }); + + testWidgets("loads older replies back to the root", (tester) async { + await openThreadsDrawer(tester); + await tester.tapWhenShown( + find.message(rootBody).inside(ThreadsDrawer).inkWell, + ); + await tester.pumpUntil( + find.message(reply(replyCount)).inside(ThreadDrawer), + ); + + // Dragging down scrolls up the reversed timeline, to older replies + await scrollThread(tester, find.message(rootBody), const .new(0, 400)); + expect(find.message(reply(1)).inside(ThreadDrawer), findsWidgets); + }); +} diff --git a/integration_test/threads_test.dart b/integration_test/threads_test.dart new file mode 100644 index 0000000..8105f1b --- /dev/null +++ b/integration_test/threads_test.dart @@ -0,0 +1,258 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/composer/composer.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:nexus/widgets/threads/thread_drawer.dart"; +import "package:nexus/widgets/threads/thread_summary.dart"; +import "package:nexus/widgets/threads/threads_drawer.dart"; + +import "helpers/matrix_user.dart"; +import "helpers/test_app.dart"; +import "helpers/pump_until.dart"; +import "helpers/threads.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final app = TestApp("alice", "alicepass"); + setUpAll(app.setUp); + final run = DateTime.now().millisecondsSinceEpoch; + late MatrixUser bob; + + setUpAll(() async => bob = await MatrixUser.login("bob", "bobpass")); + + /// Opens [roomName], waiting for the thread from [rootBody] in its timeline. + Future openRoom( + WidgetTester tester, + String roomName, + String rootBody, + ) async { + await app.openRoom(tester, roomName); + await tester.pumpUntil( + find.message(rootBody).inside(RoomChat).hitTestable(), + ); + } + + /// Opens the thread from the room's summary, waiting for [reply] in it. + Future openThread(WidgetTester tester, String reply) async { + await tester.tapWhenShown(find.byType(ThreadSummary)); + await tester.pumpUntil(find.message(reply).inside(ThreadDrawer)); + } + + group("a thread", () { + final roomName = "Threads $run"; + final rootBody = "Thread root $run"; + const firstReply = "First reply"; + const secondReply = "Second reply"; + + late String roomId; + late String rootId; + + setUpAll(() async { + roomId = await bob.createRoom(roomName); + // Joined before Nexus logs in, so the room is there once it does + await app.join(bob, roomId); + + rootId = (await bob.sendThread( + roomId, + rootBody, + .new([firstReply, secondReply]), + )).rootId; + }); + + Future replyCount() async => + (await bob.threadReplies(roomId, rootId)).length; + + testWidgets("logs in with OAuth and sets up encryption", (tester) async { + await app.open(tester); + + await app.selectRoom(tester, roomName); + }); + + testWidgets("summarises the thread under its root", (tester) async { + await openRoom(tester, roomName, rootBody); + + await tester.expectReplyCount(ThreadSummary, 2); + expect(find.message(secondReply).inside(ThreadSummary), findsWidgets); + // Replies are in the thread, not the room's timeline + expect(find.message(firstReply), findsNothing); + }); + + testWidgets("lists the thread in the threads drawer", (tester) async { + await openRoom(tester, roomName, rootBody); + await tester.tapWhenShown(find.byTooltip("Open threads")); + + await tester.pumpUntil(find.message(rootBody).inside(ThreadsDrawer)); + await tester.expectReplyCount(ThreadsDrawer, 2); + expect(find.text("Last active:").inside(ThreadsDrawer), findsOneWidget); + }); + + testWidgets("opens the thread from the threads drawer", (tester) async { + await openRoom(tester, roomName, rootBody); + await tester.tapWhenShown(find.byTooltip("Open threads")); + await tester.tapWhenShown( + find.message(rootBody).inside(ThreadsDrawer).inkWell, + ); + + await tester.pumpUntil(find.message(secondReply).inside(ThreadDrawer)); + }); + + testWidgets("shows the root and its replies, oldest first", (tester) async { + await openRoom(tester, roomName, rootBody); + await openThread(tester, secondReply); + + final root = find.message(rootBody).inside(ThreadDrawer).first; + final first = find.message(firstReply).inside(ThreadDrawer).first; + final second = find.message(secondReply).inside(ThreadDrawer).first; + expect(root, findsOneWidget); + expect(first, findsOneWidget); + expect( + tester.getTopLeft(root).dy, + lessThan(tester.getTopLeft(first).dy), + reason: "The root should be above the replies", + ); + expect( + tester.getTopLeft(first).dy, + lessThan(tester.getTopLeft(second).dy), + reason: "Older replies should be above newer ones", + ); + }); + + testWidgets("sends replies to the thread", (tester) async { + final body = "Reply from Nexus $run"; + final before = await replyCount(); + await openRoom(tester, roomName, rootBody); + await openThread(tester, secondReply); + + final composer = find + .widgetWithText(TextField, "Your message here...") + .inside(ThreadDrawer); + await tester.pumpUntil(composer); + await tester.enterText(composer, body); + await tester.tapWhenShown( + find.byTooltip("Send message").inside(ThreadDrawer), + ); + await tester.pumpUntil(find.message(body).inside(ThreadDrawer)); + + // It's in the thread for other clients too + final replies = await bob.threadRepliesIncluding(roomId, rootId, body); + expect(replies.last, body); + + // Shown as the latest reply once the summary has caught up + await tester.pumpUntil(find.message(body).inside(ThreadSummary)); + await tester.expectReplyCount(ThreadSummary, before + 1); + expect( + find.text(body).inside(Composer), + findsNothing, + reason: "The composer should be cleared once sent", + ); + }); + + testWidgets("shows replies from others as they arrive", (tester) async { + final body = "Live reply $run"; + await openRoom(tester, roomName, rootBody); + await openThread(tester, secondReply); + + final before = await replyCount(); + await bob.send(roomId, body, threadRoot: rootId); + + await tester.pumpUntil(find.message(body).inside(ThreadDrawer)); + await tester.pumpUntil(find.message(body).inside(ThreadSummary)); + await tester.expectReplyCount(ThreadSummary, before + 1); + }); + }); + + group("redactions", () { + final roomName = "Redactions $run"; + final rootBody = "Redaction root $run"; + const keptReply = "Kept reply"; + const redactedReply = "Redacted reply"; + + late String roomId; + late String rootId; + late String redactedId; + + setUpAll(() async { + roomId = await bob.createRoom(roomName); + await app.join(bob, roomId); + + final thread = await bob.sendThread( + roomId, + rootBody, + .new([keptReply, redactedReply]), + ); + rootId = thread.rootId; + redactedId = thread.replyIds.last; + }); + + testWidgets("drops a redacted reply from its thread", (tester) async { + await openRoom(tester, roomName, rootBody); + await tester.expectReplyCount(ThreadSummary, 2); + + await bob.redact(roomId, redactedId); + + // The reply before it becomes the latest + await tester.pumpUntil(find.message(keptReply).inside(ThreadSummary)); + await tester.expectReplyCount(ThreadSummary, 1); + + await openThread(tester, keptReply); + expect(find.message(redactedReply).inside(ThreadDrawer), findsNothing); + }); + + testWidgets("still opens the thread of a redacted root", (tester) async { + await bob.redact(roomId, rootId); + await app.openRoom(tester, roomName); + await tester.tapWhenShown(find.byTooltip("Open threads")); + + // The root's preview is redacted, so the card is found by its count + await tester.tapWhenShown( + find.text("1 reply").inside(ThreadsDrawer).inkWell, + ); + + final root = find.text("Redacted event").inside(ThreadDrawer); + final reply = find.message(keptReply).inside(ThreadDrawer); + await tester.pumpUntil(root); + await tester.pumpUntil(reply); + expect( + tester.getTopLeft(root.first).dy, + lessThan(tester.getTopLeft(reply.first).dy), + reason: "The redacted root should still be above the replies", + ); + }); + }); + + group("edits", () { + final roomName = "Edits $run"; + final rootBody = "Edit root $run"; + const originalReply = "Reply before editing"; + const editedReply = "Reply after editing"; + + late String roomId; + late String replyId; + + setUpAll(() async { + roomId = await bob.createRoom(roomName); + await app.join(bob, roomId); + + final thread = await bob.sendThread( + roomId, + rootBody, + .new([originalReply]), + ); + replyId = thread.replyIds.single; + }); + + testWidgets("shows an edited reply's new text", (tester) async { + await openRoom(tester, roomName, rootBody); + await tester.pumpUntil(find.message(originalReply).inside(ThreadSummary)); + + await bob.edit(roomId, replyId, editedReply); + + await tester.pumpUntil(find.message(editedReply).inside(ThreadSummary)); + await openThread(tester, editedReply); + expect(find.message(originalReply).inside(ThreadDrawer), findsNothing); + expect(find.text("(edited)").inside(ThreadDrawer), findsOneWidget); + }); + }); +} diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 6f637da..391a902 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -2,25 +2,23 @@ - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - nexus.federated.Nexus - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - MinimumOSVersion - 15.5 + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 diff --git a/ios/Podfile b/ios/Podfile index 9c6fa1f..620e46e 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,4 +1,7 @@ -platform :ios, 16 +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' project 'Runner', { @@ -26,30 +29,15 @@ flutter_ios_podfile_setup target 'Runner' do use_frameworks! - use_modular_headers! - + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end end post_install do |installer| installer.pods_project.targets.each do |target| - target.build_configurations.each do |config| - if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] == '8.0' - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '11.0' - end - end - flutter_additional_ios_build_settings target + flutter_additional_ios_build_settings(target) end - - ################ Awesome Notifications pod modification 1 ################### - awesome_pod_file = File.expand_path(File.join('plugins', 'awesome_notifications', 'ios', 'Scripts', 'AwesomePodFile'), '.symlinks') - require awesome_pod_file - update_awesome_pod_build_settings(installer) - ################ Awesome Notifications pod modification 1 ################### end - -################ Awesome Notifications pod modification 2 ################### -awesome_pod_file = File.expand_path(File.join('plugins', 'awesome_notifications', 'ios', 'Scripts', 'AwesomePodFile'), '.symlinks') -require awesome_pod_file -update_awesome_main_target_settings('Runner', File.dirname(File.realpath(__FILE__)), flutter_root) -################ Awesome Notifications pod modification 2 ################### diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..b29d5cb --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,28 @@ +PODS: + - Flutter (1.0.0) + - media_kit_libs_ios_video (1.0.4): + - Flutter + - media_kit_video (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) + - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + media_kit_libs_ios_video: + :path: ".symlinks/plugins/media_kit_libs_ios_video/ios" + media_kit_video: + :path: ".symlinks/plugins/media_kit_video/ios" + +SPEC CHECKSUMS: + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 + media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.17.0 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d4af5a1..065d08b 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -8,14 +8,28 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 3965FF1D950A2B2E051F6219 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5E598BBDD2ED0112E344B590 /* Pods_Runner.framework */; }; + 2ADFEE6C745FFB2100CC9AFD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8156A45F510382D558AED93A /* Pods_RunnerTests.framework */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 88D55A5F1C7625122EBEE29D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 82F9A3759658343C81A0EA4C /* Pods_Runner.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -32,12 +46,19 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 22DE4310DAFBE211CDF700B1 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 363A89F6FA09BCCBDED440CE /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 5E598BBDD2ED0112E344B590 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 6A2D47C573B065AD356C7183 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 63516CF65A79A5DDDE64B0D1 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8156A45F510382D558AED93A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 82F9A3759658343C81A0EA4C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -45,35 +66,67 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - CA00A9F43BB1AB7D908E180A /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - DFFCB3F45BF495277AE4D3EE /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + AEB96D8976BD46D6860C7041 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + B8B599FDB72E4713FB7B5A44 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + FE5E4162842555A83F8A7FD7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 8C0A36760DA8B873D8F6DDCE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2ADFEE6C745FFB2100CC9AFD /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 3965FF1D950A2B2E051F6219 /* Pods_Runner.framework in Frameworks */, + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 88D55A5F1C7625122EBEE29D /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 7226BEAE3D25CA120A434CC9 /* Pods */ = { + 1CFFAE23B502BCCEB2B191A3 /* Pods */ = { isa = PBXGroup; children = ( - DFFCB3F45BF495277AE4D3EE /* Pods-Runner.debug.xcconfig */, - CA00A9F43BB1AB7D908E180A /* Pods-Runner.release.xcconfig */, - 6A2D47C573B065AD356C7183 /* Pods-Runner.profile.xcconfig */, + AEB96D8976BD46D6860C7041 /* Pods-Runner.debug.xcconfig */, + 363A89F6FA09BCCBDED440CE /* Pods-Runner.release.xcconfig */, + 22DE4310DAFBE211CDF700B1 /* Pods-Runner.profile.xcconfig */, + B8B599FDB72E4713FB7B5A44 /* Pods-RunnerTests.debug.xcconfig */, + FE5E4162842555A83F8A7FD7 /* Pods-RunnerTests.release.xcconfig */, + 63516CF65A79A5DDDE64B0D1 /* Pods-RunnerTests.profile.xcconfig */, ); + name = Pods; path = Pods; sourceTree = ""; }; + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 594EB1F65336CF9C7958F6FA /* Frameworks */ = { + isa = PBXGroup; + children = ( + 82F9A3759658343C81A0EA4C /* Pods_Runner.framework */, + 8156A45F510382D558AED93A /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -88,8 +141,9 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, - 7226BEAE3D25CA120A434CC9 /* Pods */, - E396C277672D83FCFDB51CCB /* Frameworks */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 1CFFAE23B502BCCEB2B191A3 /* Pods */, + 594EB1F65336CF9C7958F6FA /* Frameworks */, ); sourceTree = ""; }; @@ -97,6 +151,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -111,41 +166,55 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; sourceTree = ""; }; - E396C277672D83FCFDB51CCB /* Frameworks */ = { - isa = PBXGroup; - children = ( - 5E598BBDD2ED0112E344B590 /* Pods_Runner.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + EE82EC529EB6C0F1FA480659 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 8C0A36760DA8B873D8F6DDCE /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - A3B5CB395514F4BF9CDF304E /* [CP] Check Pods Manifest.lock */, + 0830C6758DA3ECA81FB12386 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - C3C8545BDAC52D360D298A29 /* [CP] Embed Pods Frameworks */, - F46F66CC32F585BBF2FB675E /* [CP] Copy Pods Resources */, + 1D165FDFB9D0D939B041966D /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -160,6 +229,10 @@ LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; @@ -175,16 +248,27 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -199,6 +283,45 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 0830C6758DA3ECA81FB12386 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 1D165FDFB9D0D939B041966D /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -230,7 +353,7 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - A3B5CB395514F4BF9CDF304E /* [CP] Check Pods Manifest.lock */ = { + EE82EC529EB6C0F1FA480659 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -245,61 +368,44 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - C3C8545BDAC52D360D298A29 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - F46F66CC32F585BBF2FB675E /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -324,6 +430,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -353,6 +460,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -374,13 +482,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - APPLICATION_EXTENSION_API_ONLY = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = AppIcon; - BUILD_LIBRARY_FOR_DISTRIBUTION = NO; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = P3ZD9TNSKZ; + DEVELOPMENT_TEAM = PK66NJM372; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -395,10 +500,61 @@ }; name = Profile; }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B8B599FDB72E4713FB7B5A44 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FE5E4162842555A83F8A7FD7 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 63516CF65A79A5DDDE64B0D1 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -428,6 +584,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -454,6 +611,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -483,6 +641,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -506,13 +665,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { - APPLICATION_EXTENSION_API_ONLY = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = AppIcon; - BUILD_LIBRARY_FOR_DISTRIBUTION = NO; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = P3ZD9TNSKZ; + DEVELOPMENT_TEAM = PK66NJM372; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -532,13 +688,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - APPLICATION_EXTENSION_API_ONLY = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = AppIcon; - BUILD_LIBRARY_FOR_DISTRIBUTION = NO; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = P3ZD9TNSKZ; + DEVELOPMENT_TEAM = PK66NJM372; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -556,6 +709,16 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -577,6 +740,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d4..c3fedb2 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) } } diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 2b21522..1682af0 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 8471cd6..51f4a9b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index c145b15..97b3b3f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 5da5679..17d971e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index cd2b74f..ca74554 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index 68cbdbf..6129702 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 306efe8..16c3d1e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index c145b15..97b3b3f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 959cc28..f515ebd 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index d86b69c..2e49a13 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index 3a5c49b..66b9ef2 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index e563327..53af9ff 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index 30ae8c6..455803d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index 2fb68c4..7a2fd0f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index d86b69c..2e49a13 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index 151862a..c34ad9e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index c5ca065..5e21545 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index a5880bd..2140e17 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 6ea8156..a27d854 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 657cf77..8b4eb75 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 87d1ce7..21c424b 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/ios/Runner/Info.plist b/ios/Runner/Info.plist index b89f32a..7deed9e 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -3,7 +3,7 @@ CADisableMinimumFrameDurationOnPhone - + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -22,30 +22,41 @@ $(FLUTTER_BUILD_NAME) CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + nexus.federated.nexus + matrix + + + CFBundleVersion $(FLUTTER_BUILD_NUMBER) - LSSupportsOpeningDocumentsInPlace - - LSApplicationQueriesSchemes - - file - https - http - mailto - tel - - UIFileSharingEnabled - - ITSAppUsesNonExemptEncryption - LSRequiresIPhoneOS - NSCameraUsageDescription - This app needs camera access to scan asset QR codes - NSMicrophoneUsageDescription - This app doesn't use your microphone. - NSPhotoLibraryUsageDescription - This app doesn't use your photo library. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName @@ -65,5 +76,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSLocationWhenInUseUsageDescription + Nexus uses your location only when you choose to share it in a message. diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/controllers/account_data.dart b/lib/controllers/account_data.dart new file mode 100644 index 0000000..62997b5 --- /dev/null +++ b/lib/controllers/account_data.dart @@ -0,0 +1,162 @@ +import "dart:async"; +import "dart:convert"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/account_data.dart"; +import "package:nexus/models/push_rules.dart"; +import "package:nexus/models/requests/update_push_rule.dart"; +import "package:nexus/models/room_notification_level.dart"; +import "package:nexus/models/sync_data.dart"; + +class AccountDataController extends Notifier { + @override + AccountData build() => .new(); + + void update(IMap> newAccountData) => + state = newAccountData.entries.fold(state, (data, entry) { + try { + return .fromJson({ + ...json.decode(json.encode(data.toJson())), + entry.key: entry.value["content"], + }); + } catch (error) { + debugPrint("Ignoring invalid ${entry.key} account data: $error"); + return data; + } + }); + + void handleSync(SyncData sync) { + if (sync.clearState) state = .new(); + update(sync.accountData); + } + + Future setEnabledImagePacks(ImagePackRooms packs) { + ISet<(String, String)> keysOf(ImagePackRooms rooms) => rooms.rooms.entries + .expand((entry) => entry.value.keys.map((key) => (entry.key, key))) + .toISet(); + final keys = keysOf(packs); + final client = ref.read(ClientController.provider.notifier); + + return _sendAndWait( + () => .wait([ + if (state.imagePackRooms != null) + client.setAccountData( + .new(type: AccountData.imagePackRoomsKey, content: packs), + ), + client.setAccountData( + .new(type: AccountData.legacyImagePackRoomsKey, content: packs), + ), + ]), + (data) { + final current = keysOf(data.enabledImagePacks); + return current.length == keys.length && current.containsAll(keys); + }, + ); + } + + Future setIgnored(String userId, {required bool ignored}) => + _sendAndWait( + () => ref + .read(ClientController.provider.notifier) + .setAccountData( + .new( + type: AccountData.ignoredUserListKey, + content: state.ignoredUserList.withUser( + userId, + ignored: ignored, + ), + ), + ), + (data) => data.ignoredUserList.contains(userId) == ignored, + ); + + /// Saves [rule] of [kind], creating it if it's new, or otherwise changing only + /// its actions and whether it's enabled. + Future savePushRule(PushRuleKind kind, PushRule rule) { + UpdatePushRuleRequest request(UpdatePushRuleAction action) => + .new(kind: kind, ruleId: rule.ruleId, action: action); + + return _updatePushRules( + .new(switch (state.pushRules.global.find(kind, rule.ruleId)) { + null => [request(.put).copyWith(newContent: rule)], + final previous => [ + if (previous.actions != rule.actions) + request(.putActions).copyWith(actions: rule.actions), + if (previous.enabled != rule.enabled) + request(rule.enabled ? .enable : .disable), + ], + }), + (rules) => rules.find(kind, rule.ruleId) == rule, + ); + } + + Future deletePushRule(PushRuleKind kind, String ruleId) => + _updatePushRules( + .new([.new(kind: kind, ruleId: ruleId, action: .delete)]), + (rules) => rules.find(kind, ruleId) == null, + ); + + /// Replaces the push rules for the room with [roomId] with those [level] + /// needs, deleting them first so disabled ones don't stay disabled. + Future setRoomNotificationLevel( + String roomId, + RoomNotificationLevel level, + ) async { + final rules = state.pushRules.global; + if (RoomNotificationLevel.of(rules, roomId) == level) return; + + return _updatePushRules( + .new([ + ...[PushRuleKind.override, PushRuleKind.room] + .where((kind) => rules.find(kind, roomId) != null) + .map((kind) => .new(kind: kind, ruleId: roomId, action: .delete)), + if (level.ruleFor(roomId) case (:final kind, :final rule)) + .new(kind: kind, ruleId: roomId, action: .put, newContent: rule), + ]), + (rules) => RoomNotificationLevel.of(rules, roomId) == level, + ); + } + + /// Sends [requests] in order, then waits until [arrived] is true for the + /// synced push rules. + Future _updatePushRules( + IList requests, + bool Function(PushRuleset rules) arrived, + ) => _sendAndWait( + () => .forEach( + requests, + ref.read(ClientController.provider.notifier).updatePushRule, + ), + (data) => arrived(data.pushRules.global), + ); + + /// Runs [send], then waits until [arrived] is true for the synced data. + Future _sendAndWait( + Future Function() send, + bool Function(AccountData data) arrived, + ) async { + final completer = Completer(); + final removeListener = listenSelf((_, data) { + if (arrived(data) && !completer.isCompleted) completer.complete(); + }); + + try { + await send(); + if (!arrived(state)) { + await completer.future.timeout( + const .new(seconds: 10), + onTimeout: () {}, + ); + } + } finally { + removeListener(); + } + } + + static final provider = NotifierProvider( + AccountDataController.new, + ); +} diff --git a/lib/controllers/account_data_controller.dart b/lib/controllers/account_data_controller.dart deleted file mode 100644 index 125d7cf..0000000 --- a/lib/controllers/account_data_controller.dart +++ /dev/null @@ -1,16 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/models/account_data.dart"; - -class AccountDataController extends Notifier> { - @override - IMap build() => const IMap.empty(); - - void update(IMap newData) => - state = IMap({...state.unlock, ...newData.unlock}); - - static final provider = - NotifierProvider>( - AccountDataController.new, - ); -} diff --git a/lib/controllers/accounts.dart b/lib/controllers/accounts.dart new file mode 100644 index 0000000..0e93f9a --- /dev/null +++ b/lib/controllers/accounts.dart @@ -0,0 +1,60 @@ +import "dart:convert"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/models/account.dart"; + +class AccountsController extends AsyncNotifier> { + @override + Future> build() async { + final stored = await ref.watch( + KeyController.provider(KeyController.accountsKey).future, + ); + final accounts = IList( + (json.decode(stored ?? "[]") as List).map( + (account) => .fromJson(account), + ), + ); + + // Installs from before account switching are logged into the legacy slot + return accounts.isEmpty + ? .new([const .new(id: Account.legacyId)]) + : accounts; + } + + Future _set(IList accounts) async { + state = .data(accounts); + await ref + .read(KeyController.provider(KeyController.accountsKey).notifier) + .set(json.encode(accounts.map((account) => account.toJson()).toList())); + } + + Future> get _current async { + await future; + return state.requireValue; + } + + Future save(Account account) async { + final accounts = await _current; + final index = accounts.indexWhere((existing) => existing.id == account.id); + + await _set( + index == -1 ? accounts.add(account) : accounts.replace(index, account), + ); + } + + Future reset(String id) async { + final accounts = await _current; + final index = accounts.indexWhere((account) => account.id == id); + if (index != -1) await _set(accounts.replace(index, .new(id: id))); + } + + Future remove(String id) async => + _set((await _current).removeWhere((account) => account.id == id)); + + static final provider = + AsyncNotifierProvider>( + AccountsController.new, + ); +} diff --git a/lib/controllers/active_account.dart b/lib/controllers/active_account.dart new file mode 100644 index 0000000..d61e6b2 --- /dev/null +++ b/lib/controllers/active_account.dart @@ -0,0 +1,53 @@ +import "package:collection/collection.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/accounts.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/notification_request.dart"; +import "package:nexus/models/account.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +typedef AccountSwitch = ({String accountId, int? notificationRowId}); + +class ActiveAccountController( + final String accountId, + final void Function(AccountSwitch next) restart, +) extends Notifier { + @override + String build() => accountId; + + static Future stored() async => + await SharedPreferencesAsync().getString( + KeyController.activeAccountKey, + ) ?? + Account.legacyId; + + /// Restarts the app as [accountId], then opens [notificationRowId] if given. + Future switchTo(String accountId, {int? notificationRowId}) async { + if (accountId == state) { + if (notificationRowId != null) { + ref + .read(NotificationRequestController.provider.notifier) + .request(notificationRowId); + } + return; + } + + final accounts = await ref.read(AccountsController.provider.future); + if (!accounts.any((account) => account.id == accountId)) return; + + if (accounts.firstWhereOrNull((account) => account.id == state) + case Account(userId: null)) { + await ref.read(AccountsController.provider.notifier).remove(state); + } + + await ref + .read(KeyController.provider(KeyController.activeAccountKey).notifier) + .set(accountId); + + restart((accountId: accountId, notificationRowId: notificationRowId)); + } + + static final provider = NotifierProvider( + () => throw UnimplementedError("Provided by AccountScope"), + ); +} diff --git a/lib/controllers/all_emoji.dart b/lib/controllers/all_emoji.dart new file mode 100644 index 0000000..5703356 --- /dev/null +++ b/lib/controllers/all_emoji.dart @@ -0,0 +1,25 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_emoji_picker/material_emoji_picker.dart"; +import "package:nexus/controllers/image_packs.dart"; +import "package:nexus/helpers/extensions/image_pack_emoji.dart"; + +class AllEmojiController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + final packs = await ref.watch(ImagePacksController.provider(roomId).future); + final unicode = await ref.watch(EmojiController.provider.future); + + return packs + .map((pack) => pack.emojiCategory) + .nonNulls + .toIList() + .addAll(unicode); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>( + AllEmojiController.new, + ); +} diff --git a/lib/controllers/app_paused.dart b/lib/controllers/app_paused.dart new file mode 100644 index 0000000..09a5b0a --- /dev/null +++ b/lib/controllers/app_paused.dart @@ -0,0 +1,18 @@ +import "package:flutter/widgets.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +class AppPausedController extends Notifier { + @override + bool build() { + final listener = AppLifecycleListener( + onPause: () => state = true, + onResume: () => state = false, + ); + ref.onDispose(listener.dispose); + return WidgetsBinding.instance.lifecycleState == .paused; + } + + static final provider = NotifierProvider( + AppPausedController.new, + ); +} diff --git a/lib/controllers/attachment.dart b/lib/controllers/attachment.dart new file mode 100644 index 0000000..c976684 --- /dev/null +++ b/lib/controllers/attachment.dart @@ -0,0 +1,46 @@ +import "package:file_selector/file_selector.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/composer_params.dart"; +import "package:path/path.dart"; + +typedef AttachmentParams = ({ComposerParams composer, int? editingRowId}); + +class AttachmentController(final AttachmentParams params) + extends Notifier<(String, MessageContent?)?> { + @override + Null build() => null; + + Future add(XFile file) async { + final filename = basename(file.path); + state = (filename, null); + + final isEncrypted = ref.read( + RoomsController.provider.select( + (value) => + value[params.composer.roomId] + ?.state[EventType.encryption.type] + ?.isNotEmpty == + true, + ), + ); + + final content = await ref + .read(ClientController.provider.notifier) + .uploadMedia(.new(path: file.path, encrypt: isEncrypted)); + + state = (filename, content); + } + + void set((String, MessageContent?)? attachment) => state = attachment; + + static final provider = + NotifierProvider.family< + AttachmentController, + (String, MessageContent?)?, + AttachmentParams + >(AttachmentController.new); +} diff --git a/lib/controllers/auth_url.dart b/lib/controllers/auth_url.dart new file mode 100644 index 0000000..be0d1b0 --- /dev/null +++ b/lib/controllers/auth_url.dart @@ -0,0 +1,28 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_id.dart"; +import "package:nexus/models/oauth_auth_code_response.dart"; +import "package:nexus/models/requests/oauth/get_auth_url.dart"; + +class AuthUrlController(final Uri homeserver) + extends AsyncNotifier { + @override + Future build() async => ref + .read(ClientController.provider.notifier) + .getAuthUrl( + .new( + homeserverUrl: homeserver, + redirectUri: .new(scheme: "nexus.federated.nexus", path: "/"), + responseMode: .query, + scopes: .new([Scope.clientApi, Scope.device]), + clientId: await ref.watch( + ClientIdController.provider(homeserver).future, + ), + ), + ); + + static final provider = AsyncNotifierProvider.family + .autoDispose( + AuthUrlController.new, + ); +} diff --git a/lib/controllers/author.dart b/lib/controllers/author.dart new file mode 100644 index 0000000..f9c9167 --- /dev/null +++ b/lib/controllers/author.dart @@ -0,0 +1,28 @@ +import "dart:async"; + +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/event.dart"; + +class AuthorController(final Event event) + extends AsyncNotifier { + @override + Future build() async { + final member = await ref.watch( + UserController.provider(.new(roomId: event.roomId, userId: event.sender)) + .future, + ); + + return .new( + status: member.status, + avatarUrl: event.pmp?.avatarUrl ?? member.avatarUrl, + displayName: event.pmp?.displayName ?? member.displayName, + ); + } + + static final provider = + AsyncNotifierProvider.family( + AuthorController.new, + ); +} diff --git a/lib/controllers/bot_argument_suggestions.dart b/lib/controllers/bot_argument_suggestions.dart new file mode 100644 index 0000000..04c2152 --- /dev/null +++ b/lib/controllers/bot_argument_suggestions.dart @@ -0,0 +1,131 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/bot_command_draft.dart"; +import "package:nexus/controllers/members_by_status.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/via.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/weighted_search.dart"; +import "package:nexus/models/bot_argument_suggestion.dart"; +import "package:nexus/models/bot_command_draft.dart"; +import "package:nexus/models/composer_params.dart"; +import "package:nexus/models/content/bot_command_description.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/room.dart"; + +class BotArgumentSuggestionsController(final ComposerParams composer) + extends Notifier> { + @override + IList build() { + final draft = ref.watch(BotCommandDraftController.provider(composer)); + final (parameter, text) = switch (draft) { + BotCommandDraft(focusedParameter: final parameter?, :final texts) => ( + parameter, + texts[parameter.key] ?? "", + ), + _ => (null, ""), + }; + if (parameter == null) return const .empty(); + + final schema = parameter.schema; + final parts = schema is ArraySchema + ? text.split(BotParameterSchema.separator) + : [text]; + final query = parts.last.trim().toLowerCase(); + final picked = parts.take(parts.length - 1).toISet(); + + final members = schema.allows(.userId) + ? ref + .watch( + MembersByStatusController.provider( + .new(roomId: composer.roomId, status: .join), + ), + ) + .value ?? + const .empty() + : const ISet.empty(); + final rooms = schema.allows(.roomId) + ? ref.watch(RoomsController.provider).values + : const []; + + return { + ...schema.literals.map((literal) => "$literal"), + if (schema.allows(.boolean)) ...["True", "False"], + } + .map((value) => BotArgumentSuggestion(title: value, text: value)) + .followedBy( + members + .map( + (member) => switch (member) { + Event( + stateKey: final userId?, + content: MembershipContent( + :final displayName, + :final avatarUrl, + ), + ) => + BotArgumentSuggestion( + title: displayName ?? userId.localpart, + subtitle: userId, + avatar: avatarUrl, + text: userId, + ), + _ => null, + }, + ) + .nonNulls, + ) + .followedBy( + rooms + .map( + (room) => switch (room.metadata) { + final metadata? => BotArgumentSuggestion( + title: + metadata.name ?? metadata.canonicalAlias ?? metadata.id, + subtitle: metadata.canonicalAlias ?? metadata.id, + avatar: metadata.avatar, + text: metadata.id, + room: room, + ), + null => null, + }, + ) + .nonNulls, + ) + .where((suggestion) => !picked.contains(suggestion.text)) + .search( + query, + (suggestion) => [(suggestion.title, 2), (suggestion.subtitle, 1)], + ) + .take(8) + .toIList(); + } + + void pick(BotArgumentSuggestion suggestion) => ref + .read(BotCommandDraftController.provider(composer).notifier) + .pick( + suggestion, + via: switch (suggestion.room) { + final room? => ref.read(ViaController.provider(room)), + null => const .empty(), + }, + ); + + bool pickTop() { + final draft = ref.read(BotCommandDraftController.provider(composer)); + if (state.firstOrNull case final suggestion? + when draft != null && !draft.arguments.containsKey(draft.focused)) { + pick(suggestion); + return true; + } + return false; + } + + static final provider = NotifierProvider.autoDispose + .family< + BotArgumentSuggestionsController, + IList, + ComposerParams + >(BotArgumentSuggestionsController.new); +} diff --git a/lib/controllers/bot_command_draft.dart b/lib/controllers/bot_command_draft.dart new file mode 100644 index 0000000..44ee9f5 --- /dev/null +++ b/lib/controllers/bot_command_draft.dart @@ -0,0 +1,108 @@ +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/bot_argument_suggestion.dart"; +import "package:nexus/models/bot_command.dart"; +import "package:nexus/models/bot_command_draft.dart"; +import "package:nexus/models/composer_params.dart"; +import "package:nexus/models/content/bot_command_description.dart"; + +class BotCommandDraftController(final ComposerParams composer) + extends Notifier { + @override + BotCommandDraft? build() => null; + + void start(BotCommand command, {String text = ""}) { + state = BotCommandDraft( + command: command, + composer: composer, + texts: .fromEntries( + command.description.parameters + .where((parameter) => !parameter.optional) + .map( + (parameter) => switch (parameter.schema) { + LiteralSchema(:final value) => MapEntry( + parameter.key, + "$value", + ), + _ => null, + }, + ) + .nonNulls, + ), + ); + focusNext(); + if (state?.focused case final key? when text.isNotEmpty) setText(key, text); + } + + void setText(String key, String text) { + if (state case final draft?) { + state = draft.copyWith(texts: draft.texts.add(key, text)); + } + } + + void focus(String key) => state = state?.copyWith(focused: key); + + void focusNext() { + if (state case final draft?) { + state = draft.copyWith( + focused: draft.missing.firstOrNull?.key ?? draft.focused, + ); + } + } + + void addOption(String key) { + if (state case final draft?) { + state = draft.copyWith(options: draft.options.add(key), focused: key); + } + } + + void removeOption(String key) { + if (state case final draft?) { + state = draft.copyWith( + options: draft.options.remove(key), + texts: draft.texts.remove(key), + // Focus moves back a parameter if none are missing, so it isn't lost + focused: draft.focused == key + ? draft.missing.firstOrNull?.key ?? + draft.shown + .takeWhile((parameter) => parameter.key != key) + .lastOrNull + ?.key + : draft.focused, + ); + } + } + + void pick(BotArgumentSuggestion suggestion, {required ISet via}) { + if (state case BotCommandDraft( + focusedParameter: final parameter?, + :final texts, + :final vias, + )) { + final text = texts[parameter.key] ?? ""; + final isArray = parameter.schema is ArraySchema; + state = state?.copyWith( + texts: texts.add( + parameter.key, + isArray + ? "${text.substring(0, text.lastIndexOf(BotParameterSchema.separator) + 1)}${suggestion.text}, " + : suggestion.text, + ), + vias: via.isEmpty ? vias : vias.add(suggestion.text, via.toIList()), + ); + if (!isArray) focusNext(); + } + } + + void set(BotCommandDraft? draft) => state = draft; + + void clear() => state = null; + + static final provider = + NotifierProvider.family< + BotCommandDraftController, + BotCommandDraft?, + ComposerParams + >(BotCommandDraftController.new); +} diff --git a/lib/controllers/bot_commands.dart b/lib/controllers/bot_commands.dart new file mode 100644 index 0000000..49dc414 --- /dev/null +++ b/lib/controllers/bot_commands.dart @@ -0,0 +1,77 @@ +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/gomuks_commands.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/models/bot_command.dart"; +import "package:nexus/models/content/bot_command_description.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/event.dart"; + +class BotCommandsController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + final gomuksCommands = await ref.watch( + GomuksCommandsController.provider.future, + ); + + final botCommands = ref.watch( + RoomsController.provider.select((rooms) { + final room = rooms[roomId]; + if (room == null) return const IList.empty(); + + Content? membership(String userId) => + switch (room.state[EventType.membership.type]?[userId]) { + final rowId? => room.events[rowId]?.content, + null => null, + }; + + return (room.state[EventType.botCommandDescription.type]?.values ?? + const []) + .map( + (rowId) => switch (room.events[rowId]) { + Event( + redactedBy: null, + :final sender, + content: final BotCommandDescriptionContent description, + ) + when description.isValid && + !BotCommand.builtIn.contains( + description.command.split(" ").first, + ) => + switch (membership(sender)) { + MembershipContent( + status: .join, + :final displayName, + :final avatarUrl, + ) => + BotCommand( + sender: sender, + botName: displayName ?? sender.localpart, + botAvatar: avatarUrl, + description: description, + ), + _ => null, + }, + _ => null, + }, + ) + .nonNulls + .toIList(); + }), + ); + + return gomuksCommands + .addAll(botCommands) + .sortedBy((command) => command.description.command) + .toIList(); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>( + BotCommandsController.new, + ); +} diff --git a/lib/controllers/capabilities.dart b/lib/controllers/capabilities.dart new file mode 100644 index 0000000..3b7a76f --- /dev/null +++ b/lib/controllers/capabilities.dart @@ -0,0 +1,14 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/capabilities.dart"; + +class CapabilitiesController extends AsyncNotifier { + @override + Future build() => + ref.read(ClientController.provider.notifier).getCapabilities(); + + static final provider = + AsyncNotifierProvider( + CapabilitiesController.new, + ); +} diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart new file mode 100644 index 0000000..e6afa3b --- /dev/null +++ b/lib/controllers/client.dart @@ -0,0 +1,563 @@ +import "dart:async"; +import "dart:ffi"; +import "dart:io"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:ffi/ffi.dart"; +import "package:flutter/cupertino.dart"; +import "package:intl/intl.dart"; +import "package:nexus/controllers/accounts.dart"; +import "package:nexus/controllers/active_account.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/helpers/extensions/gomuks_buffer.dart"; +import "package:nexus/helpers/extensions/event_id_is_fake.dart"; +import "package:nexus/helpers/extensions/thread_events.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/bot_command_description.dart"; +import "package:nexus/models/account.dart"; +import "package:nexus/models/capabilities.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/event_context.dart"; +import "package:nexus/models/mentions_response.dart"; +import "package:nexus/models/gomuks_config.dart"; +import "package:nexus/models/gomuks_exception.dart"; +import "package:nexus/models/oauth_auth_code_response.dart"; +import "package:nexus/models/open_graph_data.dart"; +import "package:nexus/models/paginate.dart"; +import "package:nexus/models/paginate_manual.dart"; +import "package:nexus/models/requests/create_room.dart"; +import "package:nexus/models/requests/deregister_pusher.dart"; +import "package:nexus/models/requests/download_media.dart"; +import "package:nexus/models/requests/get_event.dart"; +import "package:nexus/models/requests/get_event_context.dart"; +import "package:nexus/models/requests/get_mentions.dart"; +import "package:nexus/models/requests/get_related_events.dart"; +import "package:nexus/models/requests/get_room_state.dart"; +import "package:nexus/models/requests/get_specific_room_state.dart"; +import "package:nexus/models/requests/get_space_hierarchy.dart"; +import "package:nexus/models/requests/get_room_directory.dart"; +import "package:nexus/models/requests/get_threads.dart"; +import "package:nexus/models/requests/join_room.dart"; +import "package:nexus/models/profile_response.dart"; +import "package:nexus/models/push_notification.dart"; +import "package:nexus/models/recovery_key.dart"; +import "package:nexus/models/requests/oauth/exchange_token.dart"; +import "package:nexus/models/requests/oauth/get_auth_url.dart"; +import "package:nexus/models/requests/oauth/register_client.dart"; +import "package:nexus/models/requests/paginate.dart"; +import "package:nexus/models/requests/paginate_manual.dart"; +import "package:nexus/models/requests/redact_event.dart"; +import "package:nexus/models/requests/register_pusher.dart"; +import "package:nexus/models/requests/report.dart"; +import "package:nexus/models/requests/search.dart"; +import "package:nexus/models/requests/send_event.dart"; +import "package:nexus/models/requests/send_message.dart"; +import "package:nexus/models/requests/set_account_data.dart"; +import "package:nexus/models/requests/set_membership.dart"; +import "package:nexus/models/requests/set_state.dart"; +import "package:nexus/models/requests/update_push_rule.dart"; +import "package:nexus/models/requests/upload_media.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/models/room_directory.dart"; +import "package:nexus/models/room_summary.dart"; +import "package:nexus/models/space_hierarchy.dart"; +import "package:nexus/models/spec_versions_response.dart"; +import "package:nexus/src/third_party/gomuks.g.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:path/path.dart"; +import "package:path_provider/path_provider.dart"; + +class ClientController(final String? accountId) extends AsyncNotifier { + @override + Future build() async { + final String account = + accountId ?? ref.watch(ActiveAccountController.provider); + if (accountId == null && !isInBackground) await _deleteRemovedAccounts(); + + final environment = await _environment(account); + + debugPrint("Setting Gomuks env for $account..."); + using((arena) { + for (final MapEntry(:key, :value) in environment.entries) { + GomuksSetEnv( + key.toNativeUtf8(allocator: arena).cast(), + value.toNativeUtf8(allocator: arena).cast(), + ); + } + }, malloc); + + debugPrint("Initializing Gomuks..."); + final config = GomuksConfig( + matrix: .new( + initialDeviceDisplayName: + "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", + ), + ).toJson(); + final handle = using( + (arena) => GomuksInit(config.toGomuksBufferPtr(arena).ref), + malloc, + ); + + if (!ref.mounted) { + GomuksDestroy(handle); + return Completer().future; + } + + ref.onDispose(() => GomuksDestroy(handle)); + + if (accountId != null) { + await _callGomuksMethod(handle, {}, GomuksHandlePushAsync); + } + + return handle; + } + + static Future<({Directory data, Directory cache})> + _accountsDirectories() async => ( + data: Directory( + join((await getApplicationSupportDirectory()).path, "accounts"), + ), + cache: Directory( + join((await getApplicationCacheDirectory()).path, "accounts"), + ), + ); + + static Future> _environment(String accountId) async { + if (accountId == Account.legacyId) { + // Keep the directories gomuks used before account switching existed + final isMobile = Platform.isAndroid || Platform.isIOS; + return .new({ + "GOMUKS_ROOT": isMobile + ? (await getApplicationSupportDirectory()).path + : Platform.environment["GOMUKS_ROOT"] ?? "", + "GOMUKS_CACHE_HOME": isMobile + ? (await getApplicationCacheDirectory()).path + : Platform.environment["GOMUKS_CACHE_HOME"] ?? "", + }); + } + + final (:data, :cache) = await _accountsDirectories(); + return .new({ + "GOMUKS_ROOT": join(data.path, accountId), + "GOMUKS_CACHE_HOME": join(cache.path, accountId), + }); + } + + // Can't be done on removal, since the old container still has them open + Future _deleteRemovedAccounts() async { + final ids = (await ref.read(AccountsController.provider.future)) + .map((account) => account.id) + .toISet(); + + final (:data, :cache) = await _accountsDirectories(); + final removed = await Stream.fromIterable([data, cache]) + .where((directory) => directory.existsSync()) + .asyncExpand((directory) => directory.list()) + .where((entry) => !ids.contains(basename(entry.path))) + .toList(); + + await Future.wait(removed.map((entry) => entry.delete(recursive: true))); + } + + /// Calls gomuks with [data] as JSON, returning its decoded response. + Future callGomuksMethod( + Map data, + void Function( + int handle, + GomuksOwnedBuffer data, + Pointer> callback, + ) + call, + ) async => _callGomuksMethod(await future, data, call); + + static Future _callGomuksMethod( + int handle, + Map data, + void Function( + int handle, + GomuksOwnedBuffer data, + Pointer> callback, + ) + call, + ) { + final buffer = data.toGomuksOwnedBuffer(); + + final completer = Completer(); + late final NativeCallable callable; + callable = .listener((GomuksResponse response) { + callable.close(); + try { + final json = response.buf.toJsonAndFree(); + + if (response.command.cast().toDartString() == "error") { + throw GomuksException(json.toString()); + } + + completer.complete(json); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }); + + try { + call(handle, buffer, callable.nativeFunction); + } catch (_) { + // gomuks never got the call, so it won't respond or free the buffer + callable.close(); + GomuksFreeBuffer(buffer); + rethrow; + } + + return completer.future; + } + + Future handlePush(Map data) async { + final response = await callGomuksMethod(data, GomuksHandlePushAsync); + + return response?["notification"] == null + ? null + : .fromJson(response["notification"]); + } + + Future _sendCommand( + String command, [ + Map data = const {}, + ]) => callGomuksMethod(data, (handle, data, callback) { + // gomuks copies the command name before returning + using( + (arena) => GomuksSubmitCommandAsync( + handle, + command.toNativeUtf8(allocator: arena).cast(), + data, + callback, + ), + malloc, + ); + }); + + Future redactEvent(RedactEventRequest report) => + _sendCommand("redact_event", report.toJson()); + + /// Null for gomuks' own commands that don't reply. + Future sendMessage(SendMessageRequest request) async => + switch (await _sendCommand("send_message", request.toJson())) { + final Map json => .fromJson(json), + _ => null, + }; + + /// The MSC4391 definitions of gomuks' own commands. + Future> getCommandDefinitions() async => + switch (await _sendCommand("get_command_definitions")) { + final List definitions => .new( + definitions.whereType>().map( + BotCommandDescriptionContent.fromJson, + ), + ), + _ => const .empty(), + }; + + Future sendEvent(SendEventRequest request) async => + Event.fromJson(await _sendCommand("send_event", request.toJson())); + + Future setState(SetStateRequest request) async => + await _sendCommand("set_state", request.toJson()); + + Future pauseSync() => _sendCommand("pause_sync"); + Future resumeSync() => _sendCommand("resume_sync"); + + Future verify(String recoveryKey) => + _sendCommand("verify", {"recovery_key": recoveryKey}); + + Future generateRecoveryKey(String passphrase) async => .fromJson( + await _sendCommand("generate_recovery_key", {"passphrase": passphrase}), + ); + + Future resetEncryption(RecoveryKey key) => + _sendCommand("reset_encryption", key.toJson()); + + Future getCrossSigningResetUrl(Uri homeserver) async { + const action = "org.matrix.cross_signing_reset"; + final oauth = (await _sendCommand("get_login_flows", { + "homeserver_url": homeserver.toString(), + }))["oauth"]; + + if (oauth?["account_management_uri"] case final String uri + when (oauth["account_management_actions_supported"] as List?)?.contains( + action, + ) ?? + false) { + final parsed = Uri.parse(uri); + return parsed.replace( + queryParameters: {...parsed.queryParameters, "action": action}, + ); + } + return null; + } + + Future joinRoom(JoinRoomRequest request) async => + (await _sendCommand("join_room", request.toJson()))["room_id"]; + + Future knockRoom(JoinRoomRequest request) async => + (await _sendCommand("knock_room", request.toJson()))["room_id"]; + + Future createRoom(CreateRoomRequest request) async => + (await _sendCommand("create_room", request.toJson()))["room_id"]; + + Future getSpaceHierarchy( + GetSpaceHierarchyRequest request, + ) async => + .fromJson(await _sendCommand("get_space_hierarchy", request.toJson())); + + Future getRoomDirectory( + GetRoomDirectoryRequest request, + ) async => + .fromJson(await _sendCommand("get_room_directory", request.toJson())); + + Future getRoomSummary(JoinRoomRequest request) async => + .fromJson(await _sendCommand("get_room_summary", request.toJson())); + + Future leaveRoom(String roomId) => + _sendCommand("leave_room", {"room_id": roomId}); + + 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), + ), + ); + } + + Future> getSpecificRoomState( + GetSpecificRoomStateRequest request, + ) async => .new( + ((await _sendCommand("get_specific_room_state", request.toJson())) + as List? ?? + []) + .map((event) => .fromJson(event)), + ); + + Future?> getRelatedEvents( + GetRelatedEventsRequest request, + ) async { + final response = + (await _sendCommand("get_related_events", request.toJson())) as List?; + return .new(response?.map((event) => .fromJson(event))); + } + + Future getMentions(GetMentionsRequest request) async => + .fromJson(await _sendCommand("get_mentions", request.toJson())); + + Future getEvent(GetEventRequest request) async { + final json = await _sendCommand("get_event", request.toJson()); + return json == null ? null : .fromJson(json); + } + + Future getUrlPreview(Uri url) async => + .fromJson(await _sendCommand("get_url_preview", {"url": url.toString()})); + + Future paginate(PaginateRequest request) async => + .fromJson(await _sendCommand("paginate", request.toJson())); + + Future getThreads(GetThreadsRequest request) async => + .fromJson(await _sendCommand("get_threads", request.toJson())); + + Future paginateManual(PaginateManualRequest request) async => + .fromJson(await _sendCommand("paginate_manual", request.toJson())); + + Future searchLocal(SearchRequest request) async => + .fromJson(await _sendCommand("search_local", request.toJson())); + + Future searchServer(SearchRequest request) async => + .fromJson(await _sendCommand("search_server", request.toJson())); + + Future getEventContext(GetEventContextRequest request) async => + .fromJson(await _sendCommand("get_event_context", request.toJson())); + + Future getProfile(String userId) async { + try { + return .fromJson(await _sendCommand("get_profile", {"user_id": userId})); + } catch (_) { + return .new(profile: .new(id: userId)); + } + } + + Future setProfileField(String field, Object? value) => _sendCommand( + "set_profile_field", + { + "field": field, + // gomuks deletes the field when the value is omitted, not when it's null + "value": ?value, + }, + ); + + Future reportEvent(ReportRequest request) => + _sendCommand("report_event", request.toJson()); + + Future setMembership(SetMembershipRequest request) => + _sendCommand("set_membership", request.toJson()); + + Future setAccountData(SetAccountDataRequest request) => + _sendCommand("set_account_data", request.toJson()); + + Future updatePushRule(UpdatePushRuleRequest request) => + _sendCommand("update_push_rule", request.toJson()); + + Future registerPusher(RegisterPusherRequest request) => + _sendCommand("register_homeserver_push", request.toJson()); + + Future deregisterPusher(DeregisterPusherRequest request) => + _sendCommand("register_homeserver_push", { + ...request.toJson(), + "kind": null, + }); + + Future uploadMedia(UploadMediaRequest request) async => + .fromJson(await _sendCommand("upload_media", request.toJson())); + + Future downloadMedia(DownloadMediaRequest request) async { + if (accountId == null && + ref.read(ClientStateController.provider)?.isLoggedIn != true) { + throw StateError("Can't download media while logged out"); + } + + return .new( + (await _sendCommand("download_media", request.toJson()))["path"], + ); + } + + Future logout() => _sendCommand("logout"); + + Future setTyping(String roomId, Duration timeout) => _sendCommand( + "set_typing", + {"room_id": roomId, "timeout": timeout.inMilliseconds}, + ); + + Future markRead(Room room, {String? threadRoot}) async { + if (room.metadata == null) return; + final event = switch (threadRoot) { + final threadRoot? => [ + ?room.latestThreadEvent(threadRoot), + ...room.threadEvents(threadRoot).reversed, + ], + null => + room.timeline.entries + .toIList() + .sort((a, b) => b.key.compareTo(a.key)) + .map((entry) => entry.value) + .nonNulls + .map((rowId) => room.events[rowId]) + .nonNulls, + }.where((event) => !event.eventId.isFake).firstOrNull; + + if (event == null) return; + + // Private until settings have loaded, in case the user turned sending off + final sendPublic = + ref.read(SettingsController.provider).value?.readReceipts == + .showAndSend; + + await _sendCommand("mark_read", { + "room_id": room.metadata!.id, + "receipt_type": sendPublic ? "m.read" : "m.read.private", + "event_id": event.eventId, + "thread_id": ?threadRoot, + }); + } + + Future registerClient(OAuthRegisterClientRequest request) async => + (await _sendCommand( + "oauth_register_client", + request.toJson(), + ))["client_id"]; + + Future getAuthUrl(OAuthGetAuthUrl request) async => + .fromJson( + await _sendCommand("oauth_get_authorization_url", request.toJson()), + ); + + Future exchangeToken(OAuthExchangeTokenRequest request) async => + await _sendCommand("oauth_exchange_token", request.toJson()); + + Future> getMutualRooms(String userId) async => IList( + ((await _sendCommand("get_mutual_rooms", {"user_id": userId}))["joined"] + as List?) + ?.cast(), + ); + + Future> getLocalAliases(String roomId) async => IList( + ((await _sendCommand("get_local_aliases", {"room_id": roomId}))["aliases"] + as List?) + ?.cast(), + ); + + Future createAlias(String roomId, String alias) => + _sendCommand("create_alias", {"room_id": roomId, "alias": alias}); + + Future deleteAlias(String alias) => + _sendCommand("delete_alias", {"alias": alias}); + + Future getRoomPublished(String roomId) async => + (await _sendCommand("get_room_visibility", { + "room_id": roomId, + }))["visibility"] == + "public"; + + Future setRoomPublished(String roomId, bool published) => _sendCommand( + "publish_room", + {"room_id": roomId, "visibility": published ? "public" : "private"}, + ); + + Future upgradeRoom( + String roomId, + String version, { + ISet additionalCreators = const .empty(), + }) async => (await _sendCommand("upgrade_room", { + "room_id": roomId, + "new_version": version, + if (additionalCreators.isNotEmpty) + "additional_creators": additionalCreators.toList(), + }))["replacement_room"]; + + /// The room [alias] points to, or null if it doesn't exist. + Future resolveAlias(String alias) async { + try { + return (await _sendCommand("resolve_alias", {"alias": alias}))["room_id"]; + } on GomuksException catch (error) { + if (error.statusCode == 404) return null; + rethrow; + } + } + + Future getSpecVersions() async => + .fromJson(await _sendCommand("get_versions")); + + Future getCapabilities() async => + .fromJson((await _sendCommand("get_capabilities"))["capabilities"]); + + Future discoverHomeserver(Uri homeserver) async { + try { + final response = await _sendCommand("discover_homeserver", { + "user_id": "@fake-user:${homeserver.authority}", + }); + return Uri.parse(response["m.homeserver"]?["base_url"]); + } catch (error) { + return null; + } + } + + static final provider = AsyncNotifierProvider( + () => ClientController(null), + ); + + /// The instance for an account other than the active one. + static final forAccount = + AsyncNotifierProvider.family( + ClientController.new, + ); +} diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart deleted file mode 100644 index de6e909..0000000 --- a/lib/controllers/client_controller.dart +++ /dev/null @@ -1,244 +0,0 @@ -import "dart:developer"; -import "dart:ffi"; -import "dart:isolate"; -import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:ffi/ffi.dart"; -import "package:flutter/foundation.dart"; -import "package:nexus/controllers/account_data_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/init_complete_controller.dart"; -import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/controllers/space_edges_controller.dart"; -import "package:nexus/controllers/sync_status_controller.dart"; -import "package:nexus/controllers/top_level_spaces_controller.dart"; -import "package:nexus/helpers/extensions/gomuks_buffer.dart"; -import "package:nexus/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"; -import "package:nexus/models/requests/get_related_events_request.dart"; -import "package:nexus/models/requests/get_room_state_request.dart"; -import "package:nexus/models/requests/join_room_request.dart"; -import "package:nexus/models/requests/login_request.dart"; -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_message_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"; - -class ClientController extends AsyncNotifier { - @override - Future build() async { - final handle = await Isolate.run(GomuksInit); - - final callable = - NativeCallable< - Void Function(Pointer, Int64, GomuksOwnedBuffer) - >.listener(( - Pointer command, - int requestId, - GomuksOwnedBuffer data, - ) { - try { - final muksEventType = command.cast().toDartString(); - debugPrint("Handling $muksEventType..."); - final decodedMuksEvent = data.toJson(); - - switch (muksEventType) { - case "client_state": - ref - .watch(ClientStateController.provider.notifier) - .set(ClientState.fromJson(decodedMuksEvent)); - break; - case "sync_status": - ref - .watch(SyncStatusController.provider.notifier) - .set(SyncStatus.fromJson(decodedMuksEvent)); - break; - case "init_complete": - ref.watch(InitCompleteController.provider.notifier).complete(); - break; - case "sync_complete": - final syncData = SyncData.fromJson(decodedMuksEvent); - final roomProvider = RoomsController.provider; - final accountDataProvider = AccountDataController.provider; - - if (syncData.clearState) { - ref.invalidate(roomProvider); - ref.invalidate(accountDataProvider); - } - - ref - .watch(roomProvider.notifier) - .update(syncData.rooms, syncData.leftRooms); - ref - .watch(accountDataProvider.notifier) - .update(syncData.accountData); - - if (syncData.topLevelSpaces != null) { - ref - .watch(TopLevelSpacesController.provider.notifier) - .set(syncData.topLevelSpaces!); - } - - if (syncData.spaceEdges != null) { - ref - .watch(SpaceEdgesController.provider.notifier) - .set(syncData.spaceEdges!); - } - - // ref - // .watch(SyncStatusController.provider.notifier) - // .set(SyncStatus.fromJson(decodedMuksEvent)); - break; - default: - debugPrint("Unhandled event: $muksEventType"); - } - debugPrint("Finished handling $muksEventType..."); - } catch (error, stackTrace) { - debugger(); - debugPrintStack(stackTrace: stackTrace, label: error.toString()); - } - }); - - ref.onDispose(() => GomuksDestroy(handle)); - ref.onDispose(callable.close); - - final errorCode = GomuksStart(handle, callable.nativeFunction); - - if (errorCode == 0) return handle; - throw Exception("GomuksStart returned error code $errorCode"); - } - - Future _sendCommand( - String command, [ - Map data = const {}, - ]) async { - final bufferPointer = data.toGomuksBufferPtr(); - final handle = await future; - final response = await Isolate.run( - () => GomuksSubmitCommand( - handle, - command.toNativeUtf8().cast(), - bufferPointer.ref, - ), - ); - - calloc.free(bufferPointer); - - final json = response.buf.toJson(); - if (json is String) throw json; - return json; - } - - Future redactEvent(RedactEventRequest report) => - _sendCommand("redact_event", report.toJson()); - - Future sendMessage(SendMessageRequest request) => - _sendCommand("send_message", request.toJson()); - - Future verify(String recoveryKey) async { - try { - await _sendCommand("verify", {"recovery_key": recoveryKey}); - return true; - } catch (error) { - return false; - } - } - - Future joinRoom(JoinRoomRequest request) async { - final response = await _sendCommand("join_room", request.toJson()); - return response["room_id"]; - } - - Future getAccessToken() async { - final response = await _sendCommand("get_account_info", {}); - return response?["access_token"]; - } - - Future leaveRoom(Room room) async { - if (room.metadata == null) return; - await _sendCommand("leave_room", {"room_id": room.metadata!.id}); - } - - // (await _sendCommand("get_event_context", { - // "room_id": request.roomId, - // "event_id": r"$OqZT4NuTj0J1-771IOEEWRI4XdumRNu6ighlvO3K3gc", - // })); - - Future> getRoomState(GetRoomStateRequest request) async { - final response = - (await _sendCommand("get_room_state", request.toJson())) as List; - return response.map((event) => Event.fromJson(event)).toIList(); - } - - Future?> getRelatedEvents( - GetRelatedEventsRequest request, - ) async { - final response = - (await _sendCommand("get_related_events", request.toJson())) as List?; - 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 : Event.fromJson(json); - } - - Future paginate(PaginateRequest request) async => - Paginate.fromJson(await _sendCommand("paginate", request.toJson())); - - Future getProfile(String userId) async => - Profile.fromJson(await _sendCommand("get_profile", {"user_id": userId})); - - Future reportEvent(ReportRequest report) => - _sendCommand("report_event", report.toJson()); - - Future markRead(Room room) async { - final event = room.events.firstWhereOrNull( - (event) => event.rowId == room.timeline.last.eventRowId, - ); - if (event == null || room.metadata == null) return; - - await _sendCommand("mark_read", { - "room_id": room.metadata!.id, - "receipt_type": "m.read", - "event_id": event.eventId, - }); - } - - Future login(LoginRequest login) async { - try { - await _sendCommand("login", login.toJson()); - return true; - } catch (error) { - return false; - } - } - - Future discoverHomeserver(Uri homeserver) async { - try { - final response = await _sendCommand("discover_homeserver", { - "user_id": "@fakeuser:${homeserver.host}", - }); - return response["m.homeserver"]?["base_url"]; - } catch (error) { - return null; - } - } - - static final provider = AsyncNotifierProvider( - ClientController.new, - ); -} diff --git a/lib/controllers/client_id.dart b/lib/controllers/client_id.dart new file mode 100644 index 0000000..46c98de --- /dev/null +++ b/lib/controllers/client_id.dart @@ -0,0 +1,30 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; + +class ClientIdController(final Uri homeserver) extends AsyncNotifier { + @override + Future build() => ref + .read(ClientController.provider.notifier) + .registerClient( + .new( + clientName: "Nexus", + applicationType: .native, + grantTypes: .new([.authorizationCode, .refreshToken]), + responseTypes: .new([.code]), + logoUri: Uri.https( + "nexus.federated.nexus", + "raw/branch/main/assets/mobile.svg", + ), + homeserverUrl: homeserver, + clientUri: Uri.https("nexus.federated.nexus"), + redirectUris: .new([ + .new(scheme: "nexus.federated.nexus", path: "/"), + ]), + ), + ); + + static final provider = + AsyncNotifierProvider.family( + ClientIdController.new, + ); +} diff --git a/lib/controllers/client_state_controller.dart b/lib/controllers/client_state.dart similarity index 84% rename from lib/controllers/client_state_controller.dart rename to lib/controllers/client_state.dart index 998d4a1..1b77ecb 100644 --- a/lib/controllers/client_state_controller.dart +++ b/lib/controllers/client_state.dart @@ -5,9 +5,7 @@ class ClientStateController extends Notifier { @override Null build() => null; - void set(ClientState newState) { - state = newState; - } + void set(ClientState newState) => state = newState; static final provider = NotifierProvider( ClientStateController.new, diff --git a/lib/controllers/composer_draft.dart b/lib/controllers/composer_draft.dart new file mode 100644 index 0000000..25ed273 --- /dev/null +++ b/lib/controllers/composer_draft.dart @@ -0,0 +1,97 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/attachment.dart"; +import "package:nexus/controllers/bot_command_draft.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/get_filename.dart"; +import "package:nexus/helpers/extensions/is_media.dart"; +import "package:nexus/helpers/extensions/with_latest_edit.dart"; +import "package:nexus/models/composer_draft.dart"; +import "package:nexus/models/composer_params.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/relation_type.dart"; +import "package:nexus/models/text_command.dart"; + +class ComposerDraftController(final ComposerParams composer) + extends Notifier { + @override + ComposerDraft build() => const .new(); + + BotCommandDraftController get _botCommand => + ref.read(BotCommandDraftController.provider(composer).notifier); + + void update(ComposerDraft Function(ComposerDraft draft) update) => + state = update(state); + + void relate(int rowId, RelationType type) { + if (type == .reply) { + _restoreBeforeEdit(); + state = state.copyWith(relatedEvent: rowId, relationType: .reply); + return; + } + + // Keeps the changes made so far + if (state.isEditing && state.relatedEvent == rowId) return; + + final event = ref + .read(RoomsController.provider)[composer.roomId] + ?.withLatestEdit(rowId); + final content = event?.content; + // Edit sources start with text commands, like "/me " + final (:commands, :text) = TextCommand.parse( + event?.localContent?.editSource ?? + switch (content) { + TextMessageContent(:final body) || + ImageMessageContent(:final body) || + VideoMessageContent(:final body) || + AudioMessageContent(:final body) || + FileMessageContent(:final body) => body, + _ => "", + }, + ); + + state = ComposerDraft( + text: text, + textCommands: commands, + relatedEvent: rowId, + relationType: .edit, + beforeEdit: + state.beforeEdit ?? + ( + draft: state, + botCommand: ref.read(BotCommandDraftController.provider(composer)), + ), + ); + ref + .read( + AttachmentController.provider(( + composer: composer, + editingRowId: rowId, + )).notifier, + ) + .set( + content is MessageContent && content.isMedia + ? (content.filename ?? content.body ?? "Attachment", content) + : null, + ); + _botCommand.clear(); + } + + void dismiss() { + if (state.isEditing) return _restoreBeforeEdit(); + state = state.copyWith(relatedEvent: null); + } + + void _restoreBeforeEdit() { + if (state.beforeEdit case (:final draft, :final botCommand)) { + state = draft; + _botCommand.set(botCommand); + } + } + + static final provider = + NotifierProvider.family< + ComposerDraftController, + ComposerDraft, + ComposerParams + >(ComposerDraftController.new); +} diff --git a/lib/controllers/cross_cache_controller.dart b/lib/controllers/cross_cache_controller.dart deleted file mode 100644 index 1d6d4b6..0000000 --- a/lib/controllers/cross_cache_controller.dart +++ /dev/null @@ -1,14 +0,0 @@ -import "package:cross_cache/cross_cache.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -class CrossCacheController extends Notifier { - static const String spaceKey = "space"; - static const String roomKey = "room"; - - @override - CrossCache build() => CrossCache(); - - static final provider = NotifierProvider( - CrossCacheController.new, - ); -} diff --git a/lib/controllers/emoji_shortcodes.dart b/lib/controllers/emoji_shortcodes.dart new file mode 100644 index 0000000..a24ebb1 --- /dev/null +++ b/lib/controllers/emoji_shortcodes.dart @@ -0,0 +1,31 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_emoji_picker/material_emoji_picker.dart"; + +/// Maps each unicode emoji, without variation selectors, to its shortcode. +class EmojiShortcodesController extends Notifier> { + static final _variationSelectors = RegExp("[\uFE0E\uFE0F]"); + + static String normalize(String emoji) => + emoji.replaceAll(_variationSelectors, ""); + + @override + IMap build() => .fromEntries( + ref + .watch(EmojiController.provider) + .requireValue + .map((category) => category.emojis) + .flattened + .where((emoji) => emoji.aliases.isNotEmpty) + .map( + (emoji) => + MapEntry(normalize(emoji.value), ":${emoji.aliases.first}:"), + ), + ); + + static final provider = + NotifierProvider>( + EmojiShortcodesController.new, + ); +} diff --git a/lib/controllers/enabled_image_packs.dart b/lib/controllers/enabled_image_packs.dart new file mode 100644 index 0000000..c16799c --- /dev/null +++ b/lib/controllers/enabled_image_packs.dart @@ -0,0 +1,69 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/account_data.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/requests/get_specific_room_state.dart"; + +/// The image packs the user has enabled everywhere, as room IDs to their +/// packs' state keys, once the packs' state has been loaded. +class EnabledImagePacksController + extends AsyncNotifier>> { + @override + Future>> build() async { + final enabledPacks = ref.watch( + AccountDataController.provider.select( + (data) => data.enabledImagePacks.rooms.map( + (roomId, packs) => .new(roomId, packs.keys.toISet()), + ), + ), + ); + + const packTypes = [.imagePack, .legacyImagePack]; + // Rooms we aren't in can't be fetched, and fetched rooms have every pack. + final missingKeys = ref.watch( + RoomsController.provider.select( + (rooms) => enabledPacks.entries + .where((entry) => !(rooms[entry.key]?.hasFetchedState ?? true)) + .expand( + (entry) => entry.value + .where( + (stateKey) => packTypes.every( + (type) => + rooms[entry.key]?.state[type.type]?[stateKey] == null, + ), + ) + .expand( + (stateKey) => packTypes.map( + (type) => RoomStateKey( + roomId: entry.key, + type: type.type, + stateKey: stateKey, + ), + ), + ), + ) + .toIList(), + ), + ); + + if (missingKeys.isNotEmpty) { + ref + .read(RoomsController.provider.notifier) + .addSpecificState( + await ref + .read(ClientController.provider.notifier) + .getSpecificRoomState(.new(keys: missingKeys)), + ); + } + + return enabledPacks; + } + + static final provider = + AsyncNotifierProvider.autoDispose< + EnabledImagePacksController, + IMap> + >(EnabledImagePacksController.new); +} diff --git a/lib/controllers/event.dart b/lib/controllers/event.dart new file mode 100644 index 0000000..fc1657f --- /dev/null +++ b/lib/controllers/event.dart @@ -0,0 +1,47 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/requests/get_event.dart"; + +/// The row ID of an event, fetching it if it isn't stored yet. +class EventController(final GetEventRequest request) + extends AsyncNotifier { + @override + Future build() async { + final rowId = ref.watch( + RoomsController.provider.select( + (rooms) => rooms[request.roomId]?.eventIds[request.eventId], + ), + ); + + if (rowId != null) return rowId; + + final link = ref.keepAlive(); + final fetchedEvent = await ref + .read(ClientController.provider.notifier) + .getEvent(request) + .onError((_, _) => null) + .whenComplete(link.close); + + if (fetchedEvent != null) { + // Storing the event invalidates this provider, as it watches the room. + // If nothing is listening, Riverpod won't rebuild it, and this build's + // result would be lost, so store it after returning. + final rooms = ref.read(RoomsController.provider.notifier); + Future.microtask( + () => rooms.update( + .new({ + request.roomId: .new( + events: .new({fetchedEvent.rowId: fetchedEvent}), + ), + }), + ), + ); + } + + return fetchedEvent?.rowId; + } + + static final provider = AsyncNotifierProvider.family + .autoDispose(EventController.new); +} diff --git a/lib/controllers/event_controller.dart b/lib/controllers/event_controller.dart deleted file mode 100644 index 4f72963..0000000 --- a/lib/controllers/event_controller.dart +++ /dev/null @@ -1,20 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/requests/get_event_request.dart"; - -class EventController extends AsyncNotifier { - final GetEventRequest request; - EventController(this.request); - - @override - Future build() async { - final client = ref.watch(ClientController.provider.notifier); - return await client.getEvent(request).onError((_, _) => null); - } - - static final provider = AsyncNotifierProvider.family - .autoDispose( - EventController.new, - ); -} diff --git a/lib/controllers/event_search.dart b/lib/controllers/event_search.dart new file mode 100644 index 0000000..ea78567 --- /dev/null +++ b/lib/controllers/event_search.dart @@ -0,0 +1,97 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/requests/search.dart"; + +typedef EventSearchParams = ({String roomId, String query}); +typedef EventSearchResults = ({IList rowIds, String? nextBatch}); + +class EventSearchController(final EventSearchParams info) + extends AsyncNotifier { + // We could have a mode without this for power users in the future + static String toFtsQuery(String query) => query + .split(RegExp(r"\s+")) + .where((word) => word.isNotEmpty) + .map((word) => '"${word.replaceAll('"', '""')}"*') + .join(" "); + + @override + Future build() async { + if (info.query.trim().isEmpty) { + return (rowIds: IList(), nextBatch: null); + } + + final client = ref.read(ClientController.provider.notifier); + + final encrypted = ref.read( + RoomsController.provider.select( + (rooms) => rooms[info.roomId]?.metadata?.encryptionEvent != null, + ), + ); + final request = SearchRequest( + searchTerm: encrypted ? toFtsQuery(info.query) : info.query, + roomIds: .new([info.roomId]), + ); + final response = encrypted + ? await client.searchLocal(request) + : await client.searchServer(request); + + if (!ref.mounted) return (rowIds: IList(), nextBatch: null); + + ref + .read(RoomsController.provider.notifier) + .storeEvents(response.events.addAll(response.relatedEvents)); + + return ( + rowIds: response.events.map((event) => event.rowId).toIList(), + nextBatch: response.nextBatch, + ); + } + + Future paginate() async { + if (state.isLoading) return; + + final results = await future; + if (!ref.mounted || results.nextBatch == null) return; + + state = .loading(); + + final encrypted = ref.read( + RoomsController.provider.select( + (rooms) => rooms[info.roomId]?.metadata?.encryptionEvent != null, + ), + ); + + final request = SearchRequest( + searchTerm: encrypted ? toFtsQuery(info.query) : info.query, + roomIds: .new([info.roomId]), + nextBatch: results.nextBatch, + ); + + final client = ref.read(ClientController.provider.notifier); + final response = encrypted + ? await client.searchLocal(request) + : await client.searchServer(request); + + if (!ref.mounted) return; + + ref + .read(RoomsController.provider.notifier) + .storeEvents(response.events.addAll(response.relatedEvents)); + + state = .data(( + rowIds: results.rowIds.addAll( + response.events.map((event) => event.rowId), + ), + nextBatch: response.nextBatch, + )); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose< + EventSearchController, + EventSearchResults, + EventSearchParams + >(EventSearchController.new); +} diff --git a/lib/controllers/gomuks_commands.dart b/lib/controllers/gomuks_commands.dart new file mode 100644 index 0000000..b4276eb --- /dev/null +++ b/lib/controllers/gomuks_commands.dart @@ -0,0 +1,44 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/bot_command.dart"; + +class GomuksCommandsController extends AsyncNotifier> { + static const unsupported = ISetConst({ + // Needs gomuks web + "devtools", + "upgraderoom", + + // Needs attachment stuff + "myroomavatar", + "globalavatar", + "roomavatar", + + // Already in Nexus' UI with a better UX + "poll", + }); + + @override + Future> build() async => + (await ref + .read(ClientController.provider.notifier) + .getCommandDefinitions()) + .where( + (description) => + description.isValid && + !unsupported.contains(description.command), + ) + .map( + (description) => BotCommand( + sender: BotCommand.gomuksSender, + botName: "gomuks", + description: description, + ), + ) + .toIList(); + + static final provider = + AsyncNotifierProvider>( + GomuksCommandsController.new, + ); +} diff --git a/lib/controllers/gomuks_listener.dart b/lib/controllers/gomuks_listener.dart new file mode 100644 index 0000000..3100253 --- /dev/null +++ b/lib/controllers/gomuks_listener.dart @@ -0,0 +1,176 @@ +import "dart:async"; +import "dart:ffi"; +import "dart:io"; +import "dart:isolate"; + +import "package:collection/collection.dart"; +import "package:flutter/foundation.dart"; +import "package:nexus/controllers/account_data.dart"; +import "package:nexus/controllers/accounts.dart"; +import "package:nexus/controllers/active_account.dart"; +import "package:nexus/controllers/app_paused.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/init_complete.dart"; +import "package:nexus/controllers/invites.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/space_edges.dart"; +import "package:nexus/controllers/sync_status.dart"; +import "package:nexus/controllers/top_level_spaces.dart"; +import "package:nexus/controllers/typing.dart"; +import "package:nexus/helpers/extensions/gomuks_buffer.dart"; +import "package:nexus/main.dart"; +import "package:ffi/ffi.dart"; +import "package:nexus/models/gomuks_event.dart"; +import "package:nexus/src/third_party/gomuks.g.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +class GomuksListenerController extends AsyncNotifier { + @override + Future build() async { + debugPrint("Starting gomuks..."); + final handle = await ref.watch(ClientController.provider.future); + + final callable = + NativeCallable< + Void Function(Pointer, Int64, GomuksOwnedBuffer) + >.listener(( + Pointer command, + int requestId, + GomuksOwnedBuffer data, + ) { + final type = command.cast().toDartString(); + try { + debugPrint("Handling $type..."); + switch (GomuksEvent.parse(type, data.toJsonAndFree())) { + case ClientStateEvent(:final state): + ref.read(ClientStateController.provider.notifier).set(state); + case SyncStatusEvent(:final status): + ref.read(SyncStatusController.provider.notifier).set(status); + case InitCompleteEvent(): + ref.read(InitCompleteController.provider.notifier).complete(); + case SendCompleteEvent(:final event): + ref.read(RoomsController.provider.notifier).storeEvents([ + event, + ]); + case TypingEvent(:final typing): + ref.read(TypingController.provider.notifier).set(typing); + case EventsDecryptedEvent(:final decrypted): + ref + .read(RoomsController.provider.notifier) + .handleDecrypted(decrypted); + case SyncCompleteEvent(:final sync): + ref.read(RoomsController.provider.notifier).handleSync(sync); + ref + .read(AccountDataController.provider.notifier) + .handleSync(sync); + ref.read(InvitesController.provider.notifier).handleSync(sync); + ref.read(TypingController.provider.notifier).handleSync(sync); + ref + .read(TopLevelSpacesController.provider.notifier) + .handleSync(sync); + ref + .read(SpaceEdgesController.provider.notifier) + .handleSync(sync); + case UnknownGomuksEvent(): + debugPrint("Unhandled event: $type"); + } + debugPrint("Finished handling $type..."); + } catch (error, stackTrace) { + if (kDebugMode) { + debugPrintStack(stackTrace: stackTrace, label: error.toString()); + rethrow; + } else { + showError(error, stackTrace); + } + } + }); + + ref.onDispose(callable.close); + + final errorCode = await _start(handle, callable.nativeFunction); + if (errorCode != 0) { + throw Exception("GomuksStart returned error code $errorCode"); + } + + if (!ref.mounted) return; + + // Cached so the account switcher and push routing have them while the + // account isn't active + ref.listen( + ClientStateController.provider.select( + (state) => (state?.isInitialized, state?.userId, state?.deviceId), + ), + (_, ids) => Future(() async { + final id = ref.read(ActiveAccountController.provider); + final accounts = ref.read(AccountsController.provider.notifier); + + switch (ids) { + // Logged out, so it shouldn't show as an account anymore + case (true, null, _): + await accounts.reset(id); + case (_, final userId?, final deviceId): + final previous = (await ref.read( + AccountsController.provider.future, + )).firstWhereOrNull((account) => account.id == id); + final profile = + (await ref + .read(ClientController.provider.notifier) + .getProfile(userId)) + .profile; + + // Fetching the profile fails while offline, so keep what was cached + await accounts.save( + .new( + id: id, + userId: userId, + deviceId: deviceId, + displayName: profile.displayName ?? previous?.displayName, + avatar: profile.avatarUrl ?? previous?.avatar, + ), + ); + } + }).catchError(showError), + fireImmediately: true, + ); + + if (Platform.isAndroid || Platform.isIOS) { + // Mobile OSes block network access for backgrounded apps, so pause + // syncing instead of letting it fail. + ref.listen(AppPausedController.provider, (_, paused) { + final client = ref.read(ClientController.provider.notifier); + if (paused) { + client.pauseSync().onError(showError); + } else { + ref.invalidate(TypingController.provider); + client.resumeSync().onError(showError); + } + }, fireImmediately: true); + } + } + + /// Waits for gomuks to report the client's device ID + Future deviceId() { + final completer = Completer(); + final subscription = ref.listen( + ClientStateController.provider.select((value) => value?.deviceId), + (_, next) { + if (next != null && !completer.isCompleted) completer.complete(next); + }, + fireImmediately: true, + ); + return completer.future.whenComplete(subscription.close); + } + + static Future _start( + int handle, + Pointer< + NativeFunction, Int64, GomuksOwnedBuffer)> + > + callback, + ) => Isolate.run(() => GomuksStart(handle, callback)); + + static final provider = AsyncNotifierProvider( + GomuksListenerController.new, + ); +} diff --git a/lib/controllers/header_controller.dart b/lib/controllers/header_controller.dart deleted file mode 100644 index 295cf04..0000000 --- a/lib/controllers/header_controller.dart +++ /dev/null @@ -1,20 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; - -class HeaderController extends AsyncNotifier> { - @override - Future> build() async { - if (ref.watch(ClientStateController.provider)?.isLoggedIn != true) { - return {}; - } - final client = ref.watch(ClientController.provider.notifier); - final accessToken = await client.getAccessToken(); - return {"authorization": "Bearer $accessToken"}; - } - - static final provider = - AsyncNotifierProvider>( - HeaderController.new, - ); -} diff --git a/lib/controllers/image_pack_sources.dart b/lib/controllers/image_pack_sources.dart new file mode 100644 index 0000000..61e4a2e --- /dev/null +++ b/lib/controllers/image_pack_sources.dart @@ -0,0 +1,75 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/enabled_image_packs.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/space_edges.dart"; + +typedef ImagePackSource = ({String roomId, String? stateKey}); + +class ImagePackSourcesController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + final enabledPacksFuture = ref.watch( + EnabledImagePacksController.provider.future, + ); + final spaceEdges = ref.watch(SpaceEdgesController.provider); + final directParents = ref.watch( + RoomsController.provider.select( + (rooms) => + rooms[roomId]?.state["m.space.parent"]?.keys.toISet() ?? + const .empty(), + ), + ); + + Iterable parentsOf(String childId) => spaceEdges.entries + .where((entry) => entry.value.any((edge) => edge.childId == childId)) + .map((entry) => entry.key) + .followedBy(childId == roomId ? directParents : const []); + + ISet withAncestors(ISet spaces, String childId) => + parentsOf(childId).fold( + spaces, + (spaces, parentId) => spaces.contains(parentId) + ? spaces + : withAncestors(spaces.add(parentId), parentId), + ); + + final rooms = ref.read(RoomsController.provider); + final spaces = withAncestors( + const .empty(), + roomId, + ).remove(roomId).where(rooms.containsKey).toIList(); + + final client = ref.read(ClientController.provider.notifier); + final roomsNotifier = ref.read(RoomsController.provider.notifier); + + await Future.wait( + spaces + .where((spaceId) => !rooms[spaceId]!.hasFetchedState) + .map( + (spaceId) async => roomsNotifier.addState( + spaceId, + await client.getRoomState(.new(roomId: spaceId)), + ), + ), + ); + final enabledPacks = await enabledPacksFuture; + + return .new([ + (roomId: roomId, stateKey: null), + ...spaces.map((spaceId) => (roomId: spaceId, stateKey: null)), + ...enabledPacks.entries.expand( + (entry) => entry.value.map( + (stateKey) => (roomId: entry.key, stateKey: stateKey), + ), + ), + ]); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>( + ImagePackSourcesController.new, + ); +} diff --git a/lib/controllers/image_packs.dart b/lib/controllers/image_packs.dart new file mode 100644 index 0000000..9248211 --- /dev/null +++ b/lib/controllers/image_packs.dart @@ -0,0 +1,43 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/image_pack_sources.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/room_image_packs.dart"; +import "package:nexus/models/image_pack.dart"; + +class ImagePacksController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + final sources = await ref.watch( + ImagePackSourcesController.provider(roomId).future, + ); + + final packs = ref.watch( + RoomsController.provider.select( + (rooms) => sources.expand((source) { + final packs = + rooms[source.roomId]?.imagePacks(source.roomId) ?? const .empty(); + + return switch (source.stateKey) { + final stateKey? => [?packs[stateKey]], + null => packs.values, + }; + }).toIList(), + ), + ); + + return .new( + packs + .whereNoDuplicates(by: (pack) => pack.id) + .where( + (pack) => pack.emoticons.isNotEmpty || pack.stickers.isNotEmpty, + ), + ); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>( + ImagePacksController.new, + ); +} diff --git a/lib/controllers/image_picker.dart b/lib/controllers/image_picker.dart new file mode 100644 index 0000000..797c997 --- /dev/null +++ b/lib/controllers/image_picker.dart @@ -0,0 +1,11 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:image_picker/image_picker.dart"; + +class ImagePickerController extends Notifier { + @override + ImagePicker build() => .new(); + + static final provider = NotifierProvider( + ImagePickerController.new, + ); +} diff --git a/lib/controllers/init_complete_controller.dart b/lib/controllers/init_complete.dart similarity index 100% rename from lib/controllers/init_complete_controller.dart rename to lib/controllers/init_complete.dart diff --git a/lib/controllers/invites.dart b/lib/controllers/invites.dart new file mode 100644 index 0000000..42821ac --- /dev/null +++ b/lib/controllers/invites.dart @@ -0,0 +1,137 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/experimental/mutation.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/helpers/extensions/get_localpart.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/encryption.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/name.dart"; +import "package:nexus/models/content/topic.dart"; +import "package:nexus/models/invited_room.dart"; +import "package:nexus/models/room_summary.dart"; +import "package:nexus/models/sync_data.dart"; + +/// Pending invites, newest first. +class InvitesController extends Notifier> { + @override + IList build() => .new(); + + /// Adds new invites, and removes invites for rooms that have since been + /// joined or left. + void update( + IList invitedRooms, { + ISet joinedRooms = const .empty(), + ISet leftRooms = const .empty(), + }) { + final userId = ref.read(ClientStateController.provider)?.userId; + + RoomSummary toSummary(InvitedRoom invite) { + final summary = invite.inviteState.fold( + RoomSummary(roomId: invite.roomId, membership: .invite), + (summary, event) => switch (event.content) { + NameContent(:final name) when name.isNotEmpty => summary.copyWith( + name: name, + ), + AvatarContent(:final url) => summary.copyWith(avatarUrl: url), + TopicContent(:final topic) when topic.isNotEmpty => summary.copyWith( + topic: topic, + ), + CanonicalAliasContent(:final alias) => summary.copyWith( + canonicalAlias: alias, + ), + JoinRulesContent(:final joinRule) => summary.copyWith( + joinRule: joinRule, + ), + CreateContent(:final type, :final roomVersion) => summary.copyWith( + roomType: type, + roomVersion: roomVersion, + ), + EncryptionContent(:final algorithm) => summary.copyWith( + encryption: algorithm, + ), + HistoryVisibilityContent(:final historyVisibility) => + summary.copyWith( + worldReadable: historyVisibility == .worldReadable, + ), + MembershipContent(:final isDirect, :final reason) + when event.stateKey == userId => + summary.copyWith( + invitedBy: event.sender, + inviteReason: reason, + isDirect: isDirect, + ), + _ => summary, + }, + ); + + // The inviter is only known once the user's own invite has been found + final inviterProfile = switch (invite.inviteState + .firstWhereOrNull( + (event) => + event.type == EventType.membership.type && + event.stateKey == summary.invitedBy, + ) + ?.content) { + MembershipContent content => content, + _ => null, + }; + + return summary.copyWith( + inviterProfile: inviterProfile, + // Direct messages usually have no name, so use the inviter's + name: + summary.name ?? + (summary.isDirect + ? inviterProfile?.displayName ?? summary.invitedBy?.localpart + : null), + ); + } + + final newInvites = invitedRooms.map(toSummary).toIList(); + final newRoomIds = newInvites.map((invite) => invite.roomId).toISet(); + final removed = joinedRooms.addAll(leftRooms); + + state = newInvites + .addAll(state.where((invite) => !newRoomIds.contains(invite.roomId))) + .removeWhere((invite) => removed.contains(invite.roomId)); + } + + void handleSync(SyncData sync) { + if (sync.clearState) state = .new(); + update( + sync.invitedRooms, + joinedRooms: sync.rooms.keys.toISet(), + leftRooms: sync.leftRooms, + ); + } + + void remove(String roomId) => + state = state.removeWhere((invite) => invite.roomId == roomId); + + Future decline(String roomId) async { + await ref.read(ClientController.provider.notifier).leaveRoom(roomId); + remove(roomId); + } + + /// Declines every invite, removing each as it succeeds. If any fail, the + /// first error is thrown once all have finished. + Future declineAll() => + Future.wait(state.map((invite) => decline(invite.roomId))); + + /// Accepting or declining an invite, keyed by its room ID + static final respondMutation = Mutation(); + static final declineAllMutation = Mutation(); + + static final provider = + NotifierProvider>( + InvitesController.new, + ); +} diff --git a/lib/controllers/jump_request.dart b/lib/controllers/jump_request.dart new file mode 100644 index 0000000..fc21ab2 --- /dev/null +++ b/lib/controllers/jump_request.dart @@ -0,0 +1,17 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; + +typedef JumpRequest = ({String roomId, int rowId}); + +class JumpRequestController extends Notifier { + @override + JumpRequest? build() => null; + + void request(String roomId, int rowId) => + state = (roomId: roomId, rowId: rowId); + + void consume() => state = null; + + static final provider = NotifierProvider( + JumpRequestController.new, + ); +} diff --git a/lib/controllers/key.dart b/lib/controllers/key.dart new file mode 100644 index 0000000..d0ea054 --- /dev/null +++ b/lib/controllers/key.dart @@ -0,0 +1,43 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/active_account.dart"; +import "package:nexus/controllers/shared_prefs.dart"; +import "package:nexus/models/account.dart"; + +class KeyController(final String key) extends AsyncNotifier { + static const String spaceKey = "space"; + static const String roomKey = "room"; + static const String pushKeyKey = "pushKey"; + static const String accountsKey = "accounts"; + static const String activeAccountKey = "activeAccount"; + + static const ISet _perAccountKeys = ISetConst({spaceKey, roomKey}); + + String get _prefsKey { + final account = ref.read(ActiveAccountController.provider); + + return _perAccountKeys.contains(key) && account != Account.legacyId + ? "$account/$key" + : key; + } + + @override + Future build() => + ref.watch(SharedPrefsController.provider).getString(_prefsKey); + + Future set(String? value) async { + final prefs = ref.read(SharedPrefsController.provider); + state = .data(value); + + if (value == null) { + await prefs.remove(_prefsKey); + } else { + await prefs.setString(_prefsKey, value); + } + } + + static final provider = + AsyncNotifierProvider.family( + KeyController.new, + ); +} diff --git a/lib/controllers/key_controller.dart b/lib/controllers/key_controller.dart deleted file mode 100644 index 946892e..0000000 --- a/lib/controllers/key_controller.dart +++ /dev/null @@ -1,30 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/shared_prefs_controller.dart"; - -class KeyController extends Notifier { - final String key; - KeyController(this.key); - - static const String spaceKey = "space"; - static const String roomKey = "room"; - - @override - String? build() => - ref.watch(SharedPrefsController.provider).requireValue.getString(key); - - Future set(String? id) async { - final prefs = ref.watch(SharedPrefsController.provider).requireValue; - state = id; - - if (id == null) { - prefs.remove(key); - } else { - prefs.setString(key, id); - } - } - - static final provider = - NotifierProvider.family( - KeyController.new, - ); -} diff --git a/lib/controllers/link_request.dart b/lib/controllers/link_request.dart new file mode 100644 index 0000000..0d7e7c2 --- /dev/null +++ b/lib/controllers/link_request.dart @@ -0,0 +1,21 @@ +import "package:app_links/app_links.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/helpers/extensions/get_link_info.dart"; + +class LinkRequestController extends Notifier { + @override + LinkInfo? build() { + final subscription = AppLinks().uriLinkStream.listen((uri) { + if (uri.toString().linkInfo case final linkInfo?) state = linkInfo; + }); + ref.onDispose(subscription.cancel); + + return null; + } + + void consume() => state = null; + + static final provider = NotifierProvider( + LinkRequestController.new, + ); +} diff --git a/lib/controllers/local_aliases.dart b/lib/controllers/local_aliases.dart new file mode 100644 index 0000000..e80d4bb --- /dev/null +++ b/lib/controllers/local_aliases.dart @@ -0,0 +1,28 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; + +/// A room's aliases on the user's own server. +class LocalAliasesController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() => + ref.read(ClientController.provider.notifier).getLocalAliases(roomId); + + Future add(String alias) async { + await ref + .read(ClientController.provider.notifier) + .createAlias(roomId, alias); + ref.invalidateSelf(); + } + + Future remove(String alias) async { + await ref.read(ClientController.provider.notifier).deleteAlias(alias); + ref.invalidateSelf(); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose, String>( + LocalAliasesController.new, + ); +} diff --git a/lib/controllers/member_list_opened.dart b/lib/controllers/member_list_opened.dart new file mode 100644 index 0000000..60a17d5 --- /dev/null +++ b/lib/controllers/member_list_opened.dart @@ -0,0 +1,20 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/shared_prefs.dart"; + +class MemberListOpenedController extends AsyncNotifier { + static const String key = "memberListOpened"; + + @override + Future build() async => + await ref.watch(SharedPrefsController.provider).getBool(key) ?? true; + + Future set(bool value) async { + state = .data(value); + await ref.read(SharedPrefsController.provider).setBool(key, value); + } + + static final provider = + AsyncNotifierProvider( + MemberListOpenedController.new, + ); +} diff --git a/lib/controllers/members.dart b/lib/controllers/members.dart new file mode 100644 index 0000000..8d0c989 --- /dev/null +++ b/lib/controllers/members.dart @@ -0,0 +1,44 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/requests/get_room_state.dart"; + +class MembersController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + final room = ref.watch( + RoomsController.provider.select((value) => value[roomId]), + ); + + if (room == null) return .new(); + + if (!room.hasFetchedMembers) { + final fetchedState = await ref + .read(ClientController.provider.notifier) + .getRoomState( + GetRoomStateRequest( + roomId: roomId, + fetchMembers: !(room.metadata?.hasMemberList ?? false), + includeMembers: true, + ), + ); + + ref + .read(RoomsController.provider.notifier) + .addState(roomId, fetchedState, isMembers: true); + } + + return room.state[EventType.membership.type]?.values + .map((rowId) => room.events[rowId]) + .nonNulls + .toISet() ?? + .new(); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>(MembersController.new); +} diff --git a/lib/controllers/members_by_status.dart b/lib/controllers/members_by_status.dart new file mode 100644 index 0000000..82905aa --- /dev/null +++ b/lib/controllers/members_by_status.dart @@ -0,0 +1,30 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/members.dart"; +import "package:nexus/models/configs/members_by_status.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/event.dart"; + +class MembersByStatusController(final MembersByStatusConfig config) + extends AsyncNotifier> { + @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 deleted file mode 100644 index 268a30d..0000000 --- a/lib/controllers/members_controller.dart +++ /dev/null @@ -1,25 +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/models/event.dart"; -import "package:nexus/models/room.dart"; - -class MembersController extends Notifier> { - final Room room; - MembersController(this.room); - - @override - IList build() => (room.state["m.room.member"]?.values ?? []) - .map( - (eventRowId) => - room.events.firstWhereOrNull((event) => event.rowId == eventRowId), - ) - .nonNulls - .where((member) => member.content["membership"] == "join") - .toIList(); - - static final provider = NotifierProvider.family - .autoDispose, Room>( - MembersController.new, - ); -} diff --git a/lib/controllers/members_grouped.dart b/lib/controllers/members_grouped.dart new file mode 100644 index 0000000..a668896 --- /dev/null +++ b/lib/controllers/members_grouped.dart @@ -0,0 +1,44 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/members_by_status.dart"; +import "package:nexus/controllers/room_power_levels.dart"; +import "package:nexus/models/configs/members_by_status.dart"; +import "package:nexus/models/event.dart"; + +class MembersGroupedController(final MembersByStatusConfig config) + extends AsyncNotifier>>> { + @override + Future>>> build() async { + final powerLevels = ref.watch( + RoomPowerLevelsController.provider(config.roomId), + ); + + final members = await ref.watch( + MembersByStatusController.provider(config).future, + ); + + return members + .fold>>(.new(), (result, event) { + final groupKey = powerLevels.creators.contains(event.stateKey!) + ? null + : powerLevels.levelOf(event.stateKey!).toInt(); + + return result.update( + groupKey, + (value) => value.add(event), + ifAbsent: () => .new({event}), + ); + }) + .toEntryIList( + compare: (a, b) => + (b?.key ?? double.infinity).compareTo(a?.key ?? double.infinity), + ); + } + + static final provider = + AsyncNotifierProvider.family< + MembersGroupedController, + IList>>, + MembersByStatusConfig + >(MembersGroupedController.new); +} diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart deleted file mode 100644 index f3ef13b..0000000 --- a/lib/controllers/message_controller.dart +++ /dev/null @@ -1,205 +0,0 @@ -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/controllers/members_controller.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/models/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 members = ref.read(MembersController.provider(config.room)); - final author = members.firstWhereOrNull( - (member) => member.stateKey == event.authorId, - ); - 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, - "avatarUrl": author?.content["avatar_url"], - "editSource": - event.localContent?.editSource ?? - newContent?["body"] ?? - content["body"], - "displayName": author?.content["displayname"]?.isNotEmpty == true - ? author?.content["displayname"] - : event.authorId.substring(1).split(":")[0], - "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 deleted file mode 100644 index 83bd815..0000000 --- a/lib/controllers/messages_controller.dart +++ /dev/null @@ -1,27 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/message_controller.dart"; -import "package:nexus/models/message_config.dart"; -import "package:nexus/models/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.dart similarity index 67% rename from lib/controllers/multi_provider_controller.dart rename to lib/controllers/multi_provider.dart index e23ecaa..ab37d07 100644 --- a/lib/controllers/multi_provider_controller.dart +++ b/lib/controllers/multi_provider.dart @@ -1,14 +1,14 @@ import "dart:async"; + import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -class MultiProviderController extends AsyncNotifier { - MultiProviderController(this.providers); - final IList providers; - +class MultiProviderController(final IList providers) + extends AsyncNotifier { @override - FutureOr build() async => await Future.wait( + Future build() => .wait( providers.map((provider) => ref.watch(provider.future)), + eagerError: true, ); static final provider = diff --git a/lib/controllers/mutual_rooms.dart b/lib/controllers/mutual_rooms.dart new file mode 100644 index 0000000..da4086a --- /dev/null +++ b/lib/controllers/mutual_rooms.dart @@ -0,0 +1,15 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; + +class MutualRoomsController(final String userId) + extends AsyncNotifier> { + @override + Future> build() => + ref.read(ClientController.provider.notifier).getMutualRooms(userId); + + static final provider = AsyncNotifierProvider.family + .autoDispose, String>( + MutualRoomsController.new, + ); +} diff --git a/lib/controllers/new_events_controller.dart b/lib/controllers/new_events_controller.dart deleted file mode 100644 index 215ebd3..0000000 --- a/lib/controllers/new_events_controller.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/models/event.dart"; - -class NewEventsController extends Notifier> { - final String roomId; - NewEventsController(this.roomId); - - @override - IList build() => const IList.empty(); - - void add(IList newEvents) => state = newEvents; - - static final provider = NotifierProvider.autoDispose - .family, String>( - NewEventsController.new, - ); -} diff --git a/lib/controllers/notification.dart b/lib/controllers/notification.dart new file mode 100644 index 0000000..e8a5dff --- /dev/null +++ b/lib/controllers/notification.dart @@ -0,0 +1,291 @@ +import "dart:async"; +import "dart:convert"; +import "dart:io"; + +import "package:flutter/services.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/active_account.dart"; +import "package:nexus/controllers/portal.dart"; +import "package:nexus/controllers/sidebar_page.dart"; +import "package:nexus/main.dart"; +import "package:flutter_local_notifications/flutter_local_notifications.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/models/push_notification.dart"; +import "package:xdg_desktop_portal/xdg_desktop_portal.dart"; + +typedef NotificationMessage = ({ + NotificationUser sender, + NotificationUser self, + File? senderAvatar, + bool isDm, +}); + +class NotificationController + extends AsyncNotifier { + @override + Future build() async { + final notifications = FlutterLocalNotificationsPlugin(); + + if (!Platform.isLinux) { + final darwin = DarwinInitializationSettings(); + + await notifications.initialize( + settings: .new( + windows: .new( + appName: "Nexus", + appUserModelId: "nexus.federated.nexus", + guid: "dde78daf-130f-4e46-a80a-e31deeab45d7", + ), + android: .new("ic_launcher_foreground"), + iOS: darwin, + macOS: darwin, + ), + onDidReceiveNotificationResponse: (details) { + if (details.payload case final payload?) _open(payload); + }, + ); + } + + if (Platform.isLinux) { + const notificationChannel = MethodChannel("nexus/notifications"); + + notificationChannel.setMethodCallHandler((call) async { + if (call.method != "notificationClicked") { + return; + } + + if (call.arguments case final String payload) _open(payload); + }); + } + + return notifications; + } + + static String payloadFor(String accountId, int rowId) => "$accountId:$rowId"; + + void _open(String payload) { + final (accountId, rowId) = switch (payload.split(":")) { + [final accountId, final rowId] => (accountId, int.tryParse(rowId)), + // From before notifications were per account + [final rowId] => (null, int.tryParse(rowId)), + _ => (null, null), + }; + if (rowId == null) return; + + navigatorKey.currentState?.popUntil((route) => route.isFirst); + ref.read(SidebarPageController.provider.notifier).set(.notifications); + + ref + .read(ActiveAccountController.provider.notifier) + .switchTo( + accountId ?? ref.read(ActiveAccountController.provider), + notificationRowId: rowId, + ) + .onError(showError); + } + + Future requestPermissions() async { + if (Platform.isLinux) { + return true; + } + + final controller = await future; + + if (Platform.isIOS) { + return await controller + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >() + ?.requestPermissions(alert: true, badge: true, sound: true) ?? + true; + } else if (Platform.isMacOS) { + return await controller + .resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin + >() + ?.requestPermissions(alert: true, badge: true, sound: true) ?? + true; + } else if (Platform.isAndroid) { + return await controller + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.requestNotificationsPermission() ?? + true; + } + + return true; + } + + /// Whether notifications can currently be shown, without prompting + Future permissionsGranted() async { + if (Platform.isLinux) { + return true; + } + + final controller = await future; + + if (Platform.isIOS) { + return (await controller + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >() + ?.checkPermissions()) + ?.isEnabled ?? + true; + } else if (Platform.isMacOS) { + return (await controller + .resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin + >() + ?.checkPermissions()) + ?.isEnabled ?? + true; + } else if (Platform.isAndroid) { + return await controller + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.areNotificationsEnabled() ?? + true; + } + + return true; + } + + /// Passing [message] shows it as a chat message in the conversation for + /// [threadId], naming the sender in group chats. [subtitle] is shown on + /// platforms that support it. + Future send({ + required int id, + required String title, + String? subtitle, + required String body, + String? payload, + String? threadId, + DateTime? timestamp, + File? icon, + bool sound = true, + NotificationMessage? message, + }) async { + debugPrint("Sending notification for $id"); + + // In DMs the title is already the sender + final sender = message == null || message.isDm ? null : message.sender; + final prefixedBody = sender == null ? body : "${sender.name}: $body"; + + if (Platform.isLinux) { + final portal = await ref.read(PortalController.provider.future); + const escape = HtmlEscape(.element); + + await portal.notification.addNotification( + id.toString(), + title: title, + body: prefixedBody, + markupBody: sender == null + ? null + : "${escape.convert(sender.name)}: ${escape.convert(body)}", + defaultAction: "app.event", + defaultActionTarget: payload, + sound: sound ? null : const XdgNotificationSoundSilent(), + category: message == null ? null : XdgNotificationCategory.imReceived, + priority: .normal, + buttons: [ + // TODO: Impl when notif daemons actualy support imReplyWithText + // .new( + // label: "Reply", + // action: "app.reply", + // purpose: XdgNotificationButtonPurpose.imReplyWithText, + // ), + ], + ); + return; + } + + final controller = await future; + + // Append to the conversation already shown under this id, if any + final existingStyle = Platform.isAndroid && message != null + ? await controller + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.getActiveNotificationMessagingStyle(id: id) + .onError((_, _) => null) + : null; + + final darwin = DarwinNotificationDetails( + subtitle: subtitle, + presentSound: sound, + threadIdentifier: threadId, + ); + + await controller.show( + id: id, + title: title, + body: prefixedBody, + payload: payload, + notificationDetails: .new( + android: message == null + ? .new( + "general", + "General", + subText: subtitle, + silent: !sound, + when: timestamp?.millisecondsSinceEpoch, + largeIcon: icon == null + ? null + : FilePathAndroidBitmap(icon.path), + styleInformation: BigTextStyleInformation(body), + ) + : .new( + "messages", + "Messages", + subText: subtitle, + silent: !sound, + when: timestamp?.millisecondsSinceEpoch, + category: .message, + largeIcon: icon == null + ? null + : FilePathAndroidBitmap(icon.path), + styleInformation: MessagingStyleInformation( + .new(key: message.self.id, name: message.self.name), + conversationTitle: message.isDm ? null : title, + groupConversation: !message.isDm, + messages: [ + ...?existingStyle?.messages, + .new( + body, + timestamp ?? .now(), + .new( + key: message.sender.id, + name: message.sender.name, + icon: message.senderAvatar == null + ? null + : BitmapFilePathAndroidIcon( + message.senderAvatar!.path, + ), + ), + ), + ], + ), + ), + iOS: darwin, + macOS: darwin, + windows: .new( + audio: sound ? null : .silent(), + timestamp: timestamp, + header: threadId == null + ? null + : .new(id: threadId, title: title, arguments: payload ?? ""), + ), + ), + ); + } + + static final provider = + AsyncNotifierProvider< + NotificationController, + FlutterLocalNotificationsPlugin + >(NotificationController.new); +} diff --git a/lib/controllers/notification_request.dart b/lib/controllers/notification_request.dart new file mode 100644 index 0000000..072ba99 --- /dev/null +++ b/lib/controllers/notification_request.dart @@ -0,0 +1,14 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; + +class NotificationRequestController extends Notifier { + @override + int? build() => null; + + void request(int rowId) => state = rowId; + + void consume() => state = null; + + static final provider = NotifierProvider( + NotificationRequestController.new, + ); +} diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart new file mode 100644 index 0000000..e38ffae --- /dev/null +++ b/lib/controllers/notifications.dart @@ -0,0 +1,78 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/event.dart"; + +typedef NotificationsRequest = (UnreadType? unreadType, String? roomId); + +class NotificationsController(final NotificationsRequest request) + extends AsyncNotifier> { + @override + Future> build() async { + final client = ref.read(ClientController.provider.notifier); + + final (unreadType, roomId) = request; + + final mentions = await client.getMentions( + .new( + maxTimestamp: .now(), + unreadType: unreadType ?? .highlight, + roomId: roomId, + ), + ); + + ref + .read(RoomsController.provider.notifier) + .storeEvents(mentions.events.addAll(mentions.relatedEvents)); + + return .new(mentions.events.map((event) => (event.rowId, event.roomId))); + } + + Future loadOlder() async { + final currentNotifications = await future; + state = .loading(); + state = await .guard(() async { + final lastNotification = currentNotifications.lastOrNull; + final lastTs = lastNotification == null + ? null + : ref.read( + RoomsController.provider.select( + (rooms) => rooms[lastNotification.$2] + ?.events[lastNotification.$1] + ?.timestamp, + ), + ); + + if (lastTs == null) return currentNotifications; + + final client = ref.read(ClientController.provider.notifier); + final (unreadType, roomId) = request; + + final newNotifications = await client.getMentions( + .new( + maxTimestamp: lastTs, + unreadType: unreadType ?? .highlight, + roomId: roomId, + ), + ); + + ref + .read(RoomsController.provider.notifier) + .storeEvents( + newNotifications.events.addAll(newNotifications.relatedEvents), + ); + + return currentNotifications.addAll( + newNotifications.events.map((event) => (event.rowId, event.roomId)), + ); + }); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose< + NotificationsController, + IList<(int, String)>, + NotificationsRequest + >(NotificationsController.new); +} diff --git a/lib/controllers/pinned_events.dart b/lib/controllers/pinned_events.dart new file mode 100644 index 0000000..16456fc --- /dev/null +++ b/lib/controllers/pinned_events.dart @@ -0,0 +1,26 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/event.dart"; +import "package:nexus/controllers/pinned_ids.dart"; + +class PinnedEventsController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + final pinIds = ref.watch(PinnedIdsController.provider(roomId)); + + return (await Future.wait( + pinIds.map( + (eventId) => ref.watch( + EventController.provider(.new(eventId: eventId, roomId: roomId)) + .future, + ), + ), + )).nonNulls.toIList(); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose, String>( + PinnedEventsController.new, + ); +} diff --git a/lib/controllers/pinned_ids.dart b/lib/controllers/pinned_ids.dart new file mode 100644 index 0000000..01358a9 --- /dev/null +++ b/lib/controllers/pinned_ids.dart @@ -0,0 +1,50 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/pinned_events.dart"; + +class PinnedIdsController(final String roomId) extends Notifier> { + @override + IList build() { + final room = ref.watch( + RoomsController.provider.select((rooms) => rooms[roomId]), + ); + + if (room == null) return .new(); + + final pinnedRowId = room.state[EventType.pinnedEvents.type]?[""]; + final pinnedStateEvent = pinnedRowId == null + ? null + : room.events[pinnedRowId]; + + if (pinnedStateEvent?.content case PinnedEventsContent content) { + return content.pinnedEvents; + } + + return .new(); + } + + Future addPin(String eventId) async => + setPinned(.new(pinnedEvents: .new(state.add(eventId)))); + + Future removePin(String eventId) async => + setPinned(.new(pinnedEvents: .new(state.remove(eventId)))); + + Future setPinned(PinnedEventsContent content) => ref + .read(ClientController.provider.notifier) + .setState( + .new( + roomId: roomId, + type: EventType.pinnedEvents.type, + stateKey: "", + content: content, + ), + ); + + static final provider = NotifierProvider.family + .autoDispose, String>( + PinnedIdsController.new, + ); +} diff --git a/lib/controllers/poll_votes.dart b/lib/controllers/poll_votes.dart new file mode 100644 index 0000000..c881a0c --- /dev/null +++ b/lib/controllers/poll_votes.dart @@ -0,0 +1,132 @@ +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.dart"; +import "package:nexus/controllers/room_power_levels.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/event_id_is_fake.dart"; +import "package:nexus/models/configs/event.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/encrypted.dart"; +import "package:nexus/models/content/poll_end.dart"; +import "package:nexus/models/content/poll_response.dart"; +import "package:nexus/models/content/poll_start.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/poll_votes.dart"; + +class PollVotesController(final EventConfig config) + extends AsyncNotifier { + @override + Future build() async { + // Only what's needed, so e.g. reactions don't cause refetches + final (poll, localRelated) = ref.watch( + RoomsController.provider.select((rooms) { + final room = rooms[config.roomId]; + final event = room?.events[config.eventRowId]; + if (room == null || event == null) { + return (null, const IList.empty()); + } + + final edited = event.lastEditRowId == 0 + ? null + : room.events[event.lastEditRowId]; + + return ( + ( + eventId: event.eventId, + sender: event.sender, + // Edits can change the answers + content: (edited ?? event).content, + ), + (room.relations[event.eventId] ?? const ISet.empty()) + .map((rowId) => room.events[rowId]) + .nonNulls + .where((related) => related.relationType == "m.reference") + .toIList(), + ); + }), + ); + final powerLevels = ref.watch( + RoomPowerLevelsController.provider(config.roomId), + ); + + if (poll == null) return const PollVotes(); + final PollStart(:answers, :selectionLimit) = switch (poll.content) { + PollStartContent(:final poll) => poll, + _ => const PollStart(), + }; + + final fetched = await ref + .read(ClientController.provider.notifier) + .getRelatedEvents( + .new( + roomId: config.roomId, + eventId: poll.eventId, + relationType: "m.reference", + ), + ); + + final related = + IMap.fromIterable( + (fetched ?? const IList.empty()).addAll(localRelated), + keyMapper: (event) => event.rowId, + valueMapper: (event) => event, + ).values + // gomuks' database has unsent local echoes too + .where((event) => event.redactedBy == null && !event.eventId.isFake) + .toIList(); + + final end = related + .where( + (event) => + event.content is PollEndContent && + powerLevels.canEndPoll(event.sender, pollSender: poll.sender), + ) + .sortedBy((event) => event.timestamp) + .firstOrNull; + final endedAt = end?.timestamp; + + bool beforeEnd(Event event) => + endedAt == null || !event.timestamp.isAfter(endedAt); + + final answerIds = answers.map((answer) => answer.id).toISet(); + + return .new( + endEventId: end?.eventId, + undecryptable: related + .where( + (event) => event.content is EncryptedContent && beforeEnd(event), + ) + .length, + // Each user's latest response before the end counts, even if invalid + votes: related + .where( + (event) => + event.type == EventType.pollResponse.type && beforeEnd(event), + ) + .toIList() + .sort((a, b) => a.timestamp.compareTo(b.timestamp)) + .fold(const .empty(), (votes, event) { + // Truncated before removing duplicates, as MSC3381 specifies + final picked = switch (event.content) { + PollResponseContent(:final answers) => + answers.take(selectionLimit).toISet(), + _ => null, + }; + + return votes.add( + event.sender, + // Spoiled responses count as no answers + picked != null && picked.every(answerIds.contains) + ? picked.toIList() + : const .empty(), + ); + }), + ); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose( + PollVotesController.new, + ); +} diff --git a/lib/controllers/portal.dart b/lib/controllers/portal.dart new file mode 100644 index 0000000..daae24a --- /dev/null +++ b/lib/controllers/portal.dart @@ -0,0 +1,22 @@ +import "dart:io"; + +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:xdg_desktop_portal/xdg_desktop_portal.dart"; + +class PortalController extends AsyncNotifier { + @override + Future build() async { + final portal = XdgDesktopPortalClient(); + if (!await File("/.flatpak-info").exists()) { + await portal.registerApplication("nexus.federated.nexus"); + } + + ref.onDispose(portal.close); + return portal; + } + + static final provider = + AsyncNotifierProvider( + PortalController.new, + ); +} diff --git a/lib/controllers/power_level.dart b/lib/controllers/power_level.dart new file mode 100644 index 0000000..6fe9d68 --- /dev/null +++ b/lib/controllers/power_level.dart @@ -0,0 +1,65 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/room_power_levels.dart"; +import "package:nexus/models/configs/power_level.dart"; +import "package:nexus/models/content/content.dart"; + +class PowerLevelController(final PowerLevelConfig config) + extends Notifier { + @override + bool build() { + if (config case EventPowerLevelConfig(:final eventType)) { + assert( + eventType != .redaction, + "Checking power level for a redaction should use [PowerLevelConfig.redaction].", + ); + } + + final powerLevels = ref.watch( + RoomPowerLevelsController.provider(config.roomId), + ); + final content = powerLevels.content; + + final user = ref.watch( + ClientStateController.provider.select((value) => value?.userId), + ); + if (user == null) return false; + + final userLevel = powerLevels.levelOf(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 > powerLevels.levelOf(targetUser), + + .ban => + userLevel >= content.ban && + userLevel > powerLevels.levelOf(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/power_levels_saving.dart b/lib/controllers/power_levels_saving.dart new file mode 100644 index 0000000..d20d326 --- /dev/null +++ b/lib/controllers/power_levels_saving.dart @@ -0,0 +1,20 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; + +class PowerLevelsSavingController(final String roomId) extends Notifier { + @override + bool build() => false; + + Future run(Future Function() action) async { + state = true; + try { + await action(); + } finally { + if (ref.mounted) state = false; + } + } + + static final provider = NotifierProvider.autoDispose + .family( + PowerLevelsSavingController.new, + ); +} diff --git a/lib/controllers/profile.dart b/lib/controllers/profile.dart new file mode 100644 index 0000000..246815d --- /dev/null +++ b/lib/controllers/profile.dart @@ -0,0 +1,26 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/profile_response.dart"; + +class ProfileController(final String userId) + extends AsyncNotifier { + @override + Future build() { + final client = ref.read(ClientController.provider.notifier); + return client.getProfile(userId); + } + + Future setField(String field, Object? value) async { + await ref + .read(ClientController.provider.notifier) + .setProfileField(field, value); + if (!ref.mounted) return; + ref.invalidateSelf(); + await future; + } + + static final provider = AsyncNotifierProvider.family + .autoDispose( + ProfileController.new, + ); +} diff --git a/lib/controllers/push_key.dart b/lib/controllers/push_key.dart new file mode 100644 index 0000000..24e9440 --- /dev/null +++ b/lib/controllers/push_key.dart @@ -0,0 +1,31 @@ +import "dart:convert"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/key.dart"; + +class PushKeyController(final String instance) extends AsyncNotifier { + @override + Future build() async => json.decode( + await ref.watch(KeyController.provider(KeyController.pushKeyKey).future) ?? + "{}", + )[instance]; + + Future set(String? value) async { + final provider = KeyController.provider(KeyController.pushKeyKey); + final notifier = ref.read(provider.notifier); + final prefs = IMap(json.decode(await ref.read(provider.future) ?? "{}")); + + state = .data(value); + notifier.set( + json.encode( + prefs.add(instance, value).where((_, value) => value != null).unlock, + ), + ); + } + + static final provider = + AsyncNotifierProvider.family( + PushKeyController.new, + ); +} diff --git a/lib/controllers/reactions.dart b/lib/controllers/reactions.dart new file mode 100644 index 0000000..662661c --- /dev/null +++ b/lib/controllers/reactions.dart @@ -0,0 +1,70 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/emoji_shortcodes.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/configs/event.dart"; +import "package:nexus/models/content/reaction.dart"; +import "package:nexus/models/reaction_group.dart"; + +class ReactionsController(final EventConfig config) + extends AsyncNotifier> { + @override + Future> build() async { + final unicodeShortcodes = ref.watch(EmojiShortcodesController.provider); + + 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 + .read(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?, + :final shortcode, + )) { + return acc.update( + key, + (group) => group.copyWith( + senders: group.senders.add(event.sender), + shortcode: group.shortcode ?? shortcode, + ), + ifAbsent: () => .new( + senders: .new([event.sender]), + shortcode: + shortcode ?? + unicodeShortcodes[EmojiShortcodesController.normalize( + key, + )], + ), + ); + } + + return acc; + }) ?? + .new(); + } + + static final provider = + AsyncNotifierProvider.family< + ReactionsController, + IMap, + EventConfig + >(ReactionsController.new); +} diff --git a/lib/controllers/read_receipts.dart b/lib/controllers/read_receipts.dart new file mode 100644 index 0000000..4dba46c --- /dev/null +++ b/lib/controllers/read_receipts.dart @@ -0,0 +1,128 @@ +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.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/helpers/extensions/is_shown_as_message.dart"; +import "package:nexus/models/read_receipt.dart"; + +/// Other users' read receipts in a chat, by the event ID of the message to +/// show them under: the nearest message at or before what they've read. +class ReadReceiptsController(final ChatParams chat) + extends Notifier>> { + @override + IMap> build() { + final shouldShow = ref.watch( + SettingsController.provider.select( + (settings) => settings.value?.readReceipts != .neither, + ), + ); + if (!shouldShow) return const .empty(); + + final userId = ref.watch( + ClientStateController.provider.select((state) => state?.userId), + ); + // The chat's own timeline, so receipts follow what's actually shown + final timeline = ref.watch( + RoomChatController.provider(chat) + .select((roomChat) => roomChat.value?.timeline), + ); + final room = ref.watch( + RoomsController.provider.select( + (rooms) => switch (rooms[chat.roomId]) { + final room? => ( + events: room.events, + eventIds: room.eventIds, + receipts: room.receipts, + timeline: room.timeline, + ), + null => null, + }, + ), + ); + if (userId == null || timeline == null || room == null) { + return const .empty(); + } + + final events = timeline.map((rowId) => room.events[rowId]).nonNulls; + final indexes = IMap.fromEntries( + events.indexed.map((entry) => MapEntry(entry.$2.eventId, entry.$1)), + ); + final messages = events.indexed + .where((entry) => entry.$2.isShownAsMessage) + .toIList(); + final messageIndexes = messages.map((entry) => entry.$1).toList(); + + final positions = IMap.fromEntries( + room.timeline.entries + .map( + (entry) => switch (entry.value) { + final rowId? => MapEntry(rowId, entry.key), + null => null, + }, + ) + .nonNulls, + ); + final placed = events.indexed + .map( + (entry) => switch (positions[entry.$2.rowId]) { + final position? => (index: entry.$1, position: position), + null => null, + }, + ) + .nonNulls + .toList(); + final placedPositions = placed.map((entry) => entry.position).toList(); + + int? indexOf(String eventId) { + if (indexes[eventId] case final index?) return index; + + final position = switch (room.eventIds[eventId]) { + final rowId? => positions[rowId], + null => null, + }; + if (position == null) return null; + + final before = lowerBound(placedPositions, position + 1) - 1; + return before < 0 ? null : placed[before].index; + } + + return room.receipts.values.flattened + .where( + (receipt) => + receipt.userId != userId && + receipt.thread == (chat.threadRoot ?? "main"), + ) + .map( + (receipt) => switch (indexOf(receipt.eventId)) { + final index? => ( + receipt: receipt, + message: lowerBound(messageIndexes, index + 1) - 1, + ), + null => null, + }, + ) + .nonNulls + .where((entry) => entry.message >= 0) + .groupListsBy((entry) => messages[entry.message].$2.eventId) + .map( + (eventId, entries) => MapEntry( + eventId, + entries + .map((entry) => entry.receipt) + .sorted((a, b) => b.timestamp.compareTo(a.timestamp)) + .toIList(), + ), + ) + .toIMap(); + } + + static final provider = NotifierProvider.family + .autoDispose< + ReadReceiptsController, + IMap>, + ChatParams + >(ReadReceiptsController.new); +} diff --git a/lib/controllers/recent_emoji.dart b/lib/controllers/recent_emoji.dart new file mode 100644 index 0000000..a091afb --- /dev/null +++ b/lib/controllers/recent_emoji.dart @@ -0,0 +1,63 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_emoji_picker/material_emoji_picker.dart"; +import "package:nexus/controllers/account_data.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/account_data.dart"; + +class RecentEmojiController extends Notifier> { + @override + IList build() { + final Set emoji = ref + .watch(EmojiController.provider) + .requireValue + .map((element) => element.emojis) + .flattened + .map((e) => e.value) + .toSet(); + + final IList recents = ref.watch( + AccountDataController.provider.select( + (value) => value.recentEmoji.recentEmoji, + ), + ); + + return .new( + recents.where( + (recent) => + // Is unicode or custom emote + emoji.contains(recent.emoji) || recent.emoji.startsWith("mxc://"), + ), + ); + } + + Future add(String emoji) => ref + .read(ClientController.provider.notifier) + .setAccountData( + .new( + type: AccountData.recentEmojiKey, + content: RecentEmojiData( + recentEmoji: .new([ + .new( + emoji: emoji, + total: + (state + .firstWhereOrNull( + (element) => element.emoji == emoji, + ) + ?.total ?? + 0) + + 1, + ), + ...state.whereNot((element) => element.emoji == emoji), + ]), + ), + ), + ); + + static final provider = + NotifierProvider.autoDispose>( + RecentEmojiController.new, + ); +} diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart new file mode 100644 index 0000000..773f30d --- /dev/null +++ b/lib/controllers/room_chat.dart @@ -0,0 +1,671 @@ +import "dart:async"; +import "dart:math"; + +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:fluttertagger/fluttertagger.dart"; +import "package:nexus/controllers/attachment.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/event.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/thread_replies.dart"; +import "package:nexus/helpers/extensions/is_media.dart"; +import "package:nexus/helpers/extensions/send_failed.dart"; +import "package:nexus/helpers/extensions/thread_events.dart"; +import "package:nexus/models/bot_command_draft.dart"; +import "package:nexus/models/composer_params.dart"; +import "package:nexus/models/content/bot_command.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/content/poll_end.dart"; +import "package:nexus/models/content/poll_response.dart"; +import "package:nexus/models/content/poll_start.dart"; +import "package:nexus/models/content/reaction.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/direction.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/paginate_manual.dart"; +import "package:nexus/models/requests/redact_event.dart"; +import "package:nexus/models/relation_type.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/requests/send_message.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/models/room_chat.dart"; + +typedef ChatParams = ({ + String roomId, + int? contextualEvent, + String? threadRoot, +}); + +class RoomChatController(final ChatParams info) + extends AsyncNotifier { + @override + Future build() async { + final client = ref.read(ClientController.provider.notifier); + + final room = ref.read( + RoomsController.provider.select((rooms) => rooms[info.roomId]), + ); + + if (room == null) return null; + + if (!room.hasFetchedState) { + final state = await client.getRoomState(.new(roomId: info.roomId)); + ref.read(RoomsController.provider.notifier).addState(info.roomId, state); + } + + if (info.threadRoot case final threadRoot? + when info.contextualEvent == null) { + final nextBatch = await ref.watch( + ThreadRepliesController.provider(( + roomId: info.roomId, + threadRoot: threadRoot, + )).future, + ); + + return .new( + timeline: ref.watch(RoomsController.provider.select(threadTimeline)), + hasMoreBackward: nextBatch != null, + hasMoreForward: false, + ); + } + + if (info.threadRoot == null) { + final timeline = room.timeline + .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) + .map((e) => e.value) + .toIList() + .addAll(room.clientSticky) + .nonNulls + // Threads are shown from their root instead + .where((rowId) => room.events[rowId]?.threadRoot == null) + .toIList(); + + if (info.contextualEvent == null || + timeline.contains(info.contextualEvent)) { + ref.watch( + RoomsController.provider.select( + (rooms) => ( + rooms[info.roomId]?.timeline, + rooms[info.roomId]?.clientSticky, + ), + ), + ); + + return .new( + timeline: timeline, + hasMoreBackward: room.hasMore, + hasMoreForward: false, + ); + } + } + + final context = await client.getEventContext( + .new( + roomId: info.roomId, + eventId: room.events[info.contextualEvent!]!.eventId, + ), + ); + + final contextEvents = context.before.reversed + .toIList() + .add(context.event) + .addAll(context.after); + ref + .read(RoomsController.provider.notifier) + .storeEvents(contextEvents.addAll(context.relatedEvents)); + + if (info.threadRoot case final threadRoot?) { + final (older, newer) = await ( + client.paginateManual( + .new( + roomId: info.roomId, + threadRoot: threadRoot, + since: context.start, + direction: .backward, + ), + ), + client.paginateManual( + .new( + roomId: info.roomId, + threadRoot: threadRoot, + since: context.end, + direction: .forward, + ), + ), + ).wait; + final rootRowId = await threadRootBefore(older); + ref + .read(RoomsController.provider.notifier) + .storeEvents( + older.events + .addAll(older.relatedEvents) + .addAll(newer.events) + .addAll(newer.relatedEvents), + ); + + return .new( + timeline: .new([?rootRowId]) + .addAll( + // Paginating backwards gives the newest first + older.events.reversed + .addAll( + contextEvents.where( + (event) => event.threadRoot == threadRoot, + ), + ) + .addAll(newer.events) + .map((event) => event.rowId), + ) + .removeDuplicates(), + hasMoreBackward: older.nextBatch != null, + hasMoreForward: newer.nextBatch != null, + historicalData: .new( + start: older.nextBatch ?? "", + end: newer.nextBatch ?? "", + ), + ); + } + + return .new( + timeline: .new( + contextEvents + .where((event) => event.threadRoot == null) + .map((event) => event.rowId), + ), + hasMoreBackward: true, + hasMoreForward: true, + historicalData: .new(start: context.start, end: context.end), + ); + } + + /// The thread's root, which comes before all of its events, if paginating + /// backwards to [older] reached the start of the thread. + Future threadRootBefore(PaginateManual older) async => + switch (info.threadRoot) { + final threadRoot? when older.nextBatch == null => await ref.read( + EventController.provider( + .new(roomId: info.roomId, eventId: threadRoot), + ).future, + ), + _ => null, + }; + + /// The thread's root, then its events, if this is a thread's chat. + IList threadTimeline(IMap rooms) => + switch ((rooms[info.roomId], info.threadRoot)) { + (final room?, final threadRoot?) => .new([ + ?room.eventIds[threadRoot], + ...room.threadEvents(threadRoot).map((event) => event.rowId), + ]), + _ => const .empty(), + }; + + Relation? replyRelation(Event? replied) => + switch (info.threadRoot ?? replied?.threadRoot) { + null => + replied == null + ? null + : .new(eventId: replied.eventId, relationType: .reply), + final threadRoot => .new( + eventId: + replied?.eventId ?? + ref + .read(RoomsController.provider)[info.roomId] + ?.latestThreadEvent(threadRoot) + ?.eventId ?? + threadRoot, + relationType: .reply, + threadRoot: threadRoot, + isFallingBack: replied == null, + ), + }; + + Future deleteMessage(Event event, {String? reason}) => ref + .read(ClientController.provider.notifier) + .redactEvent( + RedactEventRequest( + eventId: event.eventId, + roomId: info.roomId, + reason: reason, + ), + ); + + void hideClientSticky(int eventRowId) => ref + .read(RoomsController.provider.notifier) + .update( + .new({ + info.roomId: Room( + resetClientSticky: true, + clientSticky: + ref + .read( + RoomsController.provider.select( + (rooms) => rooms[info.roomId]?.clientSticky, + ), + ) + ?.remove(eventRowId) ?? + .new(), + ), + }), + ); + + Future paginate(Direction direction) async { + if (state.isLoading) return; + + final chat = await future; + + if (direction == .forward + ? chat?.hasMoreForward == false + : chat?.hasMoreBackward == false) { + return; + } + + if ((info.threadRoot, chat) case (final threadRoot?, final chat?) + when chat.historicalData == null) { + return ref + .read( + ThreadRepliesController.provider(( + roomId: info.roomId, + threadRoot: threadRoot, + )).notifier, + ) + .loadOlder(); + } + + state = .loading(); + + final client = ref.read(ClientController.provider.notifier); + + try { + if (chat?.historicalData == null) { + final timelineKeys = ref + .read( + RoomsController.provider.select((value) => value[info.roomId]), + ) + ?.timeline + .keys; + final response = await client.paginate( + .new( + roomId: info.roomId, + maxTimelineId: timelineKeys?.isNotEmpty == true + ? timelineKeys?.reduce(min) + : null, + ), + ); + + if (response.events.isEmpty) { + state = .data(chat); + } + + ref + .read(RoomsController.provider.notifier) + .update( + .new({ + info.roomId: .new( + // ignore: unused_result - I am using it you donkey + events: .fromIterable( + response.events.addAll(response.relatedEvents), + keyMapper: (event) => event.rowId, + valueMapper: (event) => event, + ), + hasMore: response.hasMore, + receipts: response.receipts, + // ignore: unused_result - I am using it you donkey + timeline: .fromIterable( + response.events, + keyMapper: (event) => event.timelineRowId, + valueMapper: (event) => event.rowId, + ), + ), + }), + ); + } else { + final paginationResponse = await client.paginateManual( + .new( + roomId: info.roomId, + threadRoot: info.threadRoot, + direction: direction, + since: direction == .forward + ? chat!.historicalData!.end + : chat!.historicalData!.start, + ), + ); + + ref + .read(RoomsController.provider.notifier) + .storeEvents( + paginationResponse.events.addAll( + paginationResponse.relatedEvents, + ), + ); + + final rootRowId = direction == .backward + ? await threadRootBefore(paginationResponse) + : null; + final eventRowIds = [ + ?rootRowId, + ...(direction == .forward + ? paginationResponse.events + : paginationResponse.events.reversed) + .where((event) => event.threadRoot == info.threadRoot) + .map((element) => element.rowId), + ].lock; + + state = .data( + .new( + timeline: direction == .forward + ? chat.timeline.addAll(eventRowIds) + : eventRowIds.addAll(chat.timeline), + hasMoreForward: + direction == .forward && paginationResponse.nextBatch == null + ? false + : chat.hasMoreForward, + hasMoreBackward: + direction == .backward && paginationResponse.nextBatch == null + ? false + : chat.hasMoreBackward, + historicalData: chat.historicalData?.copyWith( + start: + (direction == .backward + ? paginationResponse.nextBatch + : null) ?? + chat.historicalData!.start, + end: + (direction == .forward + ? paginationResponse.nextBatch + : null) ?? + chat.historicalData!.end, + ), + ), + ); + } + } catch (_) { + state = .data(chat); + rethrow; + } + } + + Future send( + String text, { + bool shouldMention = true, + required IList tags, + required RelationType relationType, + int? relatedEventRowId, + }) async { + final events = ref.read(RoomsController.provider)[info.roomId]?.events; + final relatedEvent = relatedEventRowId == null + ? null + : events?[relatedEventRowId]; + + final ComposerParams composer = ( + roomId: info.roomId, + threadRoot: info.threadRoot, + ); + final attachmentProvider = AttachmentController.provider(( + composer: composer, + editingRowId: relationType == .edit ? relatedEventRowId : null, + )); + final attachment = ref.read(attachmentProvider)?.$2; + ref.invalidate(attachmentProvider); + + // Edits of media use the attachment, which starts as the current media + final latestContent = relatedEvent == null + ? null + : (events?[relatedEvent.lastEditRowId] ?? relatedEvent).content; + final baseContent = + attachment ?? + (relationType == .edit && latestContent?.isMedia == false + ? latestContent + : null); + + // Replaces each formatted tag, like ":id#name#", with its ID + final taggedMessage = tags.fold( + text, + (message, tag) => message.replaceAllMapped( + RegExp( + "${RegExp.escape(tag.triggerCharacter)}(${RegExp.escape(tag.id)})(#[^#]*#)?", + ), + (match) => match.group(1)!, + ), + ); + + final client = ref.read(ClientController.provider.notifier); + final event = await client.sendMessage( + SendMessageRequest( + roomId: info.roomId, + baseContent: baseContent, + mentions: Mentions( + userIds: [ + if (shouldMention && relatedEvent != null && relationType == .reply) + relatedEvent.sender, + ].toIList(), + room: taggedMessage.contains("@room"), + ), + text: taggedMessage, + relation: relationType == .edit && relatedEvent != null + ? .new(eventId: relatedEvent.eventId, relationType: .edit) + : replyRelation(relatedEvent), + ), + ); + + addSent(event); + } + + /// Shows [event], which was just sent, until it comes down sync. + void addSent(Event? event) { + if (event case final event?) { + ref + .read(RoomsController.provider.notifier) + .update( + .new({ + info.roomId: .new( + events: .new({event.rowId: event}), + clientSticky: .new({event.rowId}), + ), + }), + ); + } + } + + Future removeReaction( + String reaction, + Event event, + String userId, + ) async { + final client = ref.read(ClientController.provider.notifier); + final allReactionEvents = await client.getRelatedEvents( + .new( + roomId: info.roomId, + eventId: event.eventId, + relationType: "m.annotation", + ), + ); + + final reactionEvent = allReactionEvents?.firstWhereOrNull( + (event) => + event.redactedBy == null && + switch (event.content) { + ReactionContent(:final key) => + key == reaction && event.sender == userId, + _ => false, + }, + ); + + if (reactionEvent != null) { + await client.redactEvent( + .new(eventId: reactionEvent.eventId, roomId: info.roomId), + ); + } + } + + /// Reacts to [event] with [reaction], which is an mxc URL for custom emoji, + /// with their [shortcode]. + Future sendReaction( + String reaction, + Event event, { + String? shortcode, + }) async { + final client = ref.read(ClientController.provider.notifier); + + await client.sendEvent( + .new( + roomId: info.roomId, + type: EventType.reaction.type, + // Wrapped in colons, like other clients send it + content: ReactionContent( + key: reaction, + shortcode: shortcode == null ? null : ":$shortcode:", + ), + synchronous: true, + disableEncryption: true, + relatesTo: event.eventId, + relationType: "m.annotation", + ), + ); + } + + /// Sends [content], as a reply to [repliedEventRowId] if set. + Future sendWithReply( + EventType type, + Content content, { + bool shouldMention = true, + int? repliedEventRowId, + }) async { + final repliedEvent = repliedEventRowId == null + ? null + : ref.read( + RoomsController.provider.select( + (rooms) => rooms[info.roomId]?.events[repliedEventRowId], + ), + ); + + final event = await ref + .read(ClientController.provider.notifier) + .sendEvent( + .new( + roomId: info.roomId, + type: type.type, + content: content, + mentions: Mentions( + userIds: [ + if (shouldMention && repliedEvent != null) repliedEvent.sender, + ].toIList(), + ), + reply: replyRelation(repliedEvent), + ), + ); + + addSent(event); + } + + /// Sends [image] as a sticker, as a reply to [repliedEventRowId] if set. + Future sendSticker( + String shortcode, + PackImage image, { + bool shouldMention = true, + int? repliedEventRowId, + }) => sendWithReply( + .sticker, + StickerContent( + body: image.body ?? shortcode, + info: image.info ?? const .new(), + url: image.url, + ), + shouldMention: shouldMention, + repliedEventRowId: repliedEventRowId, + ); + + Future sendPoll( + PollStart poll, { + bool shouldMention = true, + int? repliedEventRowId, + }) => sendWithReply( + .pollStart, + PollStartContent(poll: poll), + shouldMention: shouldMention, + repliedEventRowId: repliedEventRowId, + ); + + Future sendLocation( + LocationMessageContent location, { + bool shouldMention = true, + int? repliedEventRowId, + }) => sendWithReply( + .message, + location, + shouldMention: shouldMention, + repliedEventRowId: repliedEventRowId, + ); + + Future sendBotCommand( + BotCommandDraft draft, { + int? repliedEventRowId, + }) async { + final repliedEvent = repliedEventRowId == null + ? null + : ref + .read(RoomsController.provider)[info.roomId] + ?.events[repliedEventRowId]; + + final event = await ref + .read(ClientController.provider.notifier) + .sendMessage( + .new( + roomId: info.roomId, + text: "", + baseContent: BotCommandMessageContent( + body: draft.body, + command: draft.input, + ), + mentions: .new(userIds: [draft.command.sender].lock), + relation: replyRelation(repliedEvent), + ), + ); + + addSent(event); + } + + /// Completes once the event has come down sync. + Future sendPollReference( + Event poll, + EventType type, + Content content, + ) async { + // The end event lands in the timeline and rebuilds this, so no ref after awaiting + final rooms = ref.read(RoomsController.provider.notifier); + final event = await ref + .read(ClientController.provider.notifier) + .sendEvent( + .new( + roomId: info.roomId, + type: type.type, + content: content, + relatesTo: poll.eventId, + relationType: "m.reference", + synchronous: true, + ), + ); + + if (event.sendFailed) throw Exception(event.sendError); + + await rooms.awaitEvent(info.roomId, event.eventId); + } + + /// An empty [answers] removes the vote. + Future votePoll(Event poll, IList answers) => sendPollReference( + poll, + .pollResponse, + PollResponseContent(answers: answers), + ); + + Future endPoll(Event poll) => + sendPollReference(poll, .pollEnd, const PollEndContent()); + + static final provider = AsyncNotifierProvider.family + .autoDispose( + RoomChatController.new, + ); +} diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart deleted file mode 100644 index 4a4dba2..0000000 --- a/lib/controllers/room_chat_controller.dart +++ /dev/null @@ -1,304 +0,0 @@ -import "dart:async"; - -import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart" as chat; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:fluttertagger/fluttertagger.dart"; -import "package:nexus/controllers/client_controller.dart"; -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/message_config.dart"; -import "package:nexus/models/messages_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 { - final String roomId; - RoomChatController(this.roomId); - - @override - Future build() async { - final client = ref.watch(ClientController.provider.notifier); - var room = ref.read(RoomsController.provider)[roomId]; - if (room == null) return InMemoryChatController(); - - final state = await client.getRoomState( - GetRoomStateRequest( - roomId: roomId, - fetchMembers: room.metadata?.hasMemberList == false, - includeMembers: true, - ), - ); - - ref - .read(RoomsController.provider.notifier) - .update( - { - roomId: Room( - events: state, - state: state.fold( - const IMap.empty(), - (previousValue, stateEvent) => previousValue.add( - stateEvent.type, - (previousValue[stateEvent.type] ?? const IMap.empty()).addAll( - IMap({ - if (stateEvent.stateKey != null) - stateEvent.stateKey!: stateEvent.rowId, - }), - ), - ), - ), - ), - }.toIMap(), - const ISet.empty(), - ); - - room = ref.read(RoomsController.provider)[roomId]; - if (room == null) return InMemoryChatController(); - - final messages = await ref.watch( - MessagesController.provider( - MessagesConfig( - room: room, - events: room.timeline - .map( - (timelineRowTuple) => room!.events.firstWhereOrNull( - (event) => event.rowId == timelineRowTuple.eventRowId, - ), - ) - .nonNulls - .toIList(), - ), - ).future, - ); - final controller = InMemoryChatController(messages: messages.toList()); - - ref.onDispose( - ref.listen(NewEventsController.provider(roomId), (_, next) async { - 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); - } - - return controller; - } - - Future insertMessage(Message message) async { - final controller = await future; - final oldMessage = message.metadata?["txnId"] == null - ? null - : controller.messages.firstWhereOrNull( - (element) => - element.metadata?["txnId"] == message.metadata?["txnId"], - ); - - return oldMessage == null - ? controller.insertMessage(message) - : controller.updateMessage(oldMessage, message); - } - - Future deleteMessage(Message message, {String? reason}) 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( - PaginateRequest( - roomId: roomId, - maxTimelineId: ref - .read(RoomsController.provider)[roomId] - ?.timeline - .firstOrNull - ?.timelineRowId, - ), - ); - - ref - .watch(RoomsController.provider.notifier) - .update( - IMap({ - roomId: Room( - events: response.events.addAll(response.relatedEvents), - hasMore: response.hasMore, - timeline: response.events - .map( - (event) => TimelineRowTuple( - timelineRowId: event.timelineRowId, - eventRowId: event.rowId, - ), - ) - .toIList(), - ), - }), - const ISet.empty(), - ); - - 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 message, { - bool shouldMention = true, - required Iterable tags, - required RelationType relationType, - Message? relation, - }) async { - var taggedMessage = message; - - for (final tag in tags) { - final escaped = RegExp.escape(tag.id); - final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?"); - - taggedMessage = taggedMessage.replaceAllMapped( - pattern, - (match) => match.group(1)!, - ); - } - - final client = ref.watch(ClientController.provider.notifier); - client.sendMessage( - SendMessageRequest( - roomId: roomId, - mentions: Mentions( - userIds: [ - if (shouldMention == true && - relation != null && - relationType == RelationType.reply) - relation.authorId, - ].toIList(), - room: taggedMessage.contains("@room"), - ), - text: taggedMessage, - relation: relation == null - ? null - : Relation(eventId: relation.id, relationType: relationType), - ), - ); - } - - Future resolveUser(String id) async { - final user = await ref - .watch(ClientController.provider.notifier) - .getProfile(id); - return chat.User( - id: id, - name: user.displayName, - // imageSource: user.avatarUrl == null - // ? null - // : (await ref.watch( - // AvatarController.provider(user.avatarUrl!.toString()).future, - // )).toString(), - ); - } - - Future scrollToMessage(Message message) async { - 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( - RoomChatController.new, - ); -} diff --git a/lib/controllers/room_directory.dart b/lib/controllers/room_directory.dart new file mode 100644 index 0000000..8d6e498 --- /dev/null +++ b/lib/controllers/room_directory.dart @@ -0,0 +1,39 @@ +import "package:flutter_riverpod/experimental/mutation.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/room_directory.dart"; + +/// The public room directory of a server, or of the user's own if null. +class RoomDirectoryController(final String? server) + extends AsyncNotifier { + @override + Future build() => ref + .read(ClientController.provider.notifier) + .getRoomDirectory(.new(server: server)); + + Future loadMore() async { + final current = await future; + if (current.nextBatch == null) return; + + final next = await ref + .read(ClientController.provider.notifier) + .getRoomDirectory(.new(server: server, since: current.nextBatch)); + + if (!ref.mounted) return; + + state = AsyncData( + current.copyWith( + rooms: current.rooms.addAll(next.rooms), + nextBatch: next.nextBatch, + ), + ); + } + + /// Loading the next page of a directory, keyed by its server + static final loadMoreMutation = Mutation(); + + static final provider = AsyncNotifierProvider.family + .autoDispose( + RoomDirectoryController.new, + ); +} diff --git a/lib/controllers/room_image_packs.dart b/lib/controllers/room_image_packs.dart new file mode 100644 index 0000000..318d388 --- /dev/null +++ b/lib/controllers/room_image_packs.dart @@ -0,0 +1,37 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/room_image_packs.dart"; +import "package:nexus/models/image_pack.dart"; + +/// The image packs in [roomId]'s own state by state key, loading its state if +/// it hasn't been yet. +class RoomImagePacksController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + if (ref.read(RoomsController.provider)[roomId] case final room? + when !room.hasFetchedState) { + ref + .read(RoomsController.provider.notifier) + .addState( + roomId, + await ref + .read(ClientController.provider.notifier) + .getRoomState(.new(roomId: roomId)), + ); + } + + return ref.watch( + RoomsController.provider.select( + (rooms) => rooms[roomId]?.imagePacks(roomId) ?? const .empty(), + ), + ); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>( + RoomImagePacksController.new, + ); +} diff --git a/lib/controllers/room_power_levels.dart b/lib/controllers/room_power_levels.dart new file mode 100644 index 0000000..7001eec --- /dev/null +++ b/lib/controllers/room_power_levels.dart @@ -0,0 +1,59 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/room_version_at_least.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/room_power_levels.dart"; + +class RoomPowerLevelsController(final String roomId) + extends Notifier { + @override + RoomPowerLevels build() { + final room = ref.watch( + RoomsController.provider.select((rooms) => rooms[roomId]), + ); + + Event? stateEvent(EventType type) => switch (room?.state[type.type]?[""]) { + final rowId? => room?.events[rowId], + null => null, + }; + + final createEvent = stateEvent(.create); + final create = switch (createEvent?.content) { + final CreateContent content => content, + _ => null, + }; + // Creators only outrank everyone from room version 12, earlier ones just have a power level + final creatorsPrivileged = (create?.roomVersion).versionAtLeast(12); + + return .new( + content: switch (stateEvent(.powerLevels)?.content) { + final PowerLevelsContent content => content, + // Until there are power levels, creators have 100 + _ => PowerLevelsContent( + users: switch (createEvent) { + final createEvent? when !creatorsPrivileged => .new({ + createEvent.sender: 100, + }), + _ => const .empty(), + }, + ), + }, + creators: switch (createEvent) { + final createEvent? when creatorsPrivileged => + (create?.additionalCreatorIds ?? const .empty()) + .add(createEvent.sender) + .toISet(), + _ => const .empty(), + }, + ); + } + + static final provider = NotifierProvider.autoDispose + .family( + RoomPowerLevelsController.new, + ); +} diff --git a/lib/controllers/room_published.dart b/lib/controllers/room_published.dart new file mode 100644 index 0000000..b560575 --- /dev/null +++ b/lib/controllers/room_published.dart @@ -0,0 +1,21 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; + +/// Whether a room is published to the user's server's room directory. +class RoomPublishedController(final String roomId) extends AsyncNotifier { + @override + Future build() => + ref.read(ClientController.provider.notifier).getRoomPublished(roomId); + + Future set(bool published) async { + await ref + .read(ClientController.provider.notifier) + .setRoomPublished(roomId, published); + ref.invalidateSelf(); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose( + RoomPublishedController.new, + ); +} diff --git a/lib/controllers/room_settings_sections.dart b/lib/controllers/room_settings_sections.dart new file mode 100644 index 0000000..bababee --- /dev/null +++ b/lib/controllers/room_settings_sections.dart @@ -0,0 +1,1064 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/local_aliases.dart"; +import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/power_levels_saving.dart"; +import "package:nexus/controllers/room_image_packs.dart"; +import "package:nexus/controllers/room_power_levels.dart"; +import "package:nexus/controllers/room_published.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/space_edges.dart"; +import "package:nexus/helpers/extensions/join_rule_name.dart"; +import "package:nexus/helpers/extensions/room_version_at_least.dart"; +import "package:nexus/helpers/file_dialog_helper.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/main.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/encryption.dart"; +import "package:nexus/models/content/history_visibility.dart"; +import "package:nexus/models/content/image_pack.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/power_levels.dart"; +import "package:nexus/models/content/tombstone.dart"; +import "package:nexus/models/content/topic.dart"; +import "package:nexus/models/join_rule.dart"; +import "package:nexus/models/power_level_permission.dart"; +import "package:nexus/models/setting.dart"; +import "package:nexus/models/settings_category.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/common/divider_text.dart"; +import "package:nexus/widgets/avatars/image_pack_avatar.dart"; +import "package:nexus/widgets/settings/add_users_power_level_sheet.dart"; +import "package:nexus/widgets/settings/aliases_sheet.dart"; +import "package:nexus/widgets/settings/allowed_rooms_sheet.dart"; +import "package:nexus/widgets/settings/dialog_list_tile.dart"; +import "package:nexus/widgets/settings/image_pack_sheet.dart"; +import "package:nexus/widgets/settings/local_aliases_sheet.dart"; +import "package:nexus/widgets/settings/power_level_entry_sheet.dart"; +import "package:nexus/widgets/settings/power_level_tile.dart"; +import "package:nexus/widgets/settings/text_setting_tile.dart"; +import "package:nexus/widgets/settings/upgrade_room_sheet.dart"; + +class RoomSettingsSectionsController(final String roomId) + extends AsyncNotifier>> { + @override + Future>> build() async { + final (:state, :metadataTopic, :metadataAvatar) = ref.watch( + RoomsController.provider.select((rooms) { + final room = rooms[roomId]; + return ( + state: room?.state ?? const IMap.empty(), + metadataTopic: room?.metadata?.topic, + metadataAvatar: room?.metadata?.avatar, + ); + }), + ); + + Content? contentOf(int rowId) => + ref.read(RoomsController.provider)[roomId]?.events[rowId]?.content; + final client = ref.read(ClientController.provider.notifier); + final roomsController = ref.read(RoomsController.provider.notifier); + + // The room's own state, rather than metadata like a name made up from + // its members when it has none + Content? stateContent(EventType type) => switch (state[type.type]?[""]) { + final rowId? => contentOf(rowId), + null => null, + }; + final name = switch (stateContent(.name)) { + NameContent(:final name) => name, + _ => null, + }; + final topic = switch (stateContent(.topic)) { + TopicContent(:final topic) => topic, + _ => metadataTopic, + }; + final avatar = switch (stateContent(.avatar)) { + AvatarContent(:final url) => url, + _ => metadataAvatar, + }; + + bool canSet(EventType type) => ref.watch( + PowerLevelController.provider(.state(eventType: type, roomId: roomId)), + ); + + final canSetAvatar = canSet(.avatar); + final canSetName = canSet(.name); + final canSetTopic = canSet(.topic); + final canSetJoinRules = canSet(.joinRules); + final canSetHistoryVisibility = canSet(.historyVisibility); + final canSetAliases = canSet(.canonicalAlias); + final canCreatePacks = canSet(.imagePack); + final canSetPowerLevels = + canSet(.powerLevels) && + !ref.watch(PowerLevelsSavingController.provider(roomId)); + + final joinRules = switch (stateContent(.joinRules)) { + final JoinRulesContent content => content, + _ => JoinRulesContent(joinRule: .invite), + }; + final historyVisibility = switch (stateContent(.historyVisibility)) { + HistoryVisibilityContent(:final historyVisibility) => historyVisibility, + _ => HistoryVisibility.shared, + }; + final canonicalAlias = switch (stateContent(.canonicalAlias)) { + final CanonicalAliasContent content => content, + _ => CanonicalAliasContent(), + }; + final published = canonicalAlias.altAliases.addAll([?canonicalAlias.alias]); + final localAliases = + ref.watch(LocalAliasesController.provider(roomId)).value ?? + const IList.empty(); + + final knownAliases = localAliases.addAll(canonicalAlias.altAliases).addAll([ + ?canonicalAlias.alias, + ]).toISet(); + + final versionName = switch (stateContent(.create)) { + CreateContent(:final roomVersion) => roomVersion, + _ => null, + }; + final canUpgrade = + canSet(.tombstone) && stateContent(.tombstone) is! TombstoneContent; + final joinRuleOptions = [ + JoinRule.public, + JoinRule.invite, + if (versionName.versionAtLeast(7)) JoinRule.knock, + if (versionName.versionAtLeast(8)) JoinRule.restricted, + if (versionName.versionAtLeast(10)) JoinRule.knockRestricted, + ]; + bool isRestricted(JoinRule rule) => + rule == .restricted || rule == .knockRestricted; + + final parentSpaces = ref + .watch( + SpaceEdgesController.provider.select( + (edges) => edges.entries + .where( + (entry) => entry.value.any((edge) => edge.childId == roomId), + ) + .map((entry) => entry.key) + .toIList(), + ), + ) + .addAll(state["m.space.parent"]?.keys ?? const []) + .toISet(); + final allowedRooms = joinRules.allow + .map((condition) => condition.roomId) + .nonNulls + .toISet(); + + final roomPowerLevels = ref.watch( + RoomPowerLevelsController.provider(roomId), + ); + final powerLevels = roomPowerLevels.content; + final userId = ref.watch( + ClientStateController.provider.select((state) => state?.userId), + ); + final ownLevel = userId == null ? 0.0 : roomPowerLevels.levelOf(userId); + final encrypted = stateContent(.encryption) is EncryptionContent; + final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted); + + Future setState( + EventType type, + Content content, { + String stateKey = "", + }) async { + final eventId = await client.setState( + .new( + roomId: roomId, + type: type.type, + stateKey: stateKey, + content: content, + ), + ); + if (eventId case final eventId?) { + await roomsController.awaitState( + roomId, + type.type, + eventId, + stateKey: stateKey, + ); + } + } + + final imagePacks = + ref.watch(RoomImagePacksController.provider(roomId)).value ?? + const .empty(); + + // Pack sheets can stay open while the state changes, so these read it when + // called, rather than using the state from building + IMap> currentState() => + ref.read(RoomsController.provider)[roomId]?.state ?? const .empty(); + + // Whether the legacy type still has a pack at [stateKey], to clear it + bool hasLegacyPack(String stateKey) => + switch (currentState()[EventType.legacyImagePack.type]?[stateKey]) { + final rowId? => switch (contentOf(rowId)) { + final ImagePackContent content => content.hasContent, + _ => false, + }, + null => false, + }; + final canSetLegacyPacks = canSet(.legacyImagePack); + + /// Saves the pack at [stateKey] as the stable type, migrating it by + /// clearing its legacy event if it has one. + Future setPack(String stateKey, Content content) async { + await setState(.imagePack, content, stateKey: stateKey); + if (hasLegacyPack(stateKey)) { + await setState(.legacyImagePack, const .new(), stateKey: stateKey); + } + } + + /// A state key for a new pack named [name]. It's one no pack has ever + /// had, even deleted ones, as users may still have those enabled. + String newPackKey(String name) { + final state = currentState(); + final usedKeys = [ + EventType.imagePack, + EventType.legacyImagePack, + ].expand((type) => state[type.type]?.keys ?? []).toISet(); + final slug = name + .toLowerCase() + .replaceAll(RegExp(r"[^\p{L}\p{N}_-]+", unicode: true), "_") + .replaceAll(RegExp(r"^_+|_+$"), ""); + final base = slug.isEmpty ? "pack" : slug; + + return Iterable.generate( + usedKeys.length + 1, + (index) => index == 0 ? base : "${base}_${index + 1}", + ).firstWhere((key) => !usedKeys.contains(key)); + } + + Future setAliases( + String? canonical, + ISet alternatives, + ) async { + final stale = (await Future.wait( + alternatives.addAll([?canonical]).map((alias) async { + try { + return await client.resolveAlias(alias) == roomId ? null : alias; + } catch (_) { + return null; + } + }), + )).nonNulls.toISet(); + bool kept(String alias) => !stale.contains(alias); + + await setState( + .canonicalAlias, + CanonicalAliasContent( + alias: canonical != null && kept(canonical) ? canonical : null, + altAliases: alternatives.where(kept).toISet(), + ), + ); + } + + Future setAllowedRooms(ISet roomIds) => setState( + .joinRules, + joinRules.copyWith( + allow: roomIds + .map((roomId) => AllowCondition(roomId: roomId, type: .membership)) + .toISet(), + ), + ); + + Setting header(String title, String description, IconData icon) => .new( + title: title, + description: description, + icon: icon, + builder: (title, _, _) => Padding( + padding: .symmetric(horizontal: 16), + child: DividerText(title), + ), + ); + + Future setPowerLevels(PowerLevelsContent content) => ref + .read(PowerLevelsSavingController.provider(roomId).notifier) + .run(() => setState(.powerLevels, content).onError(showError)); + + // Event types with their own level that don't have a setting of their own + final listedEvents = shownEvents + .addAll(PowerLevelGroup.values.expand((group) => group.events)) + .map((event) => event.type.type) + .toISet(); + final otherEvents = powerLevels.events.entries + .where((entry) => !listedEvents.contains(entry.key)) + .toIList() + .sort((a, b) => a.key.compareTo(b.key)); + + // Users with their own level, the highest first + final users = powerLevels.users.entries.toIList().sort( + (a, b) => switch (b.value.compareTo(a.value)) { + 0 => a.key.compareTo(b.key), + final order => order, + }, + ); + MembershipContent? memberOf(String userId) => + switch (state[EventType.membership.type]?[userId]) { + final rowId? => switch (contentOf(rowId)) { + final MembershipContent content => content, + _ => null, + }, + null => null, + }; + + /// A tile opening a [sheet] to add entries, like event types or users, with their own power level. + Setting addSetting({ + required String title, + required String description, + required Widget Function(void Function(PowerLevelsContent content) save) + sheet, + }) => .new( + title: title, + description: description, + icon: Icons.add, + builder: (title, description, icon) => Builder( + builder: (context) => ListTile( + enabled: canSetPowerLevels, + leading: Icon(icon), + title: Text(title), + subtitle: Text(description), + onTap: () => context.showAdaptiveSheet((_) => sheet(setPowerLevels)), + ), + ), + ); + + Setting powerLevelSetting({ + required String title, + required String description, + required IconData icon, + required int value, + required PowerLevelsContent Function(int level) update, + }) => .new( + title: title, + description: description, + icon: icon, + builder: (title, description, icon) => PowerLevelTile( + leading: Icon(icon), + title: title, + description: description, + value: value, + ownLevel: ownLevel, + onChanged: canSetPowerLevels + ? (level) => setPowerLevels(update(level)) + : null, + ), + ); + + Setting permissionSetting(PowerLevelPermission permission) => + powerLevelSetting( + title: permission.title, + description: permission.description, + icon: permission.icon, + value: permission.levelIn(powerLevels), + update: (level) => permission.withLevel(powerLevels, level), + ); + + /// A setting for the level needed to send [event], which otherwise needs [fallback]. + Setting eventSetting(PowerLevelEvent event, {required int fallback}) => + powerLevelSetting( + title: event.title, + description: event.description, + icon: event.icon, + value: powerLevels.events[event.type.type] ?? fallback, + update: (level) => powerLevels.copyWith( + events: powerLevels.events.add(event.type.type, level), + ), + ); + + Widget tileOf(Setting setting) => + setting.builder(setting.title, setting.description, setting.icon); + + /// A setting for all of [group]'s levels at once, which expands to set each. + Setting groupSetting(PowerLevelGroup group) => .new( + title: group.title, + description: group.description, + icon: group.icon, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final expanded = useState(false); + + return Column( + mainAxisSize: .min, + children: [ + PowerLevelTile( + leading: Icon(icon), + title: title, + description: description, + value: group.levelIn(powerLevels), + ownLevel: ownLevel, + // Changing it would change levels above the user's own + onChanged: + canSetPowerLevels && + group.highestIn(powerLevels) <= ownLevel + ? (level) => + setPowerLevels(group.withLevel(powerLevels, level)) + : null, + trailing: IconButton( + tooltip: expanded.value ? "Collapse" : "Set each", + icon: AnimatedRotation( + turns: expanded.value ? 0.5 : 0, + duration: Durations.medium2, + child: Icon(Icons.expand_more), + ), + onPressed: () => expanded.value = !expanded.value, + ), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: expanded.value + ? Padding( + padding: .only(left: 16), + child: Column( + mainAxisSize: .min, + children: [ + ...group.permissions + .map(permissionSetting) + .map(tileOf), + ...group.events + .map( + (event) => eventSetting( + event, + fallback: powerLevels.stateDefault, + ), + ) + .map(tileOf), + ], + ), + ) + : const SizedBox(width: .infinity), + ), + ], + ); + }, + ), + ); + + return .new({ + "Room": .new([ + .new( + title: "General", + icon: Icons.tune, + settings: .new([ + .new( + title: "Avatar", + description: "The image shown for the room.", + icon: Icons.image, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final saving = Saving.use(); + Future save(Future Function() action) => + saving.run(() => action().onError(showError)); + + return ListTile( + enabled: canSetAvatar && !saving.active, + leading: Icon(icon), + title: Text(title), + subtitle: Text(description), + trailing: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + AvatarOrHash( + avatar, + name ?? "Unnamed Room", + dimension: 40, + fallback: Icon(Icons.numbers), + ), + if (avatar != null && canSetAvatar) + IconButton( + tooltip: "Remove avatar", + icon: Icon(Icons.delete), + onPressed: saving.active + ? null + : () => save( + () => setState(.avatar, const .new()), + ), + ), + ], + ), + onTap: () async { + final file = await ProviderScope.containerOf(context) + .read(FileDialogHelper.provider) + .pickFile(images: true); + if (file == null || !context.mounted) return; + + await save(() async { + final content = await client.uploadMedia( + .new(path: file.path, encrypt: false), + ); + if (content case ImageMessageContent( + :final url, + :final info, + )) { + await setState( + .avatar, + AvatarContent(url: url, info: info), + ); + } else { + throw Exception("The file isn't an image"); + } + }); + }, + ); + }, + ), + ), + .new( + title: "Room Name", + description: "The name shown for the room.", + icon: Icons.title, + builder: (title, description, icon) => TextSettingTile( + icon: Icon(icon), + title: title, + description: description, + value: name, + onChanged: canSetName + ? (value) => setState( + .name, + NameContent(name: value), + ).onError(showError) + : null, + ), + ), + .new( + title: "Topic", + description: "A short description of the room.", + icon: Icons.info, + builder: (title, description, icon) => TextSettingTile( + icon: Icon(icon), + title: title, + description: description, + value: topic, + multiline: true, + onChanged: canSetTopic + ? (value) => setState( + .topic, + TopicContent( + topic: value, + content: .new( + representations: .new([.new(body: value)]), + ), + ), + ).onError(showError) + : null, + ), + ), + .new( + title: "Publish to Room Directory", + description: "List the room in your homeserver's directory.", + icon: Icons.travel_explore, + builder: (title, description, icon) => HookConsumer( + builder: (context, ref, _) { + final saving = Saving.use(); + final published = ref.watch( + RoomPublishedController.provider(roomId), + ); + + return SwitchListTile( + secondary: Icon(icon), + title: Text(title), + subtitle: Text(description), + value: published.value ?? false, + onChanged: + canSetAliases && published.hasValue && !saving.active + ? (value) => saving.run( + () => ref + .read( + RoomPublishedController.provider(roomId) + .notifier, + ) + .set(value) + .onError(showError), + ) + : null, + ); + }, + ), + ), + .new( + title: "Room Version", + description: "The rules the room follows, set when it's created.", + icon: Icons.upgrade, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final saving = Saving.use(); + + return ListTile( + leading: Icon(icon), + title: Text(title), + subtitle: Text( + versionName == null + ? description + : "Version $versionName", + ), + trailing: TextButton( + onPressed: + canUpgrade && versionName != null && !saving.active + ? () => context.showAdaptiveSheet( + (_) => UpgradeRoomSheet( + roomId: roomId, + currentVersion: versionName, + creators: roomPowerLevels.creators.remove( + userId ?? "", + ), + userId: userId, + onSave: (version, creators) => saving.run( + () => client + .upgradeRoom( + roomId, + version, + additionalCreators: creators, + ) + .then((_) {}) + .onError(showError), + ), + ), + ) + : null, + child: Text("Upgrade"), + ), + ); + }, + ), + ), + ]), + ), + .new( + title: "Access", + icon: Icons.key, + settings: .new([ + .new( + title: "Join Rule", + description: "Who can join the room.", + icon: Icons.login, + builder: (title, description, icon) => DialogListTile( + icon: Icon(icon), + title: title, + subtitle: Text(description), + value: joinRules.joinRule, + options: joinRuleOptions, + getName: (rule) => rule.displayName, + onChanged: canSetJoinRules + ? (rule) => setState( + .joinRules, + JoinRulesContent( + joinRule: rule, + allow: isRestricted(rule) && joinRules.allow.isEmpty + ? .new( + parentSpaces.map( + (roomId) => + .new(roomId: roomId, type: .membership), + ), + ) + : joinRules.allow, + ), + ).onError(showError) + : null, + ), + ), + if (isRestricted(joinRules.joinRule)) + .new( + title: "Allowed Rooms", + description: + "Members of these rooms can join without an invite.", + icon: Icons.meeting_room, + builder: (title, description, icon) => HookConsumer( + builder: (context, ref, _) { + final saving = Saving.use(); + final names = ref.watch( + RoomsController.provider.select( + (rooms) => allowedRooms + .map( + (roomId) => + rooms[roomId]?.metadata?.name ?? roomId, + ) + .toIList(), + ), + ); + + return ListTile( + enabled: canSetJoinRules && !saving.active, + leading: Icon(icon), + title: Text(title), + subtitle: Text( + names.isEmpty + ? "None, so only invited users can join" + : names.join(", "), + maxLines: 2, + overflow: .ellipsis, + ), + onTap: () => context.showAdaptiveSheet( + (_) => AllowedRoomsSheet( + roomId: roomId, + allowed: allowedRooms, + parentSpaces: parentSpaces, + onSave: (allowed) => saving.run( + () => setAllowedRooms(allowed).onError(showError), + ), + ), + ), + ); + }, + ), + ), + .new( + title: "Aliases", + description: + "The addresses the room advertises, and its main one.", + icon: Icons.alternate_email, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: canSetAliases && !saving.active, + leading: Icon(icon), + title: Text(title), + subtitle: Text( + switch (( + canonicalAlias.alias, + canonicalAlias.altAliases.length, + )) { + (null, 0) => description, + (final alias?, 0) => alias, + (final alias?, 1) => + "$alias and ${canonicalAlias.altAliases.first}", + (final alias?, final others) => + "$alias and $others more", + (null, _) => canonicalAlias.altAliases.join(", "), + }, + maxLines: 2, + overflow: .ellipsis, + ), + onTap: () => context.showAdaptiveSheet( + (_) => AliasesSheet( + aliases: knownAliases, + canonical: canonicalAlias.alias, + alternatives: canonicalAlias.altAliases, + onSave: (canonical, alternatives) => saving.run( + () => setAliases( + canonical, + alternatives, + ).onError(showError), + ), + ), + ), + ); + }, + ), + ), + .new( + title: "Local Aliases", + description: "Addresses for the room on your server.", + icon: Icons.dns, + builder: (title, description, icon) => Builder( + builder: (context) => ListTile( + leading: Icon(icon), + title: Text(title), + subtitle: Text( + localAliases.isEmpty + ? description + : localAliases.join(", "), + maxLines: 2, + overflow: .ellipsis, + ), + onTap: () => context.showAdaptiveSheet( + (_) => LocalAliasesSheet( + roomId, + onRemoved: canSetAliases + ? (alias) async { + if (published.contains(alias)) { + await setAliases( + canonicalAlias.alias, + canonicalAlias.altAliases, + ); + } + } + : null, + ), + ), + ), + ), + ), + .new( + title: "History Visibility", + description: + "Which messages from before joining members can read.", + icon: Icons.history, + builder: (title, description, icon) => + DialogListTile( + icon: Icon(icon), + title: title, + subtitle: Text(description), + value: historyVisibility, + options: [.shared, .invited, .joined], + getName: (visibility) => switch (visibility) { + .shared => "All events", + .invited => "After invite", + .joined => "After join", + .worldReadable => "Anyone", + .unknown => "Unknown", + }, + onChanged: canSetHistoryVisibility + ? (visibility) => setState( + .historyVisibility, + HistoryVisibilityContent( + historyVisibility: visibility, + ), + ).onError(showError) + : null, + ), + ), + ]), + ), + .new( + title: "Permissions", + icon: Icons.admin_panel_settings, + settings: .new([ + permissionSetting(.usersDefault), + header("Users", "Power levels of specific users.", Icons.group), + ...users.map((entry) { + final member = memberOf(entry.key); + // Others can only be changed if they're below you, but your own level can be lowered + final canChange = + canSetPowerLevels && + (entry.key == userId || entry.value < ownLevel); + + return Setting( + title: member?.displayName ?? entry.key, + description: entry.key, + icon: Icons.person, + builder: (title, description, _) => PowerLevelTile( + leading: AvatarOrHash( + member?.avatarUrl, + title, + dimension: 40, + ), + title: title, + description: description, + value: entry.value, + ownLevel: ownLevel, + onChanged: canChange + ? (level) => setPowerLevels( + powerLevels.copyWith( + users: powerLevels.users.add(entry.key, level), + ), + ) + : null, + onRemoved: canChange + ? () => setPowerLevels( + powerLevels.copyWith( + users: powerLevels.users.remove(entry.key), + ), + ) + : null, + removeConfirmation: entry.key == userId + ? "Remove your own power level? You'll have the default of ${powerLevels.usersDefault}, and won't be able to raise it again yourself." + : "Remove $title's power level? They'll have the default of ${powerLevels.usersDefault}.", + ), + ); + }), + addSetting( + title: "Add Users", + description: "Give users their own power level.", + sheet: (save) => AddUsersPowerLevelSheet( + roomId: roomId, + existing: powerLevels.users.keys.toISet().union( + roomPowerLevels.creators, + ), + ownLevel: ownLevel, + onSave: (users, level) => save( + powerLevels.copyWith( + users: powerLevels.users.addEntries( + users.map((user) => MapEntry(user, level)), + ), + ), + ), + ), + ), + header( + "Groups", + "Power levels set together, which can each be set on their own.", + Icons.workspaces, + ), + ...PowerLevelGroup.values.map(groupSetting), + header( + "Regular Events", + "Power levels needed to send messages and other timeline events.", + Icons.chat, + ), + ...shownEvents + .where((event) => event != .pinnedEvents) + .map( + (event) => + eventSetting(event, fallback: powerLevels.eventsDefault), + ), + permissionSetting(.eventsDefault), + header( + "State Events", + "Power levels needed to change the room's state.", + Icons.settings, + ), + permissionSetting(.invite), + permissionSetting(.stateDefault), + header( + "Other Events", + "Power levels for specific event types.", + Icons.code, + ), + ...otherEvents.map( + (entry) => Setting( + title: entry.key, + description: "The power level needed to send this event.", + icon: Icons.code, + builder: (title, _, icon) => PowerLevelTile( + leading: Icon(icon), + title: title, + description: null, + value: entry.value, + ownLevel: ownLevel, + onChanged: canSetPowerLevels + ? (level) => setPowerLevels( + powerLevels.copyWith( + events: powerLevels.events.add(entry.key, level), + ), + ) + : null, + onRemoved: canSetPowerLevels + ? () => setPowerLevels( + powerLevels.copyWith( + events: powerLevels.events.remove(entry.key), + ), + ) + : null, + ), + ), + ), + addSetting( + title: "Add Event", + description: "Give an event type its own power level.", + sheet: (save) => PowerLevelEntrySheet( + title: "Add Event", + keyLabel: "Event type", + keyHint: "m.room.name", + ownLevel: ownLevel, + onSave: (type, level) => save( + powerLevels.copyWith( + events: powerLevels.events.add(type, level), + ), + ), + ), + ), + ]), + ), + .new( + title: "Image Packs", + icon: Icons.emoji_emotions, + settings: .new([ + ...imagePacks.entries.map((entry) { + final stateKey = entry.key; + final pack = entry.value; + final canEdit = + canCreatePacks && + (canSetLegacyPacks || !hasLegacyPack(stateKey)); + + return Setting( + title: pack.name, + description: pack.imageCountLabel, + icon: Icons.emoji_emotions, + builder: (title, description, _) => HookBuilder( + key: ValueKey(stateKey), + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: !saving.active, + leading: ImagePackAvatar(pack.icon), + title: Text(title), + subtitle: Text(description), + onTap: () => context.showAdaptiveSheet( + (_) => ImagePackSheet( + title: canEdit ? "Edit Image Pack" : title, + content: pack.content, + onSave: canEdit + ? (content) => saving.run( + () => setPack( + stateKey, + content, + ).onError(showError), + ) + : null, + onDeleted: canEdit + ? () => saving.run( + () => setPack( + stateKey, + const .new(), + ).onError(showError), + ) + : null, + ), + ), + ); + }, + ), + ); + }), + .new( + title: "Create Pack", + description: "Add a pack of emoticons and stickers to the room.", + icon: Icons.add, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: canCreatePacks && !saving.active, + leading: Icon(icon), + title: Text(title), + subtitle: Text(description), + onTap: () => context.showAdaptiveSheet( + (_) => ImagePackSheet( + title: "Create Pack", + content: const .new(), + onSave: (content) => saving.run( + () => setState( + .imagePack, + content, + stateKey: newPackKey(content.pack!.displayName!), + ).onError(showError), + ), + ), + ), + ); + }, + ), + ), + ]), + ), + ]), + }); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose< + RoomSettingsSectionsController, + IMap>, + String + >(RoomSettingsSectionsController.new); +} diff --git a/lib/controllers/room_summary.dart b/lib/controllers/room_summary.dart new file mode 100644 index 0000000..e5b3174 --- /dev/null +++ b/lib/controllers/room_summary.dart @@ -0,0 +1,16 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/requests/join_room.dart"; +import "package:nexus/models/room_summary.dart"; + +class RoomSummaryController(final JoinRoomRequest request) + extends AsyncNotifier { + @override + Future build() => + ref.read(ClientController.provider.notifier).getRoomSummary(request); + + static final provider = AsyncNotifierProvider.family + .autoDispose( + RoomSummaryController.new, + ); +} diff --git a/lib/controllers/room_typing.dart b/lib/controllers/room_typing.dart new file mode 100644 index 0000000..887a8b2 --- /dev/null +++ b/lib/controllers/room_typing.dart @@ -0,0 +1,34 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/controllers/typing.dart"; + +/// The other users typing in a room, or none if typing indicators are hidden +/// in settings. +class RoomTypingController(final String roomId) + extends Notifier> { + @override + IList build() { + final shouldShow = ref.watch( + SettingsController.provider.select( + (settings) => settings.value?.typingIndicators != .neither, + ), + ); + if (!shouldShow) return const .empty(); + + final userId = ref.watch( + ClientStateController.provider.select((state) => state?.userId), + ); + + return ref + .watch(TypingController.provider.select((typing) => typing[roomId])) + ?.remove(userId ?? "") ?? + const .empty(); + } + + static final provider = NotifierProvider.family + .autoDispose, String>( + RoomTypingController.new, + ); +} diff --git a/lib/controllers/rooms.dart b/lib/controllers/rooms.dart new file mode 100644 index 0000000..66ffa3b --- /dev/null +++ b/lib/controllers/rooms.dart @@ -0,0 +1,243 @@ +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/models/event.dart"; +import "package:nexus/models/events_decrypted.dart"; +import "package:nexus/models/read_receipt.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/models/sync_data.dart"; + +class RoomsController extends Notifier> { + @override + IMap build() => .new(); + + void addState(String roomId, IList state, {bool isMembers = false}) => + update( + .new({ + roomId: stateRoom(state) + .copyWith(hasFetchedState: true, hasFetchedMembers: isMembers), + }), + .new(), + ); + + void addSpecificState(Iterable state) => update( + .new( + state + .groupListsBy((event) => event.roomId) + .map((roomId, roomState) => .new(roomId, stateRoom(roomState))), + ), + ); + + static Room stateRoom(Iterable state) => Room( + events: .fromEntries(state.map((event) => .new(event.rowId, event))), + state: .new( + state + .groupListsBy((event) => event.type) + .map( + (type, group) => .new( + type, + .fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))), + ), + ), + ), + ); + + static IMap> mergeReceipts( + IMap> existing, + IMap> incoming, + ) => incoming.isEmpty + ? existing + : existing.values.flattened + .followedBy(incoming.values.flattened) + .groupListsBy((receipt) => (receipt.userId, receipt.thread)) + .values + .map( + (receipts) => receipts.reduce( + (newest, receipt) => newest.timestamp.isAfter(receipt.timestamp) + ? newest + : receipt, + ), + ) + .groupListsBy((receipt) => receipt.eventId) + .map((eventId, receipts) => MapEntry(eventId, receipts.toIList())) + .toIMap(); + + /// Adds events to their rooms, without changing the rooms' timelines. + void storeEvents(Iterable events) => update( + .new( + events + .groupListsBy((event) => event.roomId) + .map( + (roomId, roomEvents) => .new( + roomId, + Room( + // ignore: unused_result - I am using it you donkey + events: .fromIterable( + roomEvents, + keyMapper: (event) => event.rowId, + ), + ), + ), + ), + ), + ); + + void handleSync(SyncData sync) { + if (sync.clearState) state = .new(); + update(sync.rooms, sync.leftRooms); + } + + void handleDecrypted(EventsDecrypted decrypted) { + final metadata = state[decrypted.roomId]?.metadata; + + update( + .new({ + decrypted.roomId: .new( + events: decrypted.events, + metadata: metadata?.copyWith( + previewEventRowID: + decrypted.previewEventRowID ?? metadata.previewEventRowID, + sortingTimestamp: + decrypted.sortingTimestamp ?? metadata.sortingTimestamp, + ), + ), + }), + ); + } + + static IMap> addRelations( + IMap> relations, + Iterable events, + ) => events.fold( + relations, + (acc, event) => switch (event.relatesTo) { + final relatesTo? => acc.update( + relatesTo, + (rowIds) => rowIds.add(event.rowId), + ifAbsent: () => .new([event.rowId]), + ), + null => acc, + }, + ); + + /// IDs replace previous ones for the same row, e.g. once a local echo is sent. + static IMap addEventIds( + IMap eventIds, + IMap existing, + Iterable events, + ) => events.fold(eventIds, (acc, event) { + final previousId = existing[event.rowId]?.eventId; + + return (previousId != null && previousId != event.eventId + ? acc.remove(previousId) + : acc) + .add(event.eventId, event.rowId); + }); + + void update( + IMap rooms, [ + ISet leftRooms = const .empty(), + ]) { + final merged = rooms.entries.fold(state, (acc, entry) { + final roomId = entry.key; + final incoming = entry.value; + final existing = acc[roomId]; + + return acc.add( + roomId, + existing?.copyWith( + hasMore: incoming.hasMore, + clientSticky: incoming.resetClientSticky + ? incoming.clientSticky + : (incoming.clientSticky.isEmpty + ? existing.clientSticky + : existing.clientSticky.addAll( + incoming.clientSticky, + )) + .removeWhere( + (rowId) => incoming.timeline.values.contains(rowId), + ), + metadata: incoming.metadata ?? existing.metadata, + events: incoming.events.isEmpty + ? existing.events + : existing.events.addAll(incoming.events), + relations: addRelations( + existing.relations, + incoming.events.values, + ), + eventIds: addEventIds( + existing.eventIds, + existing.events, + incoming.events.values, + ), + state: incoming.state.entries.fold( + existing.state, + (previousValue, event) => previousValue.add( + event.key, + (previousValue[event.key] ?? .new()).addAll(event.value), + ), + ), + resetClientSticky: false, + reset: false, + hasFetchedMembers: + incoming.hasFetchedMembers || existing.hasFetchedMembers, + hasFetchedState: + incoming.hasFetchedState || existing.hasFetchedState, + timeline: incoming.reset + ? incoming.timeline + : existing.timeline.addAll(incoming.timeline), + receipts: mergeReceipts(existing.receipts, incoming.receipts), + ) ?? + incoming.copyWith( + receipts: mergeReceipts(const .empty(), incoming.receipts), + relations: addRelations(const .empty(), incoming.events.values), + eventIds: addEventIds( + const .empty(), + const .empty(), + incoming.events.values, + ), + ), + ); + }); + + state = leftRooms.fold(merged, (acc, roomId) => acc.remove(roomId)); + } + + /// Waits for a sent state event to become the room's state, which happens + /// once it comes down sync. Gives up after a while, e.g. if another event + /// replaced it first. + Future awaitState( + String roomId, + String type, + String eventId, { + String stateKey = "", + }) => awaitRooms( + (rooms) => switch (rooms[roomId]?.state[type]?[stateKey]) { + final rowId? => rooms[roomId]?.events[rowId]?.eventId == eventId, + null => false, + }, + ); + + /// Waits for a sent event to come down sync, giving up after a while. + Future awaitEvent(String roomId, String eventId) => awaitRooms( + (rooms) => rooms[roomId]?.eventIds.containsKey(eventId) ?? false, + ); + + Future awaitRooms(bool Function(IMap rooms) arrived) { + if (arrived(state)) return .value(); + + final completer = Completer(); + final removeListener = listenSelf((_, rooms) { + if (arrived(rooms) && !completer.isCompleted) completer.complete(); + }); + return completer.future + .timeout(const .new(seconds: 10), onTimeout: () {}) + .whenComplete(removeListener); + } + + static final provider = NotifierProvider>( + RoomsController.new, + ); +} diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart deleted file mode 100644 index 0945644..0000000 --- a/lib/controllers/rooms_controller.dart +++ /dev/null @@ -1,83 +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/new_events_controller.dart"; -import "package:nexus/models/read_receipt.dart"; -import "package:nexus/models/room.dart"; - -class RoomsController extends Notifier> { - @override - IMap build() => const IMap.empty(); - - void update(IMap rooms, ISet leftRooms) { - final merged = rooms.entries.fold(state, (acc, entry) { - final roomId = entry.key; - final incoming = entry.value; - final existing = acc[roomId]; - - final events = existing?.events.updateById( - incoming.events, - (item) => item.eventId, - ); - - 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( - metadata: incoming.metadata ?? existing.metadata, - events: events!, - state: incoming.state.entries.fold( - existing.state, - (previousValue, event) => previousValue.add( - event.key, - (previousValue[event.key] ?? const IMap.empty()).addAll( - event.value, - ), - ), - ), - 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] ?? IList()).addAll( - event.value, - ), - ), - ), - ) ?? - incoming, - ); - }); - - final prunedList = leftRooms.fold( - merged, - (acc, roomId) => acc.remove(roomId), - ); - state = prunedList; - } - - static final provider = NotifierProvider>( - RoomsController.new, - ); -} diff --git a/lib/controllers/selected_room.dart b/lib/controllers/selected_room.dart new file mode 100644 index 0000000..075fcdd --- /dev/null +++ b/lib/controllers/selected_room.dart @@ -0,0 +1,33 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/selected_space.dart"; +import "package:nexus/helpers/extensions/get_all_child_rooms.dart"; + +class SelectedRoomController extends Notifier { + @override + String? build() { + final roomId = ref.watch( + KeyController.provider(KeyController.roomKey) + .select((async) => async.value), + ); + + final roomIds = ref.watch( + SelectedSpaceController.provider.select( + (space) => space?.allChildRooms + .map((room) => room.metadata?.id) + .nonNulls + .toIList() + // Real spaces have a timeline of their own + .addAll([?space.room?.metadata?.id]), + ), + ); + if (roomIds == null) return roomId; + + return roomIds.contains(roomId) ? roomId : roomIds.firstOrNull; + } + + static final provider = NotifierProvider( + SelectedRoomController.new, + ); +} diff --git a/lib/controllers/selected_room_controller.dart b/lib/controllers/selected_room_controller.dart deleted file mode 100644 index ffba78c..0000000 --- a/lib/controllers/selected_room_controller.dart +++ /dev/null @@ -1,24 +0,0 @@ -import "package:collection/collection.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/selected_space_controller.dart"; -import "package:nexus/models/room.dart"; - -class SelectedRoomController extends Notifier { - @override - Room? build() { - final space = ref.watch(SelectedSpaceController.provider); - final selectedRoomId = ref.watch( - KeyController.provider(KeyController.roomKey), - ); - - return space.children.firstWhereOrNull( - (room) => room.metadata?.id == selectedRoomId, - ) ?? - space.children.firstOrNull; - } - - static final provider = NotifierProvider( - SelectedRoomController.new, - ); -} diff --git a/lib/controllers/selected_space.dart b/lib/controllers/selected_space.dart new file mode 100644 index 0000000..7a54060 --- /dev/null +++ b/lib/controllers/selected_space.dart @@ -0,0 +1,25 @@ +import "package:collection/collection.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/spaces.dart"; +import "package:nexus/models/space.dart"; + +class SelectedSpaceController extends Notifier { + @override + Space? build() { + final spaces = ref.watch(SpacesController.provider).value; + if (spaces == null) return null; + + final spaceId = ref.watch( + KeyController.provider(KeyController.spaceKey) + .select((async) => async.value), + ); + + return spaces.firstWhereOrNull((space) => space.id == spaceId) ?? + spaces.firstOrNull; + } + + static final provider = NotifierProvider( + SelectedSpaceController.new, + ); +} diff --git a/lib/controllers/selected_space_controller.dart b/lib/controllers/selected_space_controller.dart deleted file mode 100644 index dbeb71f..0000000 --- a/lib/controllers/selected_space_controller.dart +++ /dev/null @@ -1,22 +0,0 @@ -import "package:collection/collection.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/spaces_controller.dart"; -import "package:nexus/models/space.dart"; - -class SelectedSpaceController extends Notifier { - @override - Space build() { - final spaces = ref.watch(SpacesController.provider); - final selectedSpaceId = ref.watch( - KeyController.provider(KeyController.spaceKey), - ); - - return spaces.firstWhereOrNull((space) => space.id == selectedSpaceId) ?? - spaces.first; - } - - static final provider = NotifierProvider( - SelectedSpaceController.new, - ); -} diff --git a/lib/controllers/settings.dart b/lib/controllers/settings.dart new file mode 100644 index 0000000..1d6076a --- /dev/null +++ b/lib/controllers/settings.dart @@ -0,0 +1,28 @@ +import "dart:convert"; + +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/settings_file.dart"; +import "package:nexus/models/settings.dart"; + +class SettingsController extends AsyncNotifier { + @override + Future build() async { + final file = await ref.watch(SettingsFileController.provider.future); + + try { + return .fromJson(json.decode(await file.readAsString())); + } catch (_) { + return .new(); + } + } + + Future set(Settings settings) async { + state = .data(settings); + final file = await ref.read(SettingsFileController.provider.future); + await file.writeAsString(json.encode(settings.toJson())); + } + + static final provider = AsyncNotifierProvider( + SettingsController.new, + ); +} diff --git a/lib/controllers/settings_file.dart b/lib/controllers/settings_file.dart new file mode 100644 index 0000000..9b57777 --- /dev/null +++ b/lib/controllers/settings_file.dart @@ -0,0 +1,24 @@ +import "dart:io"; + +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:path/path.dart"; +import "package:path_provider/path_provider.dart"; +import "package:xdg_directories/xdg_directories.dart"; + +class SettingsFileController extends AsyncNotifier { + @override + Future build() async { + final directory = await switch (Platform.isLinux) { + true => Directory( + join(configHome.absolute.path, "nexus"), + ).create(recursive: true), + false => getApplicationSupportDirectory(), + }; + + return File(join(directory.absolute.path, "config.json")); + } + + static final provider = AsyncNotifierProvider( + SettingsFileController.new, + ); +} diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart new file mode 100644 index 0000000..999fea6 --- /dev/null +++ b/lib/controllers/settings_sections.dart @@ -0,0 +1,822 @@ +import "dart:io"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:intl/intl.dart"; +import "package:m3e_buttons/m3e_buttons.dart"; +import "package:multi_state_switch/multi_state_switch.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/account_data.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/enabled_image_packs.dart"; +import "package:nexus/controllers/notification.dart"; +import "package:nexus/controllers/profile.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/controllers/unified_push.dart"; +import "package:nexus/controllers/spec_versions.dart"; +import "package:nexus/controllers/unified_push_allowed.dart"; +import "package:nexus/controllers/unified_push_distributors.dart"; +import "package:nexus/helpers/extensions/distributor_name.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/room_image_packs.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/models/account_data.dart"; +import "package:nexus/models/client_state.dart"; +import "package:nexus/models/image_pack.dart"; +import "package:nexus/models/name_color.dart"; +import "package:nexus/models/push_rule_switch.dart"; +import "package:nexus/models/room_order.dart"; +import "package:nexus/models/setting.dart"; +import "package:nexus/models/settings.dart"; +import "package:nexus/models/settings_category.dart"; +import "package:nexus/models/show_and_send.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/common/divider_text.dart"; +import "package:nexus/widgets/avatars/image_pack_avatar.dart"; +import "package:nexus/widgets/settings/block_user_field.dart"; +import "package:nexus/widgets/settings/dialog_list_tile.dart"; +import "package:nexus/widgets/settings/enable_image_packs_sheet.dart"; +import "package:nexus/widgets/settings/keyword_field.dart"; +import "package:nexus/widgets/users/user_bottom_sheet.dart"; +import "package:nexus/widgets/encryption/reset_encryption_sheet.dart"; + +class SettingsSectionsController + extends AsyncNotifier>> { + @override + Future>> build() async { + final settings = await ref.watch(SettingsController.provider.future); + + // Loads the enabled packs' state, so they can be shown + ref.watch(EnabledImagePacksController.provider); + final enabledPacks = ref.watch( + AccountDataController.provider.select((data) => data.enabledImagePacks), + ); + final enabledPackInfo = ref.watch( + RoomsController.provider.select( + (rooms) => enabledPacks.rooms.entries.expand((entry) { + final room = rooms[entry.key]; + final packs = room?.imagePacks(entry.key) ?? const .empty(); + + return entry.value.keys.map( + (stateKey) => ( + roomId: entry.key, + stateKey: stateKey, + roomName: room?.metadata?.name ?? entry.key, + pack: packs[stateKey], + ), + ); + }).toIList(), + ), + ); + + Future setEnabledPacks(ImagePackRooms packs) => ref + .read(AccountDataController.provider.notifier) + .setEnabledImagePacks(packs); + + /// A tile for a [ShowAndSend] setting, saving the settings from [update]. + Widget showAndSendTile( + String title, + String description, + IconData icon, { + required ShowAndSend value, + required Settings Function(ShowAndSend value) update, + }) => DialogListTile( + icon: Icon(icon), + title: title, + subtitle: Text(description), + value: value, + options: ShowAndSend.values, + getName: (option) => switch (option) { + .showAndSend => "Show and send", + .show => "Only show", + .neither => "Neither", + }, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(update(value)) + .onError(showError), + ); + + final pushRules = ref.watch( + AccountDataController.provider.select((data) => data.pushRules.global), + ); + final ignoredUsers = ref.watch( + AccountDataController.provider.select( + (data) => data.ignoredUserList.ignoredUsers.keys.toIList().sort(), + ), + ); + final accountData = ref.read(AccountDataController.provider.notifier); + + Setting header(String title, String description, IconData icon) => .new( + title: title, + description: description, + icon: icon, + builder: (title, _, _) => Padding( + padding: .symmetric(horizontal: 16), + child: DividerText(title), + ), + ); + + /// A switch for push rules, which is disabled while [onChanged] saves. A + /// null [value] is shown in the middle, for rules set up some other way. + Setting pushSwitchSetting({ + required String title, + required String description, + required IconData icon, + required bool? value, + required Future Function(bool value) onChanged, + }) => .new( + title: title, + description: description, + icon: icon, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final saving = Saving.use(); + final position = switch (value) { + false => 0, + null => 1, + true => 2, + }; + void change(bool value) => + saving.run(() => onChanged(value)).onError(showError); + + return ListTile( + enabled: !saving.active, + leading: Icon(icon), + title: Text(title), + subtitle: Text(description), + trailing: MultiStateSwitch( + states: 3, + // As wide as a regular switch, which has 2 states 20 apart + stepWidth: 10, + value: position, + semanticFormatterCallback: (position) => + ["Off", "Custom", "On"][position], + onChanged: saving.active + ? null + : (newPosition) => change(newPosition > position), + ), + // Tapping turns it off only when it's fully on + onTap: () => change(value != true), + ); + }, + ), + ); + + return .new({ + "General": .new([ + .new( + title: "Appearance", + icon: Icons.brush, + settings: .new([ + .new( + title: "Theme", + description: + "Toggle between Light Mode, Dark Mode, and System themes.", + icon: Icons.contrast, + builder: (title, description, icon) => DialogListTile( + icon: Icon(icon), + title: title, + subtitle: Text(description), + value: settings.theme, + options: ThemeMode.values, + getName: (option) => toBeginningOfSentenceCase(option.name), + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(theme: value)) + .onError(showError), + ), + ), + .new( + title: "Use Dynamic Theme", + icon: Icons.palette, + description: "Toggle on or off Dynamic Theme. Only available on Android, Linux, Windows, or MacOS.", + builder: (title, description, icon) => SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: settings.useDynamicTheming, + onChanged: + (Platform.isAndroid || + Platform.isLinux || + Platform.isMacOS || + Platform.isWindows) + ? (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(useDynamicTheming: value)) + .onError(showError) + : null, + ), + ), + .new( + title: "Name Colors", + description: "Color names by power level, so moderators and admins stand out, or by user, so everyone has their own.", + icon: Icons.format_color_text, + builder: (title, description, icon) => DialogListTile( + icon: Icon(icon), + title: title, + subtitle: Text(description), + value: settings.nameColor, + options: NameColor.values, + getName: (option) => switch (option) { + .powerLevel => "By power level", + .user => "By user", + }, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(nameColor: value)) + .onError(showError), + ), + ), + ]), + ), + .new( + title: "Behavior", + icon: Icons.psychology, + settings: .new([ + .new( + title: "All Rooms In Home", + description: "Show all rooms in home, instead of just ones that aren't in spaces", + icon: Icons.home, + builder: (title, description, icon) => SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: settings.showAllInHome, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(showAllInHome: value)) + .onError(showError), + ), + ), + .new( + title: "Room Ordering", + description: "Sort rooms by recent activity, optionally showing mentioned and unread rooms first.", + icon: Icons.sort, + builder: (title, description, icon) => DialogListTile( + icon: Icon(icon), + title: title, + subtitle: Text(description), + value: settings.roomOrder, + options: RoomOrder.values, + getName: (option) => switch (option) { + .unreadFirst => "Mentioned, then unread", + .recentActivity => "Recent activity only", + }, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(roomOrder: value)) + .onError(showError), + ), + ), + .new( + title: "Swipe To Reply", + description: "Swipe a message to the right to reply to it.", + icon: Icons.swipe_right, + builder: (title, description, icon) => SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: settings.swipeToReply, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(swipeToReply: value)) + .onError(showError), + ), + ), + if (!Platform.isAndroid && !Platform.isIOS) + .new( + title: "Show Minimize Button", + description: + "Enable this to add a minimize button to the appbar", + icon: Icons.minimize, + builder: (title, description, icon) => SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: settings.showMinimizeButton, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(showMinimizeButton: value)) + .onError(showError), + ), + ), + .new( + title: "Linux Mobile Mode", + description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window, and swiping drawers open.", + icon: Icons.construction, + builder: (title, description, icon) => SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: settings.linuxMobileMode, + onChanged: Platform.isLinux + ? (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(linuxMobileMode: value)) + .onError(showError) + : null, + ), + ), + .new( + title: "Hide Membership Events", + description: "Hides membership events (invites, joins, leaves & bans) from appearing in conversations. Knocks are still shown.", + icon: Icons.person_off, + builder: (title, description, icon) => SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: settings.hideMembershipEvents, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(hideMembershipEvents: value)) + .onError(showError), + ), + ), + .new( + title: "Hide Server ACL Events", + description: "Hides server ACL (server ban list) changes from appearing in conversations.", + icon: Icons.dns, + builder: (title, description, icon) => SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: settings.hideServerAclEvents, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(hideServerAclEvents: value)) + .onError(showError), + ), + ), + ]), + ), + .new( + title: "Privacy", + icon: Icons.privacy_tip, + settings: .new([ + .new( + title: "Typing Indicators", + description: "Show when others are typing, and let others see when you're typing.", + icon: Icons.keyboard, + builder: (title, description, icon) => showAndSendTile( + title, + description, + icon, + value: settings.typingIndicators, + update: (value) => settings.copyWith(typingIndicators: value), + ), + ), + .new( + title: "Read Receipts", + description: "Show how far others have read, and let others see what you've read. When not sent, read receipts are still sent privately, so your other devices know what you've read.", + icon: Icons.done_all, + builder: (title, description, icon) => showAndSendTile( + title, + description, + icon, + value: settings.readReceipts, + update: (value) => settings.copyWith(readReceipts: value), + ), + ), + ]), + ), + ]), + if (ref.watch(ClientStateController.provider) case ClientState( + isLoggedIn: true, + :final userId?, + )) + "Account": .new([ + .new( + title: "Profile", + icon: Icons.person, + settings: .new([ + .new( + title: "Profile", + description: "Your avatar, display name, pronouns, and timezone, as others see them.", + icon: Icons.person, + builder: (title, description, icon) => Center( + child: ConstrainedBox( + constraints: .new(maxWidth: 500), + child: UserBottomSheet(userId, editable: true), + ), + ), + ), + ]), + ), + .new( + title: "Image Packs", + icon: Icons.emoji_emotions, + settings: .new([ + ...enabledPackInfo.map( + (info) => Setting( + title: + info.pack?.name ?? + ImagePack.fallbackName(info.roomName, info.stateKey), + description: info.roomName, + icon: Icons.emoji_emotions, + builder: (title, description, _) => HookBuilder( + key: ValueKey((info.roomId, info.stateKey)), + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: !saving.active, + leading: ImagePackAvatar(info.pack?.icon), + title: Text(title), + subtitle: Text(description), + trailing: IconButton( + tooltip: "Remove pack", + icon: Icon(Icons.delete), + onPressed: saving.active + ? null + : () => saving.run( + () => setEnabledPacks( + ref + .read(AccountDataController.provider) + .enabledImagePacks + .withPack( + info.roomId, + info.stateKey, + enabled: false, + ), + ).onError(showError), + ), + ), + ); + }, + ), + ), + ), + .new( + title: "Add Packs", + description: + "Use image packs from any of your rooms in every room.", + icon: Icons.add, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: !saving.active, + leading: Icon(icon), + title: Text(title), + subtitle: Text(description), + onTap: () => context.showAdaptiveSheet( + (_) => EnableImagePacksSheet( + enabled: enabledPacks, + onSave: (packs) => saving.run( + () => setEnabledPacks(packs).onError(showError), + ), + ), + ), + ); + }, + ), + ), + ]), + ), + .new( + title: "Notifications", + icon: Icons.notifications, + settings: .new([ + .new( + title: "Push Notifications", + description: "Enable push notifications using Web Push, and choose which UnifiedPush distributor to use.", + builder: (title, description, icon) => HookConsumer( + builder: (context, ref, _) { + final loading = useState(false); + final pusherRegistered = ref.watch( + UnifiedPushController.provider, + ); + final unifiedPushAllowed = ref.watch( + UnifiedPushAllowedController.provider, + ); + final distributors = ref.watch( + UnifiedPushDistributorsController.provider, + ); + final current = pusherRegistered.value; + + return DialogListTile( + title: title, + subtitle: Text( + unifiedPushAllowed.maybeWhen( + data: (data) => data, + orElse: () => null, + ) ?? + description, + ), + icon: Icon(icon), + value: current, + options: [ + null, + ...?distributors.value, + if (current != null && + distributors.value?.contains(current) != true) + current, + ], + getName: (option) => + option == null ? "Off" : option.distributorName, + onChanged: loading.value + ? null + : unifiedPushAllowed.maybeWhen( + data: (data) => data == null, + orElse: () => false, + ) + ? pusherRegistered.maybeWhen( + data: (_) => (value) async { + if (value == current) return; + try { + loading.value = true; + if (value != null) { + final messenger = ScaffoldMessenger.of( + context, + ); + if (!await ref + .read( + NotificationController + .provider + .notifier, + ) + .requestPermissions()) { + messenger.showSnackBar( + .new( + content: Text( + "Notifications are disabled. Enable them for Nexus in your system settings.", + ), + ), + ); + return; + } + await ref + .read( + UnifiedPushController + .provider + .notifier, + ) + .register(value); + } else { + await ref + .read( + UnifiedPushController + .provider + .notifier, + ) + .deregister(); + } + } catch (error, stackTrace) { + showError(error, stackTrace); + } finally { + loading.value = false; + } + }, + orElse: () => null, + ) + : null, + ); + }, + ), + icon: Icons.notification_add, + ), + if (pushRules.master case final master?) + pushSwitchSetting( + title: "Mute All", + description: "Don't notify for anything, without changing your other rules.", + icon: Icons.notifications_off, + value: master.enabled, + onChanged: (mute) => accountData.savePushRule( + .override, + master.copyWith(enabled: mute), + ), + ), + ...PushRuleSection.values.expand( + (section) => [ + header(section.title, section.description, section.icon), + ...PushRuleSwitch.values + .where( + (option) => + option.section == section && + option.rulesIn(pushRules).isNotEmpty, + ) + .map( + (option) => pushSwitchSetting( + title: option.title, + description: option.description, + icon: option.icon, + value: option.valueIn(pushRules), + onChanged: (value) => Future.wait( + option + .changesIn(pushRules, value) + .map( + (change) => accountData.savePushRule( + change.kind, + change.rule, + ), + ), + ), + ), + ), + ], + ), + header( + "Keywords", + "Messages containing these words, which notify with a highlight and sound.", + Icons.text_fields, + ), + ...pushRules.keywords.map( + (rule) => Setting( + title: rule.pattern ?? rule.ruleId, + description: "Messages containing this keyword.", + icon: Icons.text_fields, + builder: (title, description, icon) => HookBuilder( + key: ValueKey(rule.ruleId), + builder: (context) { + final saving = Saving.use(); + final custom = + !rule.enabled || rule.actions != .highlightWithSound; + + return ListTile( + enabled: !saving.active, + leading: Icon(icon), + title: Text(title), + trailing: IconButton( + tooltip: custom + ? "Set up differently by another client. Press to reset to Nexus defaults." + : "Remove keyword", + icon: Icon(custom ? Icons.warning : Icons.delete), + onPressed: saving.active + ? null + : () => saving + .run( + () => custom + ? accountData.savePushRule( + .content, + rule.copyWith( + enabled: true, + actions: .highlightWithSound, + ), + ) + : accountData.deletePushRule( + .content, + rule.ruleId, + ), + ) + .onError(showError), + ), + ); + }, + ), + ), + ), + .new( + title: "Add Keyword", + description: "Notify for messages containing a word.", + icon: Icons.add, + builder: (_, _, _) => KeywordField(), + ), + ]), + ), + .new( + title: "Safety", + icon: Icons.gpp_good, + settings: .new([ + .new( + title: "Invite Blocking", + description: "Block invites, either completely, or block only invites from users without shared private rooms (depends on server support).", + builder: (title, description, icon) => Consumer( + builder: (context, ref, _) { + final specVersionsResponse = ref.watch( + SpecVersionsController.provider, + ); + return DialogListTile( + icon: Icon(icon), + title: title, + subtitle: Text(description), + value: ref + .watch(AccountDataController.provider) + .invitePermissionConfig + .defaultAction, + options: + specVersionsResponse.maybeWhen( + data: (data) => data.unstableFeatures.msc4494, + orElse: () => true, + ) + ? DefaultInviteAction.values + : IList(DefaultInviteAction.values) + .remove(.denyPublic) + .toList(), + getName: (option) => switch (option) { + .allow => "Allow", + .deny => "Deny", + .denyPublic => "Deny public", + }, + onChanged: specVersionsResponse.maybeWhen( + data: (_) => + (value) => ref + .read(ClientController.provider.notifier) + .setAccountData( + .new( + type: AccountData.invitePermissionConfigKey, + content: InvitePermissionConfig( + defaultAction: value, + ), + ), + ) + .onError(showError), + orElse: () => null, + ), + ); + }, + ), + icon: Icons.person_off, + ), + header( + "Blocked Users", + "Users whose messages and invites are hidden. They aren't told they're blocked.", + Icons.block, + ), + ...ignoredUsers.map( + (userId) => Setting( + title: userId, + description: "A blocked user.", + icon: Icons.person, + builder: (title, description, icon) => HookConsumer( + key: ValueKey(userId), + builder: (context, ref, _) { + final saving = Saving.use(); + final profile = ref + .watch(ProfileController.provider(userId)) + .value + ?.profile; + final name = profile?.displayName ?? userId.localpart; + + return ListTile( + enabled: !saving.active, + leading: AvatarOrHash(profile?.avatarUrl, name), + title: Text(name, overflow: .ellipsis), + subtitle: Text(userId, overflow: .ellipsis), + onTap: () => context.showAdaptiveSheet( + (_) => UserBottomSheet(userId), + ), + trailing: IconButton( + tooltip: "Unblock", + icon: Icon(Icons.lock_open), + onPressed: saving.active + ? null + : () => saving + .run( + () => accountData.setIgnored( + userId, + ignored: false, + ), + ) + .onError(showError), + ), + ); + }, + ), + ), + ), + .new( + title: "Block User", + description: "Block a user by their ID.", + icon: Icons.block, + builder: (_, _, _) => BlockUserField(), + ), + ]), + ), + .new( + title: "Other", + icon: Icons.key, + settings: .new([ + .new( + title: "Reset Encryption Identity", + description: "Replace your encryption identity and recovery key with new ones, if you've lost your recovery key or your encryption is broken.", + builder: (title, description, icon) => Builder( + builder: (context) => M3EButton.icon( + onPressed: () => context.showAdaptiveSheet( + (_) => const ResetEncryptionSheet(), + ), + label: Text(title), + icon: Icon(icon), + tooltip: description, + ), + ), + icon: Icons.key_off, + ), + ]), + ), + ]), + }); + } + + static final provider = + AsyncNotifierProvider.autoDispose< + SettingsSectionsController, + IMap> + >(SettingsSectionsController.new); +} diff --git a/lib/controllers/shared_prefs.dart b/lib/controllers/shared_prefs.dart new file mode 100644 index 0000000..9ecb34a --- /dev/null +++ b/lib/controllers/shared_prefs.dart @@ -0,0 +1,12 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +class SharedPrefsController extends Notifier { + @override + SharedPreferencesAsync build() => SharedPreferencesAsync(); + + static final provider = + NotifierProvider( + SharedPrefsController.new, + ); +} diff --git a/lib/controllers/shared_prefs_controller.dart b/lib/controllers/shared_prefs_controller.dart deleted file mode 100644 index f4dcdae..0000000 --- a/lib/controllers/shared_prefs_controller.dart +++ /dev/null @@ -1,12 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:shared_preferences/shared_preferences.dart"; - -class SharedPrefsController extends AsyncNotifier { - @override - Future build() => SharedPreferences.getInstance(); - - static final provider = - AsyncNotifierProvider( - SharedPrefsController.new, - ); -} diff --git a/lib/controllers/sidebar_page.dart b/lib/controllers/sidebar_page.dart new file mode 100644 index 0000000..d80cb44 --- /dev/null +++ b/lib/controllers/sidebar_page.dart @@ -0,0 +1,18 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/notification_request.dart"; +import "package:nexus/models/sidebar_page.dart"; + +class SidebarPageController extends Notifier { + // Show the notification that switched accounts, if one did + @override + SidebarPage build() => + ref.read(NotificationRequestController.provider) == null + ? .rooms + : .notifications; + + void set(SidebarPage page) => state = page; + + static final provider = NotifierProvider( + SidebarPageController.new, + ); +} diff --git a/lib/controllers/space_edges_controller.dart b/lib/controllers/space_edges.dart similarity index 63% rename from lib/controllers/space_edges_controller.dart rename to lib/controllers/space_edges.dart index 12694d6..3b239bf 100644 --- a/lib/controllers/space_edges_controller.dart +++ b/lib/controllers/space_edges.dart @@ -1,13 +1,16 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/models/space_edge.dart"; +import "package:nexus/models/sync_data.dart"; class SpaceEdgesController extends Notifier>> { @override - IMap> build() => const IMap.empty(); + IMap> build() => .new(); - void set(IMap> newEdges) => - state = state.addAll(newEdges); + void handleSync(SyncData sync) { + if (sync.clearState) state = .new(); + state = state.addAll(sync.spaceEdges ?? const .empty()); + } static final provider = NotifierProvider>>( diff --git a/lib/controllers/space_hierarchy.dart b/lib/controllers/space_hierarchy.dart new file mode 100644 index 0000000..226b091 --- /dev/null +++ b/lib/controllers/space_hierarchy.dart @@ -0,0 +1,41 @@ +import "package:flutter_riverpod/experimental/mutation.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/space_hierarchy.dart"; + +class SpaceHierarchyController(final String spaceId) + extends AsyncNotifier { + @override + Future build() => ref + .read(ClientController.provider.notifier) + .getSpaceHierarchy(.new(roomId: spaceId, maxDepth: 1)); + + Future loadMore() async { + final current = await future; + if (current.nextBatch == null) return; + + final next = await ref + .read(ClientController.provider.notifier) + .getSpaceHierarchy( + .new(roomId: spaceId, maxDepth: 1, from: current.nextBatch), + ); + + state = AsyncData( + current.copyWith( + rooms: current.rooms.addAll(next.rooms), + nextBatch: next.nextBatch, + ), + ); + } + + /// Adding rooms to a space, keyed by its ID + static final addChildrenMutation = Mutation(); + + /// Removing a room from a space, keyed by (space ID, room ID) + static final removeChildMutation = Mutation(); + + static final provider = AsyncNotifierProvider.family + .autoDispose( + SpaceHierarchyController.new, + ); +} diff --git a/lib/controllers/spaces.dart b/lib/controllers/spaces.dart new file mode 100644 index 0000000..4d6dffe --- /dev/null +++ b/lib/controllers/spaces.dart @@ -0,0 +1,171 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/account_data.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/controllers/top_level_spaces.dart"; +import "package:nexus/controllers/space_edges.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/models/room_metadata.dart"; +import "package:nexus/models/space.dart"; +import "package:nexus/models/subspace.dart"; + +class SpacesController extends AsyncNotifier> { + @override + Future> build() async { + 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 = spaceEdges.map( + (spaceId, edges) => + MapEntry(spaceId, edges.map((edge) => edge.childId).toList()), + ); + + /// Everything under [spaceId], however deep, skipping any already + /// [visited] so cycles end. + ISet collectDescendants( + String spaceId, [ + ISet visited = const .empty(), + ]) => (childrenById[spaceId] ?? const []).fold( + visited, + (visited, childId) => visited.contains(childId) + ? visited + : collectDescendants(childId, visited.add(childId)), + ); + + Space buildSpace(String spaceId) { + final space = rooms[spaceId]; + final directChildrenIds = childrenById[spaceId] ?? const []; + + return .new( + id: spaceId, + room: space, + title: space?.metadata?.name ?? "Unnamed Space", + children: .new( + directChildrenIds + .where((childId) => !childrenById.containsKey(childId)) + .map((childId) => rooms[childId]) + .nonNulls, + ), + subSpaces: .new( + directChildrenIds + .where(childrenById.containsKey) + .map( + (childId) => switch (rooms[childId]) { + final room? => Subspace( + room: room, + children: .new( + collectDescendants(childId) + .map((id) => rooms[id]) + .nonNulls, + ), + ), + null => null, + }, + ) + .nonNulls, + ), + ); + } + + final spaces = topLevelSpaceIds + .where(rooms.containsKey) + .map(buildSpace) + .toIList(); + + final usedRooms = spaces + .expand( + (space) => [ + ...space.children, + ...space.subSpaces.expand((s) => s.children), + ], + ) + .toISet(); + final usedRoomIds = usedRooms.map((room) => room.metadata?.id).toSet(); + + final directMessages = accountData.directMessages.values.flattened.toSet(); + + final otherRooms = rooms.entries + .where( + (e) => + !usedRoomIds.contains(e.key) && + !topLevelSpaceIds.contains(e.key) && + !childrenById.containsKey(e.key), + ) + .map((e) => e.value) + .toIList(); + + final homeRooms = otherRooms + .addAll( + await ref.watch( + SettingsController.provider.selectAsync( + (data) => data.showAllInHome, + ), + ) + ? usedRooms + : .empty(), + ) + .where((r) => !directMessages.contains(r.metadata?.id)) + .toIList(); + + final dmRooms = otherRooms + .where((r) => directMessages.contains(r.metadata?.id)) + .toIList(); + + final allSpaces = [ + .new( + id: "home", + title: "Home", + icon: Icons.home, + children: homeRooms, + subSpaces: .new(), + ), + .new( + id: "dms", + title: "Direct Messages", + icon: Icons.people, + children: dmRooms, + subSpaces: .new(), + ), + ...spaces, + ]; + + final roomOrder = await ref.watch( + SettingsController.provider.selectAsync((data) => data.roomOrder), + ); + + // Mentioned -> Unread -> Other + int unreadPriority(Room room) => switch (room.metadata) { + RoomMetadata(unreadHighlights: > 0) => 0, + RoomMetadata(unreadNotifications: > 0) || + RoomMetadata(unreadMessages: > 0) => 1, + _ => 2, + }; + + return allSpaces + .map( + (space) => space.copyWith( + children: space.children.sort((a, b) { + if (roomOrder == .unreadFirst) { + final byUnread = unreadPriority(a).compareTo(unreadPriority(b)); + if (byUnread != 0) return byUnread; + } + + // Most recent activity first + return (b.metadata?.sortingTimestamp ?? DateTime(0)).compareTo( + a.metadata?.sortingTimestamp ?? DateTime(0), + ); + }), + ), + ) + .toIList(); + } + + static final provider = AsyncNotifierProvider>( + SpacesController.new, + ); +} diff --git a/lib/controllers/spaces_controller.dart b/lib/controllers/spaces_controller.dart deleted file mode 100644 index ca217a5..0000000 --- a/lib/controllers/spaces_controller.dart +++ /dev/null @@ -1,116 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/account_data_controller.dart"; -import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/controllers/top_level_spaces_controller.dart"; -import "package:nexus/controllers/space_edges_controller.dart"; -import "package:nexus/models/space.dart"; -import "package:nexus/models/room.dart"; -import "package:nexus/models/space_edge.dart"; - -class SpacesController extends Notifier> { - @override - IList build() { - final rooms = ref.watch(RoomsController.provider); - final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider); - final spaceEdges = ref.watch(SpaceEdgesController.provider); - - final childRoomsBySpaceId = IMap.fromEntries( - topLevelSpaceIds.map((spaceId) { - ISet walk(String currentId) { - final children = spaceEdges[currentId] ?? IList(); - - return children.fold>(const ISet.empty(), (acc, edge) { - final childId = edge.childId; - final isSpace = spaceEdges.containsKey(childId); - - return acc - .addAll(!isSpace ? ISet([childId]) : const ISet.empty()) - .addAll(isSpace ? walk(childId) : const ISet.empty()); - }); - } - - return MapEntry( - spaceId, - walk(spaceId).map((id) => rooms[id]).nonNulls.toIList(), - ); - }), - ); - - 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) => - !allNestedRoomIds.contains(e.key) && - !topLevelSpaceIds.contains(e.key) && - !spaceEdges.containsKey(e.key), - ) - .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( - (room) => - directMessages.any( - (directMessage) => directMessage == room.metadata?.id, - ) == - false, - ) - .toIList(); - - final dmRooms = otherRooms - .where( - (room) => directMessages.any( - (directMessage) => directMessage == room.metadata?.id, - ), - ) - .toIList(); - - 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, - ), - ...topLevelSpacesList, - ].toIList(); - } - - static final provider = NotifierProvider>( - SpacesController.new, - ); -} diff --git a/lib/controllers/spec_versions.dart b/lib/controllers/spec_versions.dart new file mode 100644 index 0000000..d2316bd --- /dev/null +++ b/lib/controllers/spec_versions.dart @@ -0,0 +1,15 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/spec_versions_response.dart"; + +class SpecVersionsController extends AsyncNotifier { + @override + Future build() => + ref.read(ClientController.provider.notifier).getSpecVersions(); + + static final provider = + AsyncNotifierProvider.autoDispose< + SpecVersionsController, + SpecVersionsResponse + >(SpecVersionsController.new); +} diff --git a/lib/controllers/sync_status_controller.dart b/lib/controllers/sync_status.dart similarity index 60% rename from lib/controllers/sync_status_controller.dart rename to lib/controllers/sync_status.dart index fe65732..256c8e2 100644 --- a/lib/controllers/sync_status_controller.dart +++ b/lib/controllers/sync_status.dart @@ -1,11 +1,17 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/main.dart"; import "package:nexus/models/sync_status.dart"; class SyncStatusController extends Notifier { @override Null build() => null; - void set(SyncStatus newStatus) => state = newStatus; + void set(SyncStatus newStatus) { + if (newStatus.type == .permanentlyFailed) { + showError(newStatus.error ?? "Syncing failed"); + } + state = newStatus; + } static final provider = NotifierProvider( SyncStatusController.new, diff --git a/lib/controllers/thread_list.dart b/lib/controllers/thread_list.dart new file mode 100644 index 0000000..847b29e --- /dev/null +++ b/lib/controllers/thread_list.dart @@ -0,0 +1,37 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; + +class ThreadListController(final String roomId) extends AsyncNotifier { + @override + Future build() => fetch(null); + + Future fetch(String? since) async { + final threads = await ref + .read(ClientController.provider.notifier) + .getThreads(.new(roomId: roomId, since: since)); + ref + .read(RoomsController.provider.notifier) + .storeEvents(threads.events.addAll(threads.relatedEvents)); + + return threads.nextBatch; + } + + Future loadMore() async { + final since = await future; + if (state.isLoading || since == null) return; + + state = .loading(); + try { + state = .data(await fetch(since)); + } catch (_) { + state = .data(since); + rethrow; + } + } + + static final provider = AsyncNotifierProvider.family + .autoDispose( + ThreadListController.new, + ); +} diff --git a/lib/controllers/thread_replies.dart b/lib/controllers/thread_replies.dart new file mode 100644 index 0000000..b0e4327 --- /dev/null +++ b/lib/controllers/thread_replies.dart @@ -0,0 +1,49 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; + +typedef ThreadParams = ({String roomId, String threadRoot}); + +/// Loads a thread's replies from the server, newest first, storing them. Its +/// state is the token for older replies, or null if there are none. +class ThreadRepliesController(final ThreadParams thread) + extends AsyncNotifier { + @override + Future build() => fetch(null); + + Future fetch(String? since) async { + final response = await ref + .read(ClientController.provider.notifier) + .paginateManual( + .new( + roomId: thread.roomId, + threadRoot: thread.threadRoot, + since: since, + direction: .backward, + ), + ); + ref + .read(RoomsController.provider.notifier) + .storeEvents(response.events.addAll(response.relatedEvents)); + + return response.nextBatch; + } + + Future loadOlder() async { + final since = await future; + if (state.isLoading || since == null) return; + + state = .loading(); + try { + state = .data(await fetch(since)); + } catch (_) { + state = .data(since); + rethrow; + } + } + + static final provider = AsyncNotifierProvider.family + .autoDispose( + ThreadRepliesController.new, + ); +} diff --git a/lib/controllers/timezones.dart b/lib/controllers/timezones.dart new file mode 100644 index 0000000..458ad79 --- /dev/null +++ b/lib/controllers/timezones.dart @@ -0,0 +1,17 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:timezone/data/latest_10y.dart"; +import "package:timezone/timezone.dart"; + +/// The names of all IANA timezones, e.g. `Europe/London`, alphabetically. +class TimezonesController extends Notifier> { + @override + IList build() { + initializeTimeZones(); + return timeZoneDatabase.locations.keys.toIList().sort(); + } + + static final provider = NotifierProvider>( + TimezonesController.new, + ); +} diff --git a/lib/controllers/tombstone.dart b/lib/controllers/tombstone.dart new file mode 100644 index 0000000..92c275b --- /dev/null +++ b/lib/controllers/tombstone.dart @@ -0,0 +1,22 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/event.dart"; + +class TombstoneController(final String roomId) extends Notifier { + @override + Event? build() => ref.watch( + RoomsController.provider.select((rooms) { + final room = rooms[roomId]; + return switch (room?.state[EventType.tombstone.type]?[""]) { + final rowId? => room?.events[rowId], + null => null, + }; + }), + ); + + static final provider = NotifierProvider.family + .autoDispose( + TombstoneController.new, + ); +} diff --git a/lib/controllers/top_level_spaces_controller.dart b/lib/controllers/top_level_spaces.dart similarity index 69% rename from lib/controllers/top_level_spaces_controller.dart rename to lib/controllers/top_level_spaces.dart index e1f9c88..acdacad 100644 --- a/lib/controllers/top_level_spaces_controller.dart +++ b/lib/controllers/top_level_spaces.dart @@ -1,11 +1,12 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/models/sync_data.dart"; class TopLevelSpacesController extends Notifier> { @override - IList build() => const IList.empty(); + IList build() => .new(); - void set(IList newSpaces) => state = newSpaces; + void handleSync(SyncData sync) => state = sync.topLevelSpaces ?? state; static final provider = NotifierProvider>( diff --git a/lib/controllers/typing.dart b/lib/controllers/typing.dart new file mode 100644 index 0000000..b0e3f93 --- /dev/null +++ b/lib/controllers/typing.dart @@ -0,0 +1,20 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/models/sync_data.dart"; +import "package:nexus/models/typing.dart"; + +class TypingController extends Notifier>> { + @override + IMap> build() => .new(); + + void set(Typing typing) => state = state.add(typing.roomId, typing.userIds); + + void handleSync(SyncData sync) { + if (sync.clearState) state = .new(); + } + + static final provider = + NotifierProvider>>( + TypingController.new, + ); +} diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart new file mode 100644 index 0000000..25675e2 --- /dev/null +++ b/lib/controllers/unified_push.dart @@ -0,0 +1,305 @@ +import "dart:async"; +import "dart:convert"; +import "dart:io"; + +import "package:collection/collection.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:intl/intl.dart"; +import "package:nexus/controllers/accounts.dart"; +import "package:nexus/controllers/active_account.dart"; +import "package:nexus/controllers/capabilities.dart"; +import "package:nexus/controllers/selected_room.dart"; +import "package:nexus/controllers/notification.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/push_key.dart"; +import "package:nexus/main.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/gomuks_listener.dart"; +import "package:unifiedpush/unifiedpush.dart"; +import "package:unifiedpush_storage_shared_preferences/storage.dart"; +import "package:window_manager/window_manager.dart"; + +class UnifiedPushController extends AsyncNotifier { + Completer? _endpoint; + + /// The account [instance] (its device ID) belongs to, or the active one for + /// devices registered before account switching. + Future _accountFor(String instance) async => + (await ref.read(AccountsController.provider.future)) + .firstWhereOrNull((account) => account.deviceId == instance) + ?.id ?? + ref.read(ActiveAccountController.provider); + + ClientController _clientFor(String accountId) => + accountId == ref.read(ActiveAccountController.provider) + ? ref.read(ClientController.provider.notifier) + : ref.read(ClientController.forAccount(accountId).notifier); + + @override + Future build() async { + if (!Platform.isLinux && !Platform.isAndroid) return null; + + final registered = await UnifiedPush.initialize( + linuxOptions: .new( + dbusName: "nexus.federated.nexus.UnifiedPush", + storage: UnifiedPushStorageSharedPreferences(), + background: isInBackground, + shouldWriteService: false, + ), + onNewEndpoint: (endpoint, instance) async { + final task = Future(() async { + final pushKey = endpoint.pubKeySet!.pubKey; + await ref + .read(PushKeyController.provider(instance).notifier) + .set(pushKey); + + await _clientFor(await _accountFor(instance)).registerPusher( + .new( + appDisplayName: "Nexus", + appId: "nexus.federated.nexus", + data: .webPush( + url: .parse(endpoint.url), + auth: endpoint.pubKeySet!.auth, + ), + deviceDisplayName: + "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", + kind: .webPush, + lang: "en", + pushKey: pushKey, + ), + ); + + state = .data(await UnifiedPush.getDistributor()); + }); + + if (_endpoint case final completer? when !completer.isCompleted) { + completer.complete(task); + } else { + await task; + } + }, + onRegistrationFailed: (reason, instance) { + if (_endpoint case final completer? when !completer.isCompleted) { + completer.completeError(reason); + } else { + throw reason; + } + }, + onMessage: (message, instance) async { + debugPrint("UP message received for $instance"); + if (await deregisterIfDisabled(instance)) { + if (isInBackground) exit(0); + return; + } + + if (message.decrypted == false) { + throw Exception( + "Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.", + ); + } + final accountId = await _accountFor(instance); + final client = _clientFor(accountId); + final isActive = + accountId == ref.read(ActiveAccountController.provider); + final notification = await client.handlePush( + json.decode(String.fromCharCodes(message.content)), + ); + + if (notification == null || + (!isInBackground && + isActive && + (WindowManager.instance.getCurrent()?.isFocused ?? false) && + ref.read(SelectedRoomController.provider) == + notification.roomId)) { + if (isInBackground) exit(0); + return; + } + + final hasOtherAccounts = + (await ref.read(AccountsController.provider.future)).length > 1; + + final icon = notification.roomAvatar == null + ? null + : await client.downloadMedia( + .new(mxc: notification.roomAvatar!, isAvatar: true), + ); + + final pmp = notification.perMessageProfile; + final senderAvatarUrl = pmp?.avatar ?? notification.sender.avatar; + // Only Android can show the sender's avatar + final senderAvatar = senderAvatarUrl == null || !Platform.isAndroid + ? null + : await client.downloadMedia( + .new(mxc: senderAvatarUrl, isAvatar: true), + ); + + await ref + .read(NotificationController.provider.notifier) + .send( + // Android stacks messages into one conversation per room (and + // account, since two accounts can share a room) + id: + "$accountId/${Platform.isAndroid ? notification.roomId : notification.eventId}" + .hashCode & + 0x7fffffff, + threadId: "$accountId/${notification.roomId}", + title: notification.roomName, + subtitle: hasOtherAccounts ? notification.self.id : null, + body: notification.text, + payload: NotificationController.payloadFor( + accountId, + notification.eventRowId, + ), + timestamp: notification.timestamp, + icon: icon, + sound: notification.sound, + message: ( + sender: pmp == null + ? notification.sender + : pmp.copyWith(id: "${notification.sender.id}/${pmp.id}"), + self: notification.self, + senderAvatar: senderAvatar, + isDm: notification.isDm, + ), + ); + + if (isInBackground) exit(0); + }, + onUnregistered: deregister, + ); + + if (!registered || isInBackground) return null; + + ref.listen( + ClientStateController.provider.select((state) => state?.isLoggedIn), + (previous, isLoggedIn) => Future(() async { + if (isLoggedIn != true) return; + if (await deregisterIfDisabled()) return; + + // On startup, only accounts with push on (which have a push key). + // After logging in, push is on by default. + final deviceId = ref.read(ClientStateController.provider)?.deviceId; + if (previous != false && + (deviceId == null || + await ref.read(PushKeyController.provider(deviceId).future) == + null)) { + return; + } + + try { + await register(); + } on UnsupportedError { + // The settings tile explains that the homeserver can't send Web Push + } + }).catchError(showError), + fireImmediately: true, + ); + + return null; + } + + /// Registers with [distributor], or the current/default distributor if none + /// is given. Returns the distributor that was registered with. + Future register([String? distributor]) async { + if (distributor != null && + state.value != null && + state.value != distributor) { + await deregister(); + } + + // Not loading until logged in, since the app waits for this + final deviceId = await ref + .read(GomuksListenerController.provider.notifier) + .deviceId(); + + state = .loading(); + try { + await Future(() async { + final capabilities = await ref.read( + CapabilitiesController.provider.future, + ); + + if (capabilities.webpush?.vapid == null) { + throw UnsupportedError( + "Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus.", + ); + } + + if (distributor != null) { + await UnifiedPush.saveDistributor(distributor); + } else if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) { + throw Exception("No UnifiedPush distributors found."); + } + + final endpoint = _endpoint = Completer(); + + try { + await Future.wait([ + UnifiedPush.register( + instance: deviceId, + vapid: capabilities.webpush?.vapid, + ), + endpoint.future, + ], eagerError: true); + } finally { + if (_endpoint == endpoint) _endpoint = null; + } + }).timeout( + .new(seconds: 10), + onTimeout: () => throw Exception("UnifiedPush registration timed out."), + ); + + final current = await UnifiedPush.getDistributor(); + state = .data(current); + return current; + } catch (_) { + state = .data(null); + rethrow; + } + } + + Future deregisterIfDisabled([String? notificationInstance]) async { + if (await ref + .read(NotificationController.provider.notifier) + .permissionsGranted()) { + return false; + } + + final instance = + notificationInstance ?? + await ref.read(GomuksListenerController.provider.notifier).deviceId(); + final key = await ref.read(PushKeyController.provider(instance).future); + if (key != null) await deregister(instance); + return true; + } + + Future deregister([String? instance]) async { + instance ??= await ref + .read(GomuksListenerController.provider.notifier) + .deviceId(); + + final keyProvider = PushKeyController.provider(instance); + final key = await ref.read(keyProvider.future); + final accountId = await _accountFor(instance); + + if (key != null) { + await _clientFor(accountId) + .deregisterPusher(.new(appId: "nexus.federated.nexus", pushKey: key)); + await ref.read(keyProvider.notifier).set(null); + } else { + debugPrint( + "No matching pushKey found. Skipping deregistration from homeserver.", + ); + } + + await UnifiedPush.unregister(instance); + if (accountId == ref.read(ActiveAccountController.provider)) { + state = .data(null); + } + } + + static final provider = AsyncNotifierProvider( + UnifiedPushController.new, + ); +} diff --git a/lib/controllers/unified_push_allowed.dart b/lib/controllers/unified_push_allowed.dart new file mode 100644 index 0000000..6dd1cf7 --- /dev/null +++ b/lib/controllers/unified_push_allowed.dart @@ -0,0 +1,37 @@ +import "dart:io"; + +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/capabilities.dart"; +import "package:nexus/controllers/unified_push_distributors.dart"; + +class UnifiedPushAllowedController extends AsyncNotifier { + @override + Future build() async { + final distributors = await ref.watch( + UnifiedPushDistributorsController.provider.future, + ); + + if (distributors.isEmpty) { + return "No valid distributors found. ${Platform.isLinux + ? "Try installing KUnifiedPush" + : Platform.isAndroid + ? "Try installing Google Play Services or NTFY" + : "Your platform is not currently supported by UnifiedPush"}."; + } + + final capabilities = await ref.watch( + CapabilitiesController.provider.future, + ); + + if (capabilities.webpush?.vapid == null) { + return "Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus."; + } + + return null; + } + + static final provider = + AsyncNotifierProvider.autoDispose( + UnifiedPushAllowedController.new, + ); +} diff --git a/lib/controllers/unified_push_distributors.dart b/lib/controllers/unified_push_distributors.dart new file mode 100644 index 0000000..15fbec4 --- /dev/null +++ b/lib/controllers/unified_push_distributors.dart @@ -0,0 +1,26 @@ +import "dart:io"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:unifiedpush/unifiedpush.dart"; + +class UnifiedPushDistributorsController extends AsyncNotifier> { + @override + Future> build() async { + if (!Platform.isLinux && !Platform.isAndroid) return .new(); + + return IList( + await UnifiedPush.getDistributors().timeout( + .new(seconds: 10), + onTimeout: () => + throw Exception("UnifiedPush distributor check timed out."), + ), + ); + } + + static final provider = + AsyncNotifierProvider.autoDispose< + UnifiedPushDistributorsController, + IList + >(UnifiedPushDistributorsController.new); +} diff --git a/lib/controllers/url_preview.dart b/lib/controllers/url_preview.dart new file mode 100644 index 0000000..12adca9 --- /dev/null +++ b/lib/controllers/url_preview.dart @@ -0,0 +1,26 @@ +import "package:flutter/widgets.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/open_graph_data.dart"; + +class UrlPreviewController(final Uri url) + extends AsyncNotifier { + @override + Future build() async { + if (url.host == "matrix.to") return null; + + try { + return await ref + .read(ClientController.provider.notifier) + .getUrlPreview(url); + } catch (error, stackTrace) { + debugPrintStack(label: error.toString(), stackTrace: stackTrace); + return null; + } + } + + static final provider = + AsyncNotifierProvider.family( + UrlPreviewController.new, + ); +} diff --git a/lib/controllers/user.dart b/lib/controllers/user.dart new file mode 100644 index 0000000..bad8115 --- /dev/null +++ b/lib/controllers/user.dart @@ -0,0 +1,49 @@ +import "dart:async"; + +import "package:collection/collection.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/members.dart"; +import "package:nexus/controllers/profile.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/models/configs/user.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 profileResponse = await ref.watch( + ProfileController.provider(config.userId).future, + ); + + return .new( + status: .leave, + avatarUrl: profileResponse.profile.avatarUrl, + displayName: + profileResponse.profile.displayName ?? config.userId.localpart, + ); + } + + static final provider = + AsyncNotifierProvider.family< + UserController, + MembershipContent, + UserConfig + >(UserController.new); +} diff --git a/lib/controllers/via.dart b/lib/controllers/via.dart new file mode 100644 index 0000000..e443572 --- /dev/null +++ b/lib/controllers/via.dart @@ -0,0 +1,58 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_state.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/event.dart"; +import "package:nexus/models/room.dart"; + +class ViaController(final Room room) extends Notifier> { + @override + ISet build() { + final powerLevelUsers = + switch (room.state[EventType.powerLevels.type]?[""]) { + final rowId? => switch (room.events[rowId]?.content) { + PowerLevelsContent(:final users) => users.keys.toIList(), + _ => null, + }, + null => null, + } ?? + .new([]); + + final joinedMembers = + (room.state[EventType.membership.type]?.values ?? const []) + .map( + (rowId) => switch (room.events[rowId]) { + Event( + stateKey: final userId?, + content: MembershipContent(status: .join), + ) => + userId, + _ => null, + }, + ) + .nonNulls + .toIList(); + + return [?ref.watch(ClientStateController.provider)?.userId] + .toIList() + .addAll(powerLevelUsers) + .addAll(joinedMembers) + .map((userId) => userId.substring(userId.indexOf(":") + 1)) + .whereNoDuplicates() + .take(5) + .toISet(); + } + + static final provider = + NotifierProvider.family, Room>( + ViaController.new, + ); +} + +extension ViaQuery on ISet { + /// These servers as a matrix: URI query, or empty if there are none. + String get viaQuery => + isEmpty ? "" : "?${map((server) => "via=$server").join("&")}"; +} diff --git a/lib/helpers/current_location_helper.dart b/lib/helpers/current_location_helper.dart new file mode 100644 index 0000000..dbdb9e8 --- /dev/null +++ b/lib/helpers/current_location_helper.dart @@ -0,0 +1,82 @@ +import "dart:async"; +import "dart:io"; + +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:geolocator/geolocator.dart"; +import "package:latlong2/latlong.dart"; +import "package:nexus/controllers/portal.dart"; +import "package:nexus/models/location_exception.dart"; + +class CurrentLocationHelper(final Ref ref) { + static const _timeLimit = Duration(seconds: 10); + + /// The user's location and its accuracy in meters, or null if [askPermission] + /// is off and they haven't allowed it. + Future<(LatLng, double?)?> get({required bool askPermission}) async { + // geolocator talks to GeoClue directly, which Flatpak's sandbox blocks, so + // Linux uses the location portal, which also asks the user for permission + if (Platform.isLinux) { + // The portal can't say whether it's allowed without asking + if (!askPermission) return null; + + try { + final portal = await ref.read(PortalController.provider.future); + final location = await portal.location + .createSession(accuracy: .exact) + .first + .timeout(_timeLimit); + + return switch ((location.latitude, location.longitude)) { + (final latitude?, final longitude?) => ( + LatLng(latitude, longitude), + location.accuracy, + ), + _ => throw LocationException.failed, + }; + } on TimeoutException { + throw LocationException.timedOut; + } catch (_) { + throw LocationException.failed; + } + } + + if (!await Geolocator.isLocationServiceEnabled()) { + if (!askPermission) return null; + throw LocationException.servicesOff; + } + + var permission = await Geolocator.checkPermission(); + if (permission == .denied && askPermission) { + permission = await Geolocator.requestPermission(); + } + if (permission == .denied || permission == .deniedForever) { + if (!askPermission) return null; + throw LocationException.denied; + } + + Position? position; + try { + position = await Geolocator.getCurrentPosition( + locationSettings: Platform.isAndroid + // Play services' location can hang without Google, like on microG, + // so this uses Android's own, as other apps do + ? AndroidSettings(forceLocationManager: true, timeLimit: _timeLimit) + : const LocationSettings(timeLimit: _timeLimit), + ); + } on TimeoutException { + // A fix can take long indoors, but another app may have a recent one + position = await Geolocator.getLastKnownPosition( + forceAndroidLocationManager: true, + ); + } catch (_) { + throw LocationException.failed; + } + if (position == null) throw LocationException.timedOut; + + return (LatLng(position.latitude, position.longitude), position.accuracy); + } + + static final provider = Provider( + CurrentLocationHelper.new, + ); +} diff --git a/lib/helpers/extensions/better_when.dart b/lib/helpers/extensions/better_when.dart index beaae85..459d139 100644 --- a/lib/helpers/extensions/better_when.dart +++ b/lib/helpers/extensions/better_when.dart @@ -1,17 +1,19 @@ import "package:flutter/widgets.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/widgets/error_dialog.dart"; -import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; extension BetterWhen on AsyncValue { Widget betterWhen({ required Widget Function(T value) data, Widget Function() loading = Loading.new, bool skipLoadingOnRefresh = false, + bool skipLoadingOnReload = false, }) => when( data: data, error: (error, stackTrace) => ErrorDialog(error, stackTrace), loading: loading, skipLoadingOnRefresh: skipLoadingOnRefresh, + skipLoadingOnReload: skipLoadingOnReload, ); } diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart new file mode 100644 index 0000000..a6ecc06 --- /dev/null +++ b/lib/helpers/extensions/build_event_options.dart @@ -0,0 +1,314 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/pinned_ids.dart"; +import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/recent_emoji.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/via.dart"; +import "package:nexus/helpers/extensions/event_id_is_fake.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/emoji/emoji_picker.dart"; +import "package:nexus/widgets/emoji/mxc_emoji.dart"; +import "package:nexus/main.dart"; + +extension BuildEventOptions on Event { + IList buildEventOptions({ + required BuildContext context, + required WidgetRef ref, + required String roomId, + required String userId, + required void Function(int, RelationType) onRelation, + void Function(String threadRoot)? onStartThread, + }) { + final theme = Theme.of(context); + final danger = theme.colorScheme.error; + final isDesktop = context.isDesktop; + + final notifier = RoomChatController.provider(( + roomId: roomId, + contextualEvent: null, + threadRoot: null, + )).notifier; + + final isPinned = ref + .read(PinnedIdsController.provider(roomId)) + .contains(eventId); + final canSend = ref.read( + PowerLevelController.provider(.new(eventType: .message, roomId: roomId)), + ); + + void showReasonSheet({ + required String title, + required String description, + required String action, + required Future Function(String reason) onConfirm, + }) { + context.showAdaptiveSheet( + (context) => HookBuilder( + builder: (context) { + final reasonController = useTextEditingController(); + + return SheetLayout( + title: Text(title), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: () { + final reason = reasonController.text; + Navigator.of(context).pop(); + + onConfirm(reason).onError(showError); + }, + child: Text(action), + ), + ], + children: [ + Text(description), + TextField( + controller: reasonController, + textCapitalization: .sentences, + decoration: .new(labelText: "Reason (optional)"), + ), + ], + ); + }, + ), + ); + } + + if (eventId.isFake) { + return [ + PopupMenuItem( + onTap: () => ref.read(notifier).hideClientSticky(rowId), + child: ListTile( + leading: Icon(Icons.delete, color: danger), + title: Text("Hide", style: .new(color: danger)), + ), + ), + ].lock; + } + + return .new([ + if (ref.read( + PowerLevelController.provider( + .new(eventType: .reaction, roomId: roomId), + ), + )) + PopupMenuItem( + enabled: false, + child: IconTheme( + data: theme.iconTheme, + child: Row( + mainAxisAlignment: isDesktop ? .center : .spaceEvenly, + children: [ + ...{ + // Custom emoji are sent without their shortcode, as only + // their mxc URL is recorded + ...ref + .read(RecentEmojiController.provider) + .map((entry) => entry.emoji), + "👍", + "🤣", + "😭", + "🤔", + "❤️", + } + .take(isDesktop ? 3 : 5) + .map( + (emoji) => IconButton( + icon: emoji.startsWith("mxc://") + ? SizedBox.square( + dimension: isDesktop ? 18 : 24, + child: MxcEmoji(Uri.parse(emoji)), + ) + : Text( + emoji, + style: .new(fontSize: isDesktop ? null : 18), + ), + onPressed: () async { + Navigator.of(context).pop(); + await ref + .read(notifier) + .sendReaction(emoji, this) + .onError(showError); + + await ref + .read(RecentEmojiController.provider.notifier) + .add(emoji) + .onError(showError); + }, + ), + ), + IconButton( + icon: const Icon(Icons.emoji_emotions), + onPressed: () { + Navigator.of(context).pop(); + + context.showAdaptiveSheet( + (context) => EmojiPicker( + roomId: roomId, + allowFreeText: true, + onSelection: (emoji, {shortcode, description}) { + Navigator.of(context).pop(); + ref + .read(notifier) + .sendReaction(emoji, this, shortcode: shortcode) + .onError(showError); + }, + ), + ); + }, + ), + ], + ), + ), + ), + + if (canSend) + PopupMenuItem( + onTap: () => onRelation(rowId, .reply), + child: const ListTile( + leading: Icon(Icons.reply), + title: Text("Reply"), + ), + ), + + if (onStartThread != null && + threadCount == 0 && + canSend && + relatesTo == null) + PopupMenuItem( + onTap: () => onStartThread(eventId), + child: const ListTile( + leading: Icon(Icons.forum), + title: Text("Start Thread"), + ), + ), + + if (sender == userId && + switch (content) { + LocationMessageContent() || + TextMessageContent(botCommand: _?) => false, + MessageContent() => true, + _ => false, + }) + PopupMenuItem( + onTap: () => onRelation(rowId, .edit), + child: const ListTile(leading: Icon(Icons.edit), title: Text("Edit")), + ), + + if (ref.read( + PowerLevelController.provider( + .state(eventType: .pinnedEvents, roomId: roomId), + ), + )) + PopupMenuItem( + onTap: () async { + try { + final pins = ref.read( + PinnedIdsController.provider(roomId).notifier, + ); + + if (isPinned) { + await pins.removePin(eventId); + } else { + await pins.addPin(eventId); + } + } catch (error, stackTrace) { + showError(error, stackTrace); + } + }, + child: ListTile( + leading: const Icon(Icons.push_pin), + title: Text(isPinned ? "Unpin Event" : "Pin Event"), + ), + ), + + PopupMenuItem( + onTap: () async { + final room = ref.read( + RoomsController.provider.select((rooms) => rooms[roomId]), + ); + if (room == null) return; + + final vias = ref.read(ViaController.provider(room)).viaQuery; + + await Clipboard.setData( + ClipboardData( + text: + "matrix:roomid/${room.metadata?.id.substring(1)}/e/${eventId.substring(1)}$vias", + ), + ); + }, + child: const ListTile( + leading: Icon(Icons.link), + title: Text("Copy Link"), + ), + ), + + if (content case MessageContent(:final body?)) + PopupMenuItem( + onTap: () => Clipboard.setData(ClipboardData(text: body)), + child: const ListTile( + leading: Icon(Icons.copy), + title: Text("Copy Text"), + ), + ), + + if (ref.read( + PowerLevelController.provider( + .redaction(targetUser: sender, roomId: roomId), + ), + )) + PopupMenuItem( + onTap: () => showReasonSheet( + title: "Delete Message", + description: + "Are you sure you want to delete this message? " + "This cannot be reversed.", + action: "Delete", + onConfirm: (reason) => + ref.read(notifier).deleteMessage(this, reason: reason), + ), + child: ListTile( + leading: Icon(Icons.delete, color: danger), + title: Text("Delete", style: .new(color: danger)), + ), + ), + + PopupMenuItem( + onTap: () => showReasonSheet( + title: "Report", + description: + "Report this event to your server administrators, " + "who can take action like banning this server or room.", + action: "Report", + onConfirm: (reason) => ref + .read(ClientController.provider.notifier) + .reportEvent( + .new( + roomId: roomId, + eventId: eventId, + reason: reason.isEmpty ? null : reason, + ), + ), + ), + child: ListTile( + leading: Icon(Icons.report, color: danger), + title: Text("Report", style: .new(color: danger)), + ), + ), + ]); + } +} diff --git a/lib/helpers/extensions/create_room.dart b/lib/helpers/extensions/create_room.dart new file mode 100644 index 0000000..44173ed --- /dev/null +++ b/lib/helpers/extensions/create_room.dart @@ -0,0 +1,111 @@ +import "dart:async"; + +import "package:collection/collection.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/sidebar_page.dart"; +import "package:nexus/controllers/spaces.dart"; +import "package:nexus/helpers/extensions/focus_room.dart"; +import "package:nexus/helpers/extensions/get_all_child_rooms.dart"; +import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; +import "package:nexus/models/requests/create_room.dart"; + +extension CreateRoom on BuildContext { + /// Creates a room, showing progress and errors in snackbars, and opens it + /// once it has synced. [request] may be a future, so any lookups needed to + /// build it happen while the progress snackbar is shown. + /// + /// Returns the created room's ID, or null if creating it failed. + Future createRoom( + CreateRoomRequest request, { + required String name, + }) async { + final container = ProviderScope.containerOf(this); + final messenger = ScaffoldMessenger.of(this); + + final result = await withProgressSnackbar("Creating $name...", () async { + final roomId = await container + .read(ClientController.provider.notifier) + .createRoom(request); + + // Wait for the room to show up in the room list (under DMs, if it's + // one) before opening it. + final synced = Completer(); + final subscription = container.listen( + SpacesController.provider.select( + (async) => + async.value?.any( + (space) => + space.id == roomId || + ((!request.isDirect || space.id == "dms") && + space.allChildRooms.any( + (room) => room.metadata?.id == roomId, + )), + ) ?? + false, + ), + (_, found) { + if (found && !synced.isCompleted) synced.complete(true); + }, + fireImmediately: true, + ); + + return ( + roomId, + await synced.future + .timeout(const .new(seconds: 15), onTimeout: () => false) + .whenComplete(subscription.close), + ); + }); + if (result == null) return null; + + final (roomId, synced) = result; + if (synced && await container.focusRoom(roomId)) { + container.read(SidebarPageController.provider.notifier).set(.rooms); + } else { + messenger.showSnackBar( + .new( + content: Text("Created $name, but it hasn't finished syncing yet."), + ), + ); + } + + return roomId; + } + + /// Opens the existing DM with [userId], or creates one if there isn't one. + Future startDirectMessage( + String userId, { + required String name, + bool encrypted = true, + String? inviteReason, + }) async { + final container = ProviderScope.containerOf(this); + final existing = container + .read(RoomsController.provider) + .values + .firstWhereOrNull((room) => room.metadata?.dmUserId == userId) + ?.metadata + ?.id; + + if (existing != null) { + if (await container.focusRoom(existing)) { + container.read(SidebarPageController.provider.notifier).set(.rooms); + } + return; + } + + await createRoom( + .new( + isDirect: true, + preset: .trustedPrivateChat, + invite: .new([userId]), + inviteReason: inviteReason, + initialState: encrypted ? .new([.encryption]) : const .empty(), + ), + name: "a DM with $name", + ); + } +} diff --git a/lib/helpers/extensions/displayname_color.dart b/lib/helpers/extensions/displayname_color.dart new file mode 100644 index 0000000..eb81d16 --- /dev/null +++ b/lib/helpers/extensions/displayname_color.dart @@ -0,0 +1,38 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/room_power_levels.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/helpers/extensions/string_to_color.dart"; + +extension DisplaynameColor on WidgetRef { + /// The color of [userId]'s name in [roomId], from their power level + /// (so everyone with the same level shares one) or their user ID, depending + /// on the user's settings. + Color displaynameColor(String roomId, String userId) => switch (watch( + SettingsController.provider.select((settings) => settings.value?.nameColor), + )) { + .user => userId.colorHash, + .powerLevel || null => powerLevelColor( + watch( + RoomPowerLevelsController.provider(roomId) + .select((levels) => levels.levelOf(userId)), + ), + ), + }; + + /// Like [displaynameColor], for when [userId]'s [level] is already known, + /// like a level they're being given. + Color displaynameColorAt(String userId, double level) => switch (watch( + SettingsController.provider.select((settings) => settings.value?.nameColor), + )) { + .user => userId.colorHash, + .powerLevel || null => powerLevelColor(level), + }; + + static Color powerLevelColor(double level) => HSLColor.fromAHSL( + 1, + level.isInfinite ? 30 : (210 + level / 50 * 137.50776) % 360, + 0.7, + 0.5, + ).toColor(); +} diff --git a/lib/helpers/extensions/distributor_name.dart b/lib/helpers/extensions/distributor_name.dart new file mode 100644 index 0000000..07c1be6 --- /dev/null +++ b/lib/helpers/extensions/distributor_name.dart @@ -0,0 +1,5 @@ +extension DistributorName on String { + String get distributorName => startsWith("nexus.federated.nexus") + ? "Google Play Services" + : split(".").last; +} diff --git a/lib/helpers/extensions/event_id_is_fake.dart b/lib/helpers/extensions/event_id_is_fake.dart new file mode 100644 index 0000000..2e660af --- /dev/null +++ b/lib/helpers/extensions/event_id_is_fake.dart @@ -0,0 +1,3 @@ +extension EventIdIsFake on String { + bool get isFake => startsWith("~") || startsWith("\$gomuks"); +} diff --git a/lib/helpers/extensions/focus_room.dart b/lib/helpers/extensions/focus_room.dart new file mode 100644 index 0000000..33111ea --- /dev/null +++ b/lib/helpers/extensions/focus_room.dart @@ -0,0 +1,46 @@ +import "package:collection/collection.dart"; +import "package:flutter_riverpod/experimental/mutation.dart"; +import "package:nexus/controllers/jump_request.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/spaces.dart"; +import "package:nexus/models/space.dart"; + +extension FocusRoom on MutationTarget { + Future focusRoom(String roomId, [int? eventRowId]) async { + final spaces = await container.read(SpacesController.provider.future); + + if (spaces.firstWhereOrNull((space) => space.id == roomId) case Space _?) { + await container + .read(KeyController.provider(KeyController.spaceKey).notifier) + .set(roomId); + return true; + } + + final parent = spaces.firstWhereOrNull( + (space) => + space.children.any((room) => room.metadata?.id == roomId) || + space.subSpaces.any( + (sub) => + sub.room.metadata?.id == roomId || + sub.children.any((room) => room.metadata?.id == roomId), + ), + ); + if (parent == null) return false; + + await container + .read(KeyController.provider(KeyController.spaceKey).notifier) + .set(parent.id); + + if (eventRowId != null) { + container + .read(JumpRequestController.provider.notifier) + .request(roomId, eventRowId); + } + + await container + .read(KeyController.provider(KeyController.roomKey).notifier) + .set(roomId); + + return true; + } +} diff --git a/lib/helpers/extensions/get_all_child_rooms.dart b/lib/helpers/extensions/get_all_child_rooms.dart new file mode 100644 index 0000000..7a17eec --- /dev/null +++ b/lib/helpers/extensions/get_all_child_rooms.dart @@ -0,0 +1,8 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/models/space.dart"; + +extension GetAllChildRooms on Space { + IList get allChildRooms => + children.addAll(subSpaces.expand((s) => s.children)); +} diff --git a/lib/helpers/extensions/get_filename.dart b/lib/helpers/extensions/get_filename.dart new file mode 100644 index 0000000..c0c20e4 --- /dev/null +++ b/lib/helpers/extensions/get_filename.dart @@ -0,0 +1,11 @@ +import "package:nexus/models/content/message.dart"; + +extension GetFilename on MessageContent { + String? get filename => switch (this) { + ImageMessageContent(:final filename) || + VideoMessageContent(:final filename) || + AudioMessageContent(:final filename) || + FileMessageContent(:final filename) => filename, + _ => null, + }; +} diff --git a/lib/helpers/extensions/get_headers.dart b/lib/helpers/extensions/get_headers.dart deleted file mode 100644 index e1bb5f3..0000000 --- a/lib/helpers/extensions/get_headers.dart +++ /dev/null @@ -1,7 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/header_controller.dart"; - -extension GetHeaders on WidgetRef { - Map get headers => - watch(HeaderController.provider).requireValue; -} diff --git a/lib/helpers/extensions/get_link_info.dart b/lib/helpers/extensions/get_link_info.dart new file mode 100644 index 0000000..28e999b --- /dev/null +++ b/lib/helpers/extensions/get_link_info.dart @@ -0,0 +1,70 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +/// A parsed Matrix link. +/// +/// [identifier] is the room ID, room alias or user ID (e.g. "#room:matrix.org"), +/// [eventId] is set for event permalinks (e.g. "$abc123"), and [via] holds any +/// `via` servers from the link. +typedef LinkInfo = ({String identifier, String? eventId, IList via}); + +extension GetLinkInfo on String { + /// Parses this string as a Matrix link. + /// + /// Supports: + /// - https://matrix.to/#/{id}[/{eventId}][?via=...] + /// - matrix:roomid/{id}[/e/{eventId}][?via=...] + /// - matrix:r/{alias}[/e/{eventId}][?via=...] + /// - matrix:u/{userId} + /// + /// Returns null if this is not a Matrix link. + LinkInfo? get linkInfo { + final uri = Uri.tryParse(trim()); + if (uri == null) return null; + + if ((uri.scheme == "http" || uri.scheme == "https") && + uri.host == "matrix.to") { + // matrix.to keeps everything, including the query, in the fragment, + // so parse the fragment as its own URI. + final inner = Uri.tryParse(uri.fragment); + final segments = inner?.pathSegments.where((s) => s.isNotEmpty).toList(); + if (inner == null || segments == null || segments.isEmpty) return null; + + return ( + identifier: segments.first, + eventId: segments.length > 1 && segments[1].startsWith(r"$") + ? segments[1] + : null, + via: .new(inner.queryParametersAll["via"] ?? []), + ); + } + + if (uri.scheme == "matrix") { + final segments = uri.pathSegments; + if (segments.length < 2 || segments[1].isEmpty) return null; + + final sigil = switch (segments[0].toLowerCase()) { + "r" => "#", + "roomid" => "!", + "u" => "@", + _ => null, + }; + if (sigil == null) return null; + + // Event segment is "e/{eventId}" (without the "$" sigil) and is + // only valid on room links. + final hasEvent = + sigil != "@" && + segments.length >= 4 && + segments[2].toLowerCase() == "e" && + segments[3].isNotEmpty; + + return ( + identifier: "$sigil${segments[1]}", + eventId: hasEvent ? "\$${segments[3]}" : null, + via: .new(uri.queryParametersAll["via"] ?? []), + ); + } + + return null; + } +} diff --git a/lib/helpers/extensions/get_localpart.dart b/lib/helpers/extensions/get_localpart.dart new file mode 100644 index 0000000..200d2d0 --- /dev/null +++ b/lib/helpers/extensions/get_localpart.dart @@ -0,0 +1,17 @@ +extension GetLocalpart on String { + String get localpart => length > 1 ? substring(1).split(":").first : "?"; + + /// Whether this looks like a valid Matrix user ID, e.g. `@user:example.com`. + bool get isValidUserId => RegExp(r"^@[^:\s]+:[^\s]+$").hasMatch(this); + + /// Whether this is a valid Matrix server name, e.g. `example.com:8448`. + bool get isValidServerName => RegExp( + r"^(\[[0-9a-f:.]+\]|[a-z0-9.-]{1,255})(:\d{1,5})?$", + caseSensitive: false, + ).hasMatch(this); + + String? get serverName { + final index = indexOf(":"); + return index == -1 ? null : substring(index + 1); + } +} diff --git a/lib/helpers/extensions/get_xcode_sdk.dart b/lib/helpers/extensions/get_xcode_sdk.dart new file mode 100644 index 0000000..58920c8 --- /dev/null +++ b/lib/helpers/extensions/get_xcode_sdk.dart @@ -0,0 +1,14 @@ +import "dart:io"; + +Future getXCodeTool({String? sdkType, String? findTool}) async { + final result = await Process.run("xcrun", [ + if (sdkType != null) ...["--sdk", sdkType], + if (findTool != null) ...["-f", findTool] else "--show-sdk-path", + ]); + + if (result.exitCode != 0) { + throw Exception("Failed to get ${sdkType ?? "XCode"} ${findTool ?? "SDK"}"); + } + + return result.stdout.toString().trim(); +} diff --git a/lib/helpers/extensions/gomuks_buffer.dart b/lib/helpers/extensions/gomuks_buffer.dart index 88cfd5a..14404b4 100644 --- a/lib/helpers/extensions/gomuks_buffer.dart +++ b/lib/helpers/extensions/gomuks_buffer.dart @@ -1,36 +1,41 @@ import "dart:convert"; import "dart:ffi"; import "dart:typed_data"; -import "package:ffi/ffi.dart"; + import "package:nexus/src/third_party/gomuks.g.dart"; extension GomuksOwnedBufferToX on GomuksOwnedBuffer { - Uint8List toBytes() { + Uint8List toBytesAndFree() { try { - if (base == nullptr || length <= 0) return Uint8List(0); - return Uint8List.fromList(base.asTypedList(length)); + if (base == nullptr || length <= 0) return .new(0); + return .fromList(base.asTypedList(length)); } finally { - calloc.free(base); + GomuksFreeBuffer(this); } } - dynamic toJson() => jsonDecode(utf8.decode(toBytes())); + dynamic toJsonAndFree() { + final bytes = toBytesAndFree(); + return bytes.isEmpty ? null : jsonDecode(utf8.decode(bytes)); + } } extension JsonToGomuksBuffer on Map { - Pointer toGomuksBufferPtr() { - final jsonString = json.encode(this); - final bytes = utf8.encode(jsonString); + GomuksOwnedBuffer toGomuksOwnedBuffer() { + final bytes = utf8.encode(json.encode(this)); + final buffer = GomuksAllocBuffer(bytes.length); + buffer.base.asTypedList(bytes.length).setAll(0, bytes); + return buffer; + } - final dataPtr = calloc(bytes.length); + Pointer toGomuksBufferPtr(Allocator allocator) { + final bytes = utf8.encode(json.encode(this)); + + final dataPtr = allocator(bytes.length); dataPtr.asTypedList(bytes.length).setAll(0, bytes); - final ptr = calloc(); - - ptr.ref - ..base = dataPtr - ..length = bytes.length; - - return ptr; + return allocator() + ..ref.base = dataPtr + ..ref.length = bytes.length; } } diff --git a/lib/helpers/extensions/image_pack_emoji.dart b/lib/helpers/extensions/image_pack_emoji.dart new file mode 100644 index 0000000..fe480d5 --- /dev/null +++ b/lib/helpers/extensions/image_pack_emoji.dart @@ -0,0 +1,29 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_emoji_picker/material_emoji_picker.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/image_pack.dart"; +import "package:nexus/widgets/emoji/mxc_emoji.dart"; + +extension ImagePackEmoji on ImagePack { + EmojiCategory? get emojiCategory => _category(emoticons); + EmojiCategory? get stickerCategory => _category(stickers); + + EmojiCategory? _category(IMap images) => images.isEmpty + ? null + : .new( + icon: MxcEmoji(avatar ?? images.values.first.url), + name: name, + emojis: images + .mapTo( + (shortcode, image) => Emoji( + widget: MxcEmoji(image.url, label: shortcode), + value: image.url.toString(), + aliases: .new([shortcode]), + description: image.body ?? shortcode, + tags: const .empty(), + data: image, + ), + ) + .toIList(), + ); +} diff --git a/lib/helpers/extensions/is_media.dart b/lib/helpers/extensions/is_media.dart new file mode 100644 index 0000000..dceea43 --- /dev/null +++ b/lib/helpers/extensions/is_media.dart @@ -0,0 +1,12 @@ +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/message.dart"; + +extension IsMedia on Content { + bool get isMedia => switch (this) { + ImageMessageContent() || + VideoMessageContent() || + AudioMessageContent() || + FileMessageContent() => true, + _ => false, + }; +} diff --git a/lib/helpers/extensions/is_shown_as_message.dart b/lib/helpers/extensions/is_shown_as_message.dart new file mode 100644 index 0000000..6df7d2c --- /dev/null +++ b/lib/helpers/extensions/is_shown_as_message.dart @@ -0,0 +1,19 @@ +import "package:nexus/models/content/encrypted.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/poll_start.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/event.dart"; + +extension IsShownAsMessage on Event { + /// Whether this is a message, sticker or poll. + bool get hasMessageContent => switch (content) { + MessageContent() || StickerContent() || PollStartContent() => true, + _ => false, + }; + + /// Whether this is shown in the timeline as a message, with MessageRenderer. + bool get isShownAsMessage => + redactedBy == null && + relationType != "m.replace" && + (hasMessageContent || content is EncryptedContent); +} diff --git a/lib/helpers/extensions/join_room.dart b/lib/helpers/extensions/join_room.dart new file mode 100644 index 0000000..03140f9 --- /dev/null +++ b/lib/helpers/extensions/join_room.dart @@ -0,0 +1,70 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/sidebar_page.dart"; +import "package:nexus/helpers/extensions/focus_room.dart"; +import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; +import "package:nexus/models/requests/join_room.dart"; + +extension JoinRoom on BuildContext { + /// Joins a room, showing progress, success (with a button to open the room) + /// and errors in snackbars. + /// + /// Returns the joined room's ID, or null if joining failed. + Future joinRoom( + JoinRoomRequest request, { + required String name, + }) async { + final container = ProviderScope.containerOf(this); + final messenger = ScaffoldMessenger.of(this); + final navigator = Navigator.of(this); + + final roomId = await withProgressSnackbar( + "Joining $name...", + () => + container.read(ClientController.provider.notifier).joinRoom(request), + ); + if (roomId == null) return null; + + messenger.showSnackBar( + .new( + content: Text("Joined $name."), + action: .new( + label: "Open", + onPressed: () async { + navigator.popUntil((route) => route.isFirst); + await container.focusRoom(roomId); + container.read(SidebarPageController.provider.notifier).set(.rooms); + }, + ), + ), + ); + + return roomId; + } + + /// Knocks on a room, success and errors in snackbars. + /// Returns the room's ID, or null if knocking failed. + Future knockRoom( + JoinRoomRequest request, { + required String name, + }) async { + final container = ProviderScope.containerOf(this); + final messenger = ScaffoldMessenger.of(this); + + final roomId = await withProgressSnackbar( + "Knocking on $name...", + () => + container.read(ClientController.provider.notifier).knockRoom(request), + ); + if (roomId == null) return null; + + messenger.showSnackBar( + .new( + content: Text("Knocked on $name. Wait for a moderator to let you in."), + ), + ); + + return roomId; + } +} diff --git a/lib/helpers/extensions/join_room_with_snackbars.dart b/lib/helpers/extensions/join_room_with_snackbars.dart deleted file mode 100644 index 05b045d..0000000 --- a/lib/helpers/extensions/join_room_with_snackbars.dart +++ /dev/null @@ -1,90 +0,0 @@ -import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/spaces_controller.dart"; -import "package:nexus/helpers/extensions/link_to_mention.dart"; -import "package:nexus/models/requests/join_room_request.dart"; - -extension JoinRoomWithSnackbars on ClientController { - Future joinRoomWithSnackBars( - BuildContext context, - String roomAlias, - WidgetRef ref, - ) async { - final roomIdOrAlias = roomAlias.mention ?? roomAlias; - - 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/join_rule_name.dart b/lib/helpers/extensions/join_rule_name.dart new file mode 100644 index 0000000..94c6001 --- /dev/null +++ b/lib/helpers/extensions/join_rule_name.dart @@ -0,0 +1,14 @@ +import "package:nexus/models/join_rule.dart"; + +extension JoinRuleName on JoinRule { + /// What the rule is called in the UI, e.g. "Invite only". + String get displayName => switch (this) { + .public => "Public", + .invite => "Invite only", + .knock => "Knock", + .restricted => "Restricted", + .knockRestricted => "Restricted or knock", + .private => "Private", + .unknown => "Unknown", + }; +} diff --git a/lib/helpers/extensions/link_to_mention.dart b/lib/helpers/extensions/link_to_mention.dart deleted file mode 100644 index b0e62aa..0000000 --- a/lib/helpers/extensions/link_to_mention.dart +++ /dev/null @@ -1,44 +0,0 @@ -extension LinkToMention on String { - /// Extracts a Matrix identifier from this string. - /// - /// Supports: - /// - https://matrix.to/#/... - /// - matrix:roomid/... - /// - matrix:r/... - /// - matrix:u/... - /// - /// Returns the decoded identifier (e.g. "#room:matrix.org") - /// or null if this is not a Matrix link. - String? get mention { - final trimmed = trim(); - - final matrixTo = RegExp( - r"^https?://matrix\.to/#/(.[^/?#]+)", - caseSensitive: false, - ); - - final matrixToMatch = matrixTo.firstMatch(trimmed); - if (matrixToMatch != null) { - return Uri.decodeComponent(matrixToMatch.group(1)!); - } - - if (trimmed.toLowerCase().startsWith("matrix:")) { - try { - final uri = Uri.parse(trimmed); - - if (uri.pathSegments.isNotEmpty) { - final identifier = uri.pathSegments.last; - if (identifier.isNotEmpty) { - return "${switch (uri.pathSegments.firstOrNull) { - "r" || "roomid" => "#", - "u" => "@", - _ => "", - }}${Uri.decodeComponent(identifier)}"; - } - } - } catch (_) {} - } - - return null; - } -} diff --git a/lib/helpers/extensions/linkify_html.dart b/lib/helpers/extensions/linkify_html.dart new file mode 100644 index 0000000..f7eec0c --- /dev/null +++ b/lib/helpers/extensions/linkify_html.dart @@ -0,0 +1,45 @@ +import "package:html/dom.dart"; +import "package:html/parser.dart"; +import "package:linkify/linkify.dart"; +import "package:nexus/helpers/matrix_linkifier.dart"; + +extension LinkifyHtml on String { + /// Wraps bare links in this HTML's text in `` tags. + /// + /// Only text nodes are touched, so attribute values are left alone, and + /// text already inside a link or code is skipped. + String get linkifiedHtml { + final fragment = parseFragment(this); + _linkifyNode(fragment); + return fragment.outerHtml; + } +} + +void _linkifyNode(Node node) { + if (node is Element && const {"a", "code", "pre"}.contains(node.localName)) { + return; + } + + for (final child in node.nodes.toList()) { + if (child is! Text) { + _linkifyNode(child); + continue; + } + + final elements = linkifyText(child.data); + if (!elements.any((element) => element is LinkableElement)) continue; + + elements + .map( + (element) => switch (element) { + LinkableElement(:final text, :final url) => + Element.tag("a") + ..attributes["href"] = url + ..append(Text(text)), + _ => Text(element.text), + }, + ) + .forEach((replacement) => node.insertBefore(replacement, child)); + child.remove(); + } +} diff --git a/lib/helpers/extensions/log_out.dart b/lib/helpers/extensions/log_out.dart new file mode 100644 index 0000000..a170857 --- /dev/null +++ b/lib/helpers/extensions/log_out.dart @@ -0,0 +1,55 @@ +import "package:collection/collection.dart"; +import "package:flutter_riverpod/experimental/mutation.dart"; +import "package:nexus/controllers/accounts.dart"; +import "package:nexus/controllers/active_account.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/init_complete.dart"; +import "package:nexus/controllers/unified_push.dart"; +import "package:nexus/main.dart"; + +extension LogOut on MutationTarget { + static final mutation = Mutation(); + + Future logOut(String accountId) => mutation.run(this, (_) async { + final accounts = await container.read(AccountsController.provider.future); + final account = accounts.firstWhereOrNull( + (account) => account.id == accountId, + ); + if (account == null) return; + + final isActive = + accountId == container.read(ActiveAccountController.provider); + final client = isActive + ? container.read(ClientController.provider.notifier) + : container.read(ClientController.forAccount(accountId).notifier); + + if (account.deviceId case final deviceId?) { + await container + .read(UnifiedPushController.provider.notifier) + .deregister(deviceId) + .onError(showError); + } + if (isActive) container.invalidate(InitCompleteController.provider); + await client.logout().onError(showError); + + if (!isActive) { + container.invalidate(ClientController.forAccount(accountId)); + await container + .read(AccountsController.provider.notifier) + .remove(accountId); + return; + } + + final other = accounts.firstWhereOrNull( + (account) => account.id != accountId, + ); + if (other == null) return; + + await container + .read(AccountsController.provider.notifier) + .remove(accountId); + await container + .read(ActiveAccountController.provider.notifier) + .switchTo(other.id); + }); +} diff --git a/lib/helpers/extensions/mxc_to_https.dart b/lib/helpers/extensions/mxc_to_https.dart deleted file mode 100644 index 468da12..0000000 --- a/lib/helpers/extensions/mxc_to_https.dart +++ /dev/null @@ -1,4 +0,0 @@ -extension MxcToHttps on Uri { - Uri mxcToHttps(String homeserver) => - Uri.parse("${homeserver}_matrix/client/v1/media/download/$host$path"); -} diff --git a/lib/helpers/extensions/open_link_info.dart b/lib/helpers/extensions/open_link_info.dart new file mode 100644 index 0000000..60947bb --- /dev/null +++ b/lib/helpers/extensions/open_link_info.dart @@ -0,0 +1,49 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/event.dart"; +import "package:nexus/controllers/room_summary.dart"; +import "package:nexus/helpers/extensions/focus_room.dart"; +import "package:nexus/helpers/extensions/get_link_info.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/room_summary.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; + +extension OpenLinkInfo on WidgetRef { + Future openLinkInfo( + BuildContext context, + LinkInfo linkInfo, { + String? roomId, + }) async { + final identifier = linkInfo.identifier; + if (identifier.startsWith("@")) { + context.showUserPopover(identifier, roomId: roomId); + return; + } + + final request = RoomSummaryController.provider( + .new(roomIdOrAlias: identifier, via: linkInfo.via), + ); + final summary = await read(request.future) + .onError((_, _) => RoomSummary(roomId: identifier)); + + final eventRowId = linkInfo.eventId == null + ? null + : await read( + EventController.provider( + .new(roomId: summary.roomId, eventId: linkInfo.eventId!), + ).future, + ).onError((_, _) => null); + + if (await focusRoom(summary.roomId, eventRowId) || !context.mounted) return; + + await context.showAdaptiveSheet( + (context) => Consumer( + builder: (context, ref, _) => RoomSummarySheet( + ref.watch(request).whenOrNull(data: (data) => data) ?? summary, + via: linkInfo.via, + ), + ), + ); + } +} diff --git a/lib/helpers/extensions/refocus_after_rebuild.dart b/lib/helpers/extensions/refocus_after_rebuild.dart new file mode 100644 index 0000000..a9ce60d --- /dev/null +++ b/lib/helpers/extensions/refocus_after_rebuild.dart @@ -0,0 +1,9 @@ +import "package:material_ui/material_ui.dart"; + +extension RefocusAfterRebuild on FocusNode { + /// Focuses this again once the text field using it has been replaced. + void refocusAfterRebuild() { + unfocus(); + WidgetsBinding.instance.addPostFrameCallback((_) => requestFocus()); + } +} diff --git a/lib/helpers/extensions/room_destinations.dart b/lib/helpers/extensions/room_destinations.dart new file mode 100644 index 0000000..3d0323b --- /dev/null +++ b/lib/helpers/extensions/room_destinations.dart @@ -0,0 +1,31 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; + +extension RoomDestination on Room { + NavigationRailM3EDestination toDestination({ + required bool isDm, + String? label, + Widget? icon, + }) => NavigationRailM3EDestination( + label: label ?? metadata?.name ?? "Unnamed Room", + badgeCount: switch (metadata?.unreadNotifications) { + 0 || null => metadata?.unreadMessages == 0 ? null : 0, + int unread => unread, + }, + icon: + icon ?? + AvatarOrHash( + metadata?.avatar, + metadata?.name ?? "Unnamed Room", + fallback: isDm ? null : const Icon(Icons.numbers), + ), + ); +} + +extension RoomDestinations on IList { + List toDestinations({required bool isDm}) => + map((room) => room.toDestination(isDm: isDm)).toList(); +} diff --git a/lib/helpers/extensions/room_image_packs.dart b/lib/helpers/extensions/room_image_packs.dart new file mode 100644 index 0000000..9b9d9ce --- /dev/null +++ b/lib/helpers/extensions/room_image_packs.dart @@ -0,0 +1,34 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/image_pack.dart"; +import "package:nexus/models/room.dart"; + +extension RoomImagePacks on Room { + /// The room's image packs by state key, preferring the stable type when a + /// pack has both, and leaving out deleted ones. [roomId] names those without + /// a name when the room has none either. + IMap imagePacks(String roomId) => .fromEntries( + const [EventType.legacyImagePack, EventType.imagePack].expand( + (type) => (state[type.type] ?? .new()).entries + .map( + (entry) => switch (events[entry.value]) { + Event(redactedBy: null, content: final ImagePackContent content) + when content.hasContent => + MapEntry( + entry.key, + ImagePack.fromContent( + content, + roomId: roomId, + roomName: metadata?.name ?? roomId, + stateKey: entry.key, + ), + ), + _ => null, + }, + ) + .nonNulls, + ), + ); +} diff --git a/lib/helpers/extensions/room_summary_display.dart b/lib/helpers/extensions/room_summary_display.dart new file mode 100644 index 0000000..7b59a16 --- /dev/null +++ b/lib/helpers/extensions/room_summary_display.dart @@ -0,0 +1,18 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/room_summary.dart"; + +extension RoomSummaryDisplay on RoomSummary { + bool get isSpace => roomType == RoomType.space; + + IconData get icon => isSpace ? Icons.workspaces : Icons.numbers; + + String get displayName => + name ?? canonicalAlias ?? (isSpace ? "Unnamed Space" : "Unnamed Room"); + + /// E.g. "Space · 12 members" + String get details => [ + if (isSpace) "Space", + if (joinedMembers case final members?) "$members members", + ].join(" · "); +} diff --git a/lib/helpers/extensions/room_version_at_least.dart b/lib/helpers/extensions/room_version_at_least.dart new file mode 100644 index 0000000..f66d573 --- /dev/null +++ b/lib/helpers/extensions/room_version_at_least.dart @@ -0,0 +1,8 @@ +extension RoomVersionAtLeast on String? { + /// Whether this room version is [version] or newer. Unknown ones, like + /// unstable versions, are assumed to be new enough. + bool versionAtLeast(int version) => switch (int.tryParse(this ?? "")) { + final number? => number >= version, + null => true, + }; +} diff --git a/lib/helpers/extensions/save_attachment.dart b/lib/helpers/extensions/save_attachment.dart new file mode 100644 index 0000000..1fb048f --- /dev/null +++ b/lib/helpers/extensions/save_attachment.dart @@ -0,0 +1,34 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/show_error_snack_bar.dart"; +import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; +import "package:nexus/helpers/file_dialog_helper.dart"; + +extension SaveAttachment on BuildContext { + /// Downloads the media at [mxc], displaying a snackbar whilst it's donwnloading, + /// then lets the user pick where to save it, suggesting [filename]. + Future saveAttachment( + WidgetRef ref, + Uri mxc, { + required bool encrypted, + String? filename, + }) async { + final file = await withProgressSnackbar( + "Downloading file...", + () => ref + .read(ClientController.provider.notifier) + .downloadMedia(.new(mxc: mxc, encrypted: encrypted)), + ); + if (file == null || !mounted) return; + + final messenger = ScaffoldMessenger.of(this); + try { + await ref + .read(FileDialogHelper.provider) + .saveFile(file, filename ?? mxc.pathSegments.last); + } catch (error) { + messenger.showErrorSnackBar(error.toString()); + } + } +} diff --git a/lib/helpers/extensions/scheme_to_theme.dart b/lib/helpers/extensions/scheme_to_theme.dart index aff5d52..73ce176 100644 --- a/lib/helpers/extensions/scheme_to_theme.dart +++ b/lib/helpers/extensions/scheme_to_theme.dart @@ -1,18 +1,47 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; extension SchemeToTheme on ColorScheme { - ThemeData get theme => ThemeData.from(colorScheme: this).copyWith( - cardTheme: CardThemeData(color: primaryContainer), - appBarTheme: AppBarTheme( - titleSpacing: 0, - backgroundColor: surfaceContainerLow, - ), - textTheme: ThemeData( - fontFamilyFallback: ["sans"], + ThemeData get theme { + final textTheme = ThemeData( + fontFamily: "bundled-sans", + fontFamilyFallback: [ + "emoji", + // TODO: Remove once on Flutter 3.50, whose Linux arm64 engine uses + // fontconfig and so understands the "emoji" alias + "Noto Color Emoji", + "Twemoji", + ], brightness: brightness, - ).textTheme, - inputDecorationTheme: const InputDecorationTheme( - border: OutlineInputBorder(), - ), - ); + ).textTheme; + return .from(colorScheme: this).copyWith( + cardTheme: .new(color: primaryContainer), + badgeTheme: .new(backgroundColor: primary, textColor: onPrimary), + popupMenuTheme: .new( + shape: RoundedRectangleBorder(borderRadius: .circular(16)), + color: surfaceContainerHigh, + ), + // So ink at the edges of a sheet doesn't spill past its rounded corners + bottomSheetTheme: .new(clipBehavior: .antiAlias), + appBarTheme: AppBarTheme( + titleSpacing: 0, + backgroundColor: surfaceContainerLow, + ), + tooltipTheme: .new( + textStyle: textTheme.labelLarge?.copyWith( + fontSize: 16, + fontWeight: .w600, + ), + padding: .all(8), + decoration: BoxDecoration( + color: surfaceContainerHighest, + borderRadius: .circular(8), + ), + ), + iconButtonTheme: .new(variant: .material3Expressive), + textTheme: textTheme, + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder(), + ), + ); + } } diff --git a/lib/helpers/extensions/send_failed.dart b/lib/helpers/extensions/send_failed.dart new file mode 100644 index 0000000..cb0ba78 --- /dev/null +++ b/lib/helpers/extensions/send_failed.dart @@ -0,0 +1,6 @@ +import "package:nexus/models/event.dart"; + +extension SendFailed on Event { + /// gomuks marks events that are still sending as "not sent" (and often forgets to clear it at all) + bool get sendFailed => sendError != null && sendError != "not sent"; +} diff --git a/lib/helpers/extensions/show_about_sheet.dart b/lib/helpers/extensions/show_about_sheet.dart new file mode 100644 index 0000000..951b07d --- /dev/null +++ b/lib/helpers/extensions/show_about_sheet.dart @@ -0,0 +1,89 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_svg/flutter_svg.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:m3e_card_list/m3e_card_list.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/helpers/launch_helper.dart"; +import "package:package_info_plus/package_info_plus.dart"; + +extension ShowAboutSheet on BuildContext { + Future showAboutSheet(WidgetRef ref) async { + final packageInfo = await PackageInfo.fromPlatform(); + + if (mounted) { + showAdaptiveSheet( + (context) => SheetLayout( + actions: [ + TextButton( + onPressed: () => showLicensePage(context: context), + child: Text("View licenses"), + ), + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Close"), + ), + ], + children: [ + Column( + mainAxisSize: .min, + spacing: 16, + children: [ + Row( + spacing: 12, + children: [ + SvgPicture.asset("assets/bundled/icon.svg", width: 64), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + Wrap( + crossAxisAlignment: .center, + spacing: 4, + children: [ + Text( + "Nexus", + style: Theme.of(context) + .textTheme + .headlineMedium, + ), + Text( + "(${packageInfo.version}+${const String.fromEnvironment("GIT_COMMIT", defaultValue: "dev")})", + ), + ], + ), + + Text( + "A simple and user-friendly Matrix client", + overflow: .ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ), + ], + ), + M3ECardColumn( + onTap: (index) => + ref.read(LaunchHelper.provider).launchUrl(switch (index) { + 0 => .https("git.federated.nexus", "nexus/nexus"), + _ => .https("liberapay.com", "QuadRadical"), + }), + children: [ + ListTile( + leading: Icon(Icons.commit), + title: Text("Source Code"), + ), + ListTile( + leading: Icon(Icons.favorite, color: Colors.pinkAccent), + title: Text("Donate"), + ), + ], + ), + ], + ), + ], + ), + ); + } + } +} diff --git a/lib/helpers/extensions/show_context_menu.dart b/lib/helpers/extensions/show_context_menu.dart deleted file mode 100644 index f4762c3..0000000 --- a/lib/helpers/extensions/show_context_menu.dart +++ /dev/null @@ -1,22 +0,0 @@ -import "package:flutter/material.dart"; - -extension ShowContextMenu on BuildContext { - void showContextMenu({ - required Offset globalPosition, - required List children, - }) { - final overlay = Overlay.of(this).context.findRenderObject() as RenderBox; - - showMenu( - context: this, - 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_error_snack_bar.dart b/lib/helpers/extensions/show_error_snack_bar.dart new file mode 100644 index 0000000..4af01c5 --- /dev/null +++ b/lib/helpers/extensions/show_error_snack_bar.dart @@ -0,0 +1,28 @@ +import "package:material_ui/material_ui.dart"; + +extension ShowErrorSnackBar on ScaffoldMessengerState { + /// Shows [message] in a snackbar styled as an error. + ScaffoldFeatureController showErrorSnackBar( + String message, { + ({String label, VoidCallback onPressed})? action, + }) { + final colorScheme = Theme.of(context).colorScheme; + + return showSnackBar( + .new( + backgroundColor: colorScheme.errorContainer, + content: Text( + message, + style: .new(color: colorScheme.onErrorContainer), + ), + action: action == null + ? null + : .new( + label: action.label, + onPressed: action.onPressed, + textColor: colorScheme.onErrorContainer, + ), + ), + ); + } +} diff --git a/lib/helpers/extensions/show_image_viewer.dart b/lib/helpers/extensions/show_image_viewer.dart new file mode 100644 index 0000000..9be95b8 --- /dev/null +++ b/lib/helpers/extensions/show_image_viewer.dart @@ -0,0 +1,83 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:m3e_buttons/m3e_buttons.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; +import "package:nexus/helpers/extensions/size_to_string.dart"; +import "package:nexus/helpers/mxc_image.dart"; +import "package:nexus/models/requests/download_media.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; + +extension ShowImageViewer on BuildContext { + /// Shows the image from [request] fullscreen, zoomable. + /// + /// If [downloadFilename] is set, a download button is shown, suggesting it. + void showImageViewer( + WidgetRef ref, + DownloadMediaRequest request, { + String? downloadFilename, + int? size, + }) => showDialog( + context: this, + builder: (context) => 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 ${request.mxc}\nError: $error", + stackTrace, + ), + image: MxcImage(ref, request), + ), + ), + ), + ), + if (size != null) + Align( + alignment: .topLeft, + child: Padding( + padding: .all(32), + child: Card( + margin: .zero, + child: Padding( + padding: .symmetric(horizontal: 12, vertical: 8), + child: Text(size.sizeAsString), + ), + ), + ), + ), + Align( + alignment: .topRight, + child: Padding( + padding: .all(32), + child: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + if (downloadFilename != null) + M3EButton( + onPressed: () => context.saveAttachment( + ref, + request.mxc, + encrypted: request.encrypted, + filename: downloadFilename, + ), + child: Icon(Icons.download), + ), + M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), + ), + ], + ), + ), + ), + ], + ), + ), + ); +} diff --git a/lib/helpers/extensions/show_leave_room_sheet.dart b/lib/helpers/extensions/show_leave_room_sheet.dart new file mode 100644 index 0000000..42ff87f --- /dev/null +++ b/lib/helpers/extensions/show_leave_room_sheet.dart @@ -0,0 +1,37 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; + +extension ShowLeaveRoomSheet on BuildContext { + /// Asks the user to confirm leaving the room, then leaves it. + Future showLeaveRoomSheet(String roomId, {required String name}) => + showAdaptiveSheet( + (context) => SheetLayout( + title: const Text("Leave Room"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: () { + final client = ProviderScope.containerOf(context) + .read(ClientController.provider.notifier); + context.withProgressSnackbar( + "Leaving $name...", + () => client.leaveRoom(roomId), + ); + Navigator.of(context).pop(); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text("Leave"), + ), + ], + children: [Text("Are you sure you want to leave \"$name\"?")], + ), + ); +} diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart new file mode 100644 index 0000000..e7018c0 --- /dev/null +++ b/lib/helpers/extensions/show_user_popover.dart @@ -0,0 +1,11 @@ +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/widgets/users/user_bottom_sheet.dart"; + +extension ShowUserPopover on BuildContext { + void showUserPopover(String userId, {String? roomId, Rect? anchor}) => + showAdaptivePopover( + (context) => UserBottomSheet(userId, roomId: roomId), + anchor: anchor, + ); +} diff --git a/lib/helpers/extensions/size_to_string.dart b/lib/helpers/extensions/size_to_string.dart new file mode 100644 index 0000000..a9db345 --- /dev/null +++ b/lib/helpers/extensions/size_to_string.dart @@ -0,0 +1,15 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +extension SizeToString on int { + String get sizeAsString { + const suffixes = IListConst(["B", "KB", "MB", "GB", "TB", "PB"]); + + var i = 0; + var size = toDouble(); + while (size > 1024 && i < suffixes.length - 1) { + size /= 1024; + i++; + } + return "${size.toStringAsFixed(2)} ${suffixes[i]}"; + } +} diff --git a/lib/helpers/extensions/string_to_color.dart b/lib/helpers/extensions/string_to_color.dart new file mode 100644 index 0000000..a21ca82 --- /dev/null +++ b/lib/helpers/extensions/string_to_color.dart @@ -0,0 +1,6 @@ +import "package:color_hash/color_hash.dart"; +import "package:material_ui/material_ui.dart"; + +extension ToColor on String { + Color get colorHash => ColorHash(this, lightness: .5, saturation: .7).color; +} diff --git a/lib/helpers/extensions/success_scheme.dart b/lib/helpers/extensions/success_scheme.dart new file mode 100644 index 0000000..77f83ae --- /dev/null +++ b/lib/helpers/extensions/success_scheme.dart @@ -0,0 +1,11 @@ +import "package:dynamic_color/dynamic_color.dart"; +import "package:material_ui/material_ui.dart"; + +extension SuccessScheme on ThemeData { + /// Green colors for positive things, like accepting knocks or new pins, + /// harmonized so they fit the current theme. + ColorScheme get successScheme => ColorScheme.fromSeed( + seedColor: Colors.green.harmonizeWith(colorScheme.primary), + brightness: brightness, + ); +} diff --git a/lib/helpers/extensions/thread_events.dart b/lib/helpers/extensions/thread_events.dart new file mode 100644 index 0000000..31c7808 --- /dev/null +++ b/lib/helpers/extensions/thread_events.dart @@ -0,0 +1,50 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:nexus/helpers/extensions/event_id_is_fake.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/room.dart"; + +typedef ThreadInfo = ({int count, Event? latest}); +typedef Thread = ({Event root, ThreadInfo info}); + +extension ThreadEvents on Room { + /// The threads in the loaded events, most recently active first. + IList get threads { + DateTime lastActive(Thread thread) => + thread.info.latest?.timestamp ?? thread.root.timestamp; + + return events.values + .where((event) => event.threadCount > 0) + .map((root) => (root: root, info: thread(root))) + .sorted((a, b) => lastActive(b).compareTo(lastActive(a))) + .toIList(); + } + + /// The events in the thread from [threadRoot], oldest first, without the + /// root itself. + IList threadEvents(String threadRoot) => + (relations[threadRoot] ?? const .empty()) + .map((rowId) => events[rowId]) + .nonNulls + .where( + (event) => + event.threadRoot == threadRoot && + (!event.eventId.isFake || clientSticky.contains(event.rowId)), + ) + .sorted((a, b) => a.timestamp.compareTo(b.timestamp)) + .toIList(); + + /// How many replies the thread from [root] has, and its latest event, from + /// gomuks' aggregate on it + ThreadInfo thread(Event root) => + (count: root.threadCount, latest: events[root.threadLatestRowId]); + + /// The latest event in the thread from [threadRoot], if it's loaded. + Event? latestThreadEvent(String threadRoot) => switch (eventIds[threadRoot]) { + final rowId? => switch (events[rowId]) { + final root? => thread(root).latest, + null => null, + }, + null => null, + }; +} diff --git a/lib/helpers/extensions/weighted_search.dart b/lib/helpers/extensions/weighted_search.dart new file mode 100644 index 0000000..1f76b0a --- /dev/null +++ b/lib/helpers/extensions/weighted_search.dart @@ -0,0 +1,47 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +extension WeightedSearch on Iterable { + /// The items with a field matching [query], best match first. + IList search( + String query, + Iterable<(String?, int)> Function(T item) fieldsOf, + ) { + final needle = query.trim().toLowerCase(); + if (needle.isEmpty) return toIList(); + + int qualityOf(String text) { + final haystack = text.toLowerCase(); + if (haystack == needle) return 4; + if (haystack.startsWith(needle)) return 3; + if (haystack + .split(RegExp(r"[\s_\-:/.@#!]+")) + .any((w) => w.startsWith(needle))) { + return 2; + } + if (haystack.contains(needle)) return 1; + return 0; + } + + return .new( + map( + (item) => ( + item, + fieldsOf(item) + .map( + (field) => switch (field) { + (final text?, final weight) => + qualityOf(text) * weight, + (null, _) => 0, + }, + ) + .maxOrNull ?? + 0, + ), + ) + .where((scored) => scored.$2 > 0) + .sortedBy((scored) => -scored.$2) + .map((scored) => scored.$1), + ); + } +} diff --git a/lib/helpers/extensions/with_latest_edit.dart b/lib/helpers/extensions/with_latest_edit.dart new file mode 100644 index 0000000..6b07313 --- /dev/null +++ b/lib/helpers/extensions/with_latest_edit.dart @@ -0,0 +1,20 @@ +import "package:nexus/models/event.dart"; +import "package:nexus/models/room.dart"; + +extension WithLatestEdit on Room { + Event? withLatestEdit(int rowId) { + final event = events[rowId]; + if (event == null) return null; + + final edited = event.lastEditRowId == 0 + ? null + : events[event.lastEditRowId]; + + return edited == null + ? event + : event.copyWith( + content: edited.content, + localContent: edited.localContent, + ); + } +} diff --git a/lib/helpers/extensions/with_progress_snackbar.dart b/lib/helpers/extensions/with_progress_snackbar.dart new file mode 100644 index 0000000..c6d8fa0 --- /dev/null +++ b/lib/helpers/extensions/with_progress_snackbar.dart @@ -0,0 +1,28 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/show_error_snack_bar.dart"; + +extension WithProgressSnackbar on BuildContext { + /// Shows [progress] in a snackbar while [action] runs, and shows any error + /// it throws in an error snackbar. + /// + /// Returns the result of [action], or null if it failed. + Future withProgressSnackbar( + String progress, + Future Function() action, + ) async { + final messenger = ScaffoldMessenger.of(this); + final snackbar = messenger.showSnackBar( + .new(content: Text(progress), duration: const .new(days: 1)), + ); + + try { + return await action(); + } catch (error) { + messenger.showErrorSnackBar(error.toString()); + + return null; + } finally { + snackbar.close(); + } + } +} diff --git a/lib/helpers/file_dialog_helper.dart b/lib/helpers/file_dialog_helper.dart new file mode 100644 index 0000000..de93d12 --- /dev/null +++ b/lib/helpers/file_dialog_helper.dart @@ -0,0 +1,93 @@ +import "dart:io"; + +import "package:file_selector/file_selector.dart"; +import "package:flutter_file_dialog/flutter_file_dialog.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:mime/mime.dart"; +import "package:nexus/controllers/image_picker.dart"; +import "package:nexus/controllers/portal.dart"; +import "package:xdg_desktop_portal/xdg_desktop_portal.dart"; + +class FileDialogHelper(final Ref ref) { + Future saveFile(File file, String filename) async { + final mimeType = lookupMimeType( + file.path, + headerBytes: await file + .openRead(0, defaultMagicNumbersMaxLength) + .expand((bytes) => bytes) + .toList(), + ); + + final filenameWithExtension = mimeType == null + ? filename + : "$filename.${extensionFromMime(mimeType)}"; + + if (Platform.isAndroid || Platform.isIOS) { + return await FlutterFileDialog.saveFile( + params: .new( + sourceFilePath: file.path, + fileName: filenameWithExtension, + ), + ) != + null; + } + + final destination = Platform.isLinux + ? await getLinuxSaveLocation(filenameWithExtension) + : (await getSaveLocation(suggestedName: filenameWithExtension))?.path; + if (destination == null) return false; + + await file.copy(destination); + return true; + } + + Future getLinuxSaveLocation(String filename) async { + final portal = await ref.read(PortalController.provider.future); + + try { + final result = await portal.fileChooser + .saveFile(title: "Save file", currentName: filename) + .first; + + return switch (result.uris.firstOrNull) { + final uri? => Uri.parse(uri).toFilePath(), + null => null, + }; + } on XdgPortalRequestCancelledException catch (_) { + return null; + } + } + + /// Lets the user pick a file, or only images if [images] is true. Returns + /// null if the user cancelled. + Future pickFile({bool images = false}) async { + if (!Platform.isLinux) { + return images + ? ref.read(ImagePickerController.provider).pickImage(source: .gallery) + : openFile(); + } + + final portal = await ref.read(PortalController.provider.future); + + try { + final result = await portal.fileChooser + .openFile( + title: images ? "Pick image" : "Pick file", + filters: [ + if (images) + .new("Images", [XdgFileChooserMimeTypePattern("image/*")]), + ], + ) + .first; + + return switch (result.uris.firstOrNull) { + final uri? => XFile(Uri.parse(uri).toFilePath()), + null => null, + }; + } on XdgPortalRequestCancelledException catch (_) { + return null; + } + } + + static final provider = Provider(FileDialogHelper.new); +} diff --git a/lib/helpers/font_licenses.dart b/lib/helpers/font_licenses.dart new file mode 100644 index 0000000..7cb3640 --- /dev/null +++ b/lib/helpers/font_licenses.dart @@ -0,0 +1,109 @@ +import "package:flutter/foundation.dart"; + +const sil = """This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE."""; + +const List fontLicenses = [ + .new( + ["Inter"], + """Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) +Modified for use in this application by removing selected Unicode character mappings. + +$sil""", + ), + .new( + ["Atkinson Hyperlegible Mono"], + """Copyright 2020-2024 The Atkinson Hyperlegible Mono Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next-mono) + +$sil""", + ), +]; diff --git a/lib/helpers/hooks/chat_composer.dart b/lib/helpers/hooks/chat_composer.dart new file mode 100644 index 0000000..44b5352 --- /dev/null +++ b/lib/helpers/hooks/chat_composer.dart @@ -0,0 +1,54 @@ +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/composer_draft.dart"; +import "package:nexus/helpers/extensions/refocus_after_rebuild.dart"; +import "package:nexus/models/composer_params.dart"; +import "package:nexus/models/relation_type.dart"; + +final class ChatComposer({ + required final int? relatedEvent, + required final RelationType relationType, + required final ComposerDraftController notifier, + required final FocusNode node, +}) { + factory use(WidgetRef ref, ComposerParams composer) { + final provider = ComposerDraftController.provider(composer); + final node = useFocusNode( + onKeyEvent: (node, event) { + if (event is KeyDownEvent && event.logicalKey == .escape) { + final isEditing = ref.read(provider).isEditing; + ref.read(provider.notifier).dismiss(); + // Ending an edit replaces the text field + if (isEditing) node.refocusAfterRebuild(); + return .handled; + } + + return .ignored; + }, + ); + final (relatedEvent, relationType) = ref.watch( + provider.select((draft) => (draft.relatedEvent, draft.relationType)), + ); + + return .new( + relatedEvent: relatedEvent, + relationType: relationType, + notifier: ref.watch(provider.notifier), + node: node, + ); + } + + int? get repliedEventRowId => relationType == .reply ? relatedEvent : null; + + void relate(int rowId, RelationType type) { + notifier.relate(rowId, type); + // Starting or ending an edit replaces the text field + if (type == .edit || relationType == .edit) { + node.refocusAfterRebuild(); + } else { + node.requestFocus(); + } + } +} diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart new file mode 100644 index 0000000..84b4043 --- /dev/null +++ b/lib/helpers/hooks/chat_scroll.dart @@ -0,0 +1,251 @@ +import "dart:async"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/direction.dart"; +import "package:nexus/models/room_chat.dart"; + +typedef JumpToEvent = Future Function(int rowId); + +final class ChatScroll({ + required final IList historyRows, + required final IList liveRows, + required final GlobalKey centerKey, + required final ScrollController scrollController, + + required final bool atBottom, + required final int? highlightedEvent, + + required final JumpToEvent jumpToEvent, + required final Future Function() jumpToBottom, + required final GlobalKey Function(int eventRowId) keyFor, +}) { + factory use({ + required AsyncValue controllerData, + required Future Function(Direction direction) paginate, + required Future Function() markRead, + required ValueNotifier contextualEvent, + }) { + final anchorId = useState(null); + final atBottom = useState(true); + final pendingJump = useState(null); + final highlightedEvent = useState(null); + final hookContext = useContext(); + final pendingJumpDone = useRef?>(null); + void finishPendingJump() { + pendingJumpDone.value?.complete(); + pendingJumpDone.value = null; + } + + final scrollController = useScrollController(); + final centerKey = useMemoized(GlobalKey.new); + + final itemKeys = useMemoized(() => {}, []); + GlobalKey keyFor(int eventRowId) => + itemKeys.putIfAbsent(eventRowId, GlobalKey.new); + + useEffect(() { + if (anchorId.value == null) { + if (controllerData case AsyncData(:final value?) + when value.timeline.isNotEmpty) { + anchorId.value = value.timeline.contains(contextualEvent.value) + ? contextualEvent.value + : value.timeline.last; + } + } + + return null; + }, [controllerData, contextualEvent.value]); + + useEffect(() { + final rowId = pendingJump.value; + if (rowId == null || anchorId.value != rowId) return null; + pendingJump.value = null; + + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!scrollController.hasClients) return finishPendingJump(); + + final position = scrollController.position; + scrollController.jumpTo( + (-position.viewportDimension) + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(), + ); + await WidgetsBinding.instance.endOfFrame; + + final context = keyFor(rowId).currentContext; + if (context?.mounted == true) { + await Scrollable.ensureVisible( + context!, + alignment: 0.5, + duration: Durations.extralong1, + curve: Curves.easeInOut, + ); + } + finishPendingJump(); + }); + + return null; + }, [anchorId.value, pendingJump.value]); + + final ({IList history, IList live}) split = useMemoized(() { + final items = controllerData.value?.timeline; + final anchor = anchorId.value; + + if (items == null || anchor == null) { + return (history: const .empty(), live: const .empty()); + } + + final anchorIndex = items.indexOf(anchor); + + final historyLength = anchorIndex == -1 ? items.length : anchorIndex + 1; + return ( + history: items.take(historyLength).toIList().reversed.toIList(), + live: items.skip(historyLength).toIList(), + ); + }, [controllerData, anchorId.value]); + + final lastRowId = controllerData.value?.timeline.lastOrNull; + final previousLastRowId = useRef(null); + + void updateAtBottom() { + if (!hookContext.mounted || !scrollController.hasClients) return; + + final isAtBottom = scrollController.position.extentBefore <= 0; + if (isAtBottom != atBottom.value) atBottom.value = isAtBottom; + } + + useEffect(() { + final previous = previousLastRowId.value; + previousLastRowId.value = lastRowId; + + if (previous == null || lastRowId == null || previous == lastRowId) { + return null; + } + if (!atBottom.value || contextualEvent.value != null) return null; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!scrollController.hasClients) return; + + anchorId.value = lastRowId; + scrollController.position.correctPixels(0); + + // Other callbacks this frame still see the new event below the old + // anchor, so they think it isn't at the bottom until the next frame + WidgetsBinding.instance.addPostFrameCallback((_) => updateAtBottom()); + }); + + return null; + }, [lastRowId]); + + useEffect( + () { + const loadThreshold = 500.0; + + Future checkPosition() async { + if (!scrollController.hasClients) return; + + final position = scrollController.position; + updateAtBottom(); + + // contextualEvent updates immediately when jumping, but + // controllerData is only for the historical timeline once it loads + final isHistorical = + contextualEvent.value != null && + controllerData.value?.historicalData != null; + + if (position.extentAfter <= loadThreshold) { + await paginate(.backward); + } else if (isHistorical && position.extentBefore <= loadThreshold) { + await paginate(.forward); + } + + if (!isHistorical && position.extentBefore <= 0) await markRead(); + + if (controllerData.isLoading == false && + position.extentBefore <= 0 && + controllerData.value?.hasMoreForward == false && + isHistorical) { + anchorId.value = null; + contextualEvent.value = null; + } + } + + scrollController.addListener(checkPosition); + WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition()); + + return () => scrollController.removeListener(checkPosition); + }, + [ + scrollController, + controllerData, + paginate, + markRead, + contextualEvent.value, + ], + ); + + Future scrollToEvent(int rowId) async { + if (!scrollController.hasClients) return; + + final context = keyFor(rowId).currentContext; + if (context != null) { + await Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: Durations.extralong1, + curve: Curves.easeInOut, + ); + return; + } + + finishPendingJump(); + final done = pendingJumpDone.value = Completer(); + pendingJump.value = rowId; + if (controllerData.value?.timeline.contains(rowId) ?? false) { + anchorId.value = rowId; + } else { + anchorId.value = null; + contextualEvent.value = rowId; + } + await done.future; + } + + return .new( + historyRows: split.history, + liveRows: split.live, + centerKey: centerKey, + scrollController: scrollController, + atBottom: atBottom.value && contextualEvent.value == null, + highlightedEvent: highlightedEvent.value, + keyFor: keyFor, + jumpToEvent: (int rowId) async { + highlightedEvent.value = rowId; + + await scrollToEvent(rowId); + await Future.delayed(Durations.extralong1, () { + if (hookContext.mounted && highlightedEvent.value == rowId) { + highlightedEvent.value = null; + } + }); + }, + jumpToBottom: () async { + finishPendingJump(); + if (contextualEvent.value != null) { + anchorId.value = null; + contextualEvent.value = null; + } + + if (!scrollController.hasClients) return; + + await scrollController.animateTo( + scrollController.position.minScrollExtent, + duration: Durations.extralong1, + curve: Curves.easeInOut, + ); + }, + ); + } +} diff --git a/lib/helpers/hooks/saving.dart b/lib/helpers/hooks/saving.dart new file mode 100644 index 0000000..3a0faef --- /dev/null +++ b/lib/helpers/hooks/saving.dart @@ -0,0 +1,25 @@ +import "dart:async"; + +import "package:flutter_hooks/flutter_hooks.dart"; + +final class Saving({ + required final bool active, + required final Future Function(FutureOr Function() action) run, +}) { + factory use() { + final context = useContext(); + final active = useState(false); + + return .new( + active: active.value, + run: (action) async { + active.value = true; + try { + await action(); + } finally { + if (context.mounted) active.value = false; + } + }, + ); + } +} diff --git a/lib/helpers/hooks/tap_recognizer.dart b/lib/helpers/hooks/tap_recognizer.dart new file mode 100644 index 0000000..97c5534 --- /dev/null +++ b/lib/helpers/hooks/tap_recognizer.dart @@ -0,0 +1,11 @@ +import "package:flutter/gestures.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; + +/// A recognizer for a tappable span in text, like a name, disposed with the +/// widget. +TapGestureRecognizer useTapRecognizer() { + final recognizer = useMemoized(TapGestureRecognizer.new); + useEffect(() => recognizer.dispose, [recognizer]); + + return recognizer; +} diff --git a/lib/helpers/launch_helper.dart b/lib/helpers/launch_helper.dart index f872ef7..89c7015 100644 --- a/lib/helpers/launch_helper.dart +++ b/lib/helpers/launch_helper.dart @@ -2,17 +2,12 @@ import "package:flutter/services.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:url_launcher/url_launcher.dart" as ul; -class LaunchHelper { - final Ref ref; - LaunchHelper(this.ref); - +class LaunchHelper(Ref ref) { Future launchUrl(Uri url, {bool useWebview = false}) async { try { return await ul.launchUrl( url, - mode: useWebview - ? ul.LaunchMode.inAppBrowserView - : ul.LaunchMode.externalApplication, + mode: useWebview ? .inAppBrowserView : .externalApplication, ); } on PlatformException catch (_) { return false; diff --git a/lib/helpers/matrix_linkifier.dart b/lib/helpers/matrix_linkifier.dart new file mode 100644 index 0000000..addf468 --- /dev/null +++ b/lib/helpers/matrix_linkifier.dart @@ -0,0 +1,38 @@ +import "package:linkify/linkify.dart"; + +/// Splits [text] into plain text and URL, `matrix:` and email links. +List linkifyText(String text) => linkify( + text, + options: const LinkifyOptions(humanize: false), + linkifiers: const [UrlLinkifier(), MatrixLinkifier(), EmailLinkifier()], +); + +/// Linkifies `matrix:` URIs, which [UrlLinkifier] only handles for http(s). +final class const MatrixLinkifier() extends Linkifier { + static final _regex = RegExp( + r"\bmatrix:[^\s<>\x22\x27]*[^\s<>\x22\x27.,;:!?)\]}]", + caseSensitive: false, + ); + + @override + List parse( + List elements, + LinkifyOptions options, + ) => elements + .expand( + (element) => element is TextElement ? _split(element.text) : [element], + ) + .toList(); + + Iterable _split(String text) sync* { + var start = 0; + for (final match in _regex.allMatches(text)) { + if (match.start > start) { + yield TextElement(text.substring(start, match.start)); + } + yield UrlElement(match[0]!); + start = match.end; + } + if (start < text.length) yield TextElement(text.substring(start)); + } +} diff --git a/lib/helpers/mxc_image.dart b/lib/helpers/mxc_image.dart new file mode 100644 index 0000000..4013397 --- /dev/null +++ b/lib/helpers/mxc_image.dart @@ -0,0 +1,77 @@ +import "dart:async"; +import "dart:io"; +import "dart:isolate"; +import "dart:ui" as ui; + +import "package:avif_native/avif_native.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/requests/download_media.dart"; + +class MxcImage(final WidgetRef ref, final DownloadMediaRequest request) + extends ImageProvider { + @override + Future obtainKey(ImageConfiguration configuration) => + Future.value(this); + + @override + ImageStreamCompleter loadImage(MxcImage key, ImageDecoderCallback decode) => + MultiFrameImageStreamCompleter(codec: _loadAsync(decode), scale: 1.0); + + Future _loadAsync(ImageDecoderCallback decode) async { + final file = await ref + .read(ClientController.provider.notifier) + .downloadMedia(request); + final bytes = await File(file.path).readAsBytes(); + + // Flutter can't decode AVIF on Linux/Windows (or older Android/iOS), so we do it ourselves + if (String.fromCharCodes(bytes.skip(4).take(8)) + case "ftypavif" || "ftypavis") { + return AvifCodec(await Isolate.run(() => decodeAvif(bytes))); + } + + return decode(await .fromUint8List(bytes)); + } + + @override + bool operator ==(Object other) => + other is MxcImage && other.request == request; + + @override + int get hashCode => request.hashCode; +} + +class AvifCodec(final AvifImageData data) implements ui.Codec { + var _frameIndex = 0; + + @override + int get frameCount => data.frames.length; + + // libavif uses -2 for unknown, which we treat as infinite like browsers do + @override + int get repetitionCount => + data.repetitionCount < 0 ? -1 : data.repetitionCount; + + @override + Future getNextFrame() { + final frame = data.frames[_frameIndex++ % frameCount]; + final completer = Completer(); + ui.decodeImageFromPixels( + frame.rgba, + frame.width, + frame.height, + .rgba8888, + (image) => completer.complete(AvifFrameInfo(frame.duration, image)), + ); + return completer.future; + } + + @override + void dispose() {} +} + +class AvifFrameInfo( + @override final Duration duration, + @override final ui.Image image, +) implements ui.FrameInfo; diff --git a/lib/helpers/required_validator_helper.dart b/lib/helpers/required_validator_helper.dart new file mode 100644 index 0000000..d243684 --- /dev/null +++ b/lib/helpers/required_validator_helper.dart @@ -0,0 +1,2 @@ +String? requiredValidator(String? value) => + value == null || value.isEmpty ? "This field is required" : null; diff --git a/lib/main.dart b/lib/main.dart index 5ad6c24..590c74d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,26 +1,35 @@ import "dart:io"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +import "package:dynamic_color/dynamic_color.dart"; import "package:flutter/foundation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/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:material_emoji_picker/material_emoji_picker.dart"; +import "package:media_kit/media_kit.dart"; +import "package:nexus/controllers/active_account.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/gomuks_listener.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/member_list_opened.dart"; +import "package:nexus/controllers/multi_provider.dart"; +import "package:nexus/controllers/notification.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/controllers/unified_push.dart"; import "package:nexus/helpers/extensions/scheme_to_theme.dart"; -import "package:nexus/pages/chat_page.dart"; -import "package:nexus/pages/login_page.dart"; -import "package:nexus/pages/verify_page.dart"; -import "package:nexus/widgets/error_dialog.dart"; -import "package:nexus/widgets/loading.dart"; +import "package:nexus/helpers/font_licenses.dart"; +import "package:nexus/widgets/pages/chat.dart"; +import "package:nexus/widgets/pages/select_server.dart"; +import "package:nexus/widgets/pages/settings.dart"; +import "package:nexus/widgets/pages/verify.dart"; +import "package:nexus/widgets/account_scope.dart"; +import "package:nexus/widgets/common/appbar.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/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"; +import "package:material_ui/material_ui.dart"; final GlobalKey navigatorKey = GlobalKey(); +late final bool isInBackground; final class Logger extends ProviderObserver { @override @@ -33,15 +42,13 @@ 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]) { - if (error.toString().contains("DioException")) return; - if (error.toString().contains("Invalid source")) return; - if (error.toString().contains("UTF-16")) return; - if (error.toString().contains("HTTP request failed")) return; - if (error.toString().contains("Invalid image data")) return; + if (error.toString().contains("'_nextFrame != null': is not true.")) { + return; + } debugPrintStack(stackTrace: stackTrace, label: error.toString()); if (navigatorKey.currentContext != null) { @@ -56,88 +63,161 @@ void showError(Object error, [StackTrace? stackTrace]) { } } -void main() async { +void main(List args) async { WidgetsFlutterBinding.ensureInitialized(); + MediaKit.ensureInitialized(); - await windowManager.ensureInitialized(); - await windowManager.waitUntilReadyToShow( - WindowOptions(titleBarStyle: TitleBarStyle.hidden), - ); - - if (Platform.isLinux) { - setWindowMinSize(const Size.square(500)); - } else { - await windowManager.setMinimumSize(Size.square(500)); - } + LicenseRegistry.addLicense(() => .fromIterable(fontLicenses)); FlutterError.onError = (FlutterErrorDetails details) => showError(details.exception.toString(), details.stack); - runApp( - ProviderScope( - observers: [ - // Change false to true if you want debug information on provider reloads - // ignore: dead_code - if (false && kDebugMode) Logger(), + isInBackground = + Platform.environment["FLUTTER_HEADLESS"] != null || + args.contains("--unifiedpush-bg"); + + final accountId = await ActiveAccountController.stored(); + + if (isInBackground) { + await ProviderContainer( + overrides: [ + // Nothing to switch to in the background + ActiveAccountController.provider.overrideWith( + () => ActiveAccountController(accountId, (_) {}), + ), ], - child: const App(), - ), - ); + ).read(UnifiedPushController.provider.future).timeout(Duration(seconds: 5)); + + await Future.delayed(Duration(seconds: 10)); + exit(0); + } else { + if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { + WindowManager.instance.getCurrent() + ?..titleBarStyle = .hidden + ..isWindowControlButtonsVisible = false + ..minimumSize = Size.square(320).toNative(); + } + + runApp( + AccountScope( + initialAccountId: accountId, + retry: (_, _) => null, + observers: [ + // Change false to true if you want debug information on provider reloads + // ignore: dead_code + if (false && kDebugMode) Logger(), + ], + child: App(), + ), + ); + } } -class App extends StatelessWidget { - const App({super.key}); - +class const App({super.key}) extends StatelessWidget { @override Widget build(BuildContext context) => DynamicColorBuilder( - builder: (lightDynamic, darkDynamic) => MaterialApp( - navigatorKey: navigatorKey, - debugShowCheckedModeBanner: false, - // Use indigo to work around bugs in theme generation - theme: (lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.indigo)) - .theme, - darkTheme: - (darkDynamic ?? - ColorScheme.fromSeed( - seedColor: Colors.indigo, - brightness: Brightness.dark, - )) - .theme, - home: Scaffold( - body: Consumer( - builder: (_, ref, _) => ref - .watch( - MultiProviderController.provider( - IListConst([ - SharedPrefsController.provider, - ClientController.provider, - HeaderController.provider, + builder: (lightDynamic, darkDynamic) => Consumer( + builder: (context, ref, child) { + // Flutter turns off mobile behaviour like swiping drawers open on desktop platforms, so Linux mobile pretends to be Android + ThemeData forPlatform(ThemeData theme) => + ref.watch( + SettingsController.provider.select( + (settings) => settings.value?.linuxMobileMode ?? false, + ), + ) + ? theme.copyWith(platform: .android) + : theme; + + return MaterialApp( + navigatorKey: navigatorKey, + debugShowCheckedModeBanner: false, + theme: forPlatform( + (ref + .watch(SettingsController.provider) + .maybeWhen( + orElse: () => lightDynamic, + data: (settings) => + settings.useDynamicTheming ? lightDynamic : null, + ) ?? + ThemeData.light().colorScheme) + .theme, + ), + darkTheme: forPlatform( + (ref + .watch(SettingsController.provider) + .maybeWhen( + orElse: () => darkDynamic, + data: (settings) => + settings.useDynamicTheming ? darkDynamic : null, + ) ?? + ThemeData.dark().colorScheme) + .theme, + ), + themeMode: ref + .watch(SettingsController.provider) + .maybeWhen( + data: (settings) => settings.theme, + orElse: () => .system, + ), + home: child, + ); + }, + child: DefaultTextHeightBehavior( + textHeightBehavior: .new(leadingDistribution: .even), + child: Scaffold( + body: Consumer( + builder: (context, ref, _) => switch (ref.watch( + MultiProviderController.provider( + .new([ + GomuksListenerController.provider, + UnifiedPushController.provider, + NotificationController.provider, + MemberListOpenedController.provider, + KeyController.provider(KeyController.roomKey), + KeyController.provider(KeyController.spaceKey), + EmojiController.provider, + ]), + ), + )) { + AsyncData(value: _) || AsyncLoading(value: _?) => Consumer( + builder: (_, ref, _) { + final clientState = ref.watch(ClientStateController.provider); + + if (clientState == null || !clientState.isInitialized) { + return Loading(); + } + + if (!clientState.isLoggedIn) { + return SelectServerPage(); + } else if (!clientState.isVerified) { + return VerifyPage(); + } else { + return ChatPage(); + } + }, + ), + + AsyncLoading _ => Scaffold( + appBar: Appbar( + actions: .new([ + IconButton( + onPressed: () => showDialog( + context: context, + builder: (_) => SettingsPage(), + ), + icon: Icon(Icons.settings), + ), ]), ), - ) - .betterWhen( - data: (_) => Consumer( - builder: (_, ref, _) { - final clientState = ref.watch( - ClientStateController.provider, - ); - - if (clientState == null || !clientState.isInitialized) { - return Loading(); - } - - if (!clientState.isLoggedIn) { - return LoginPage(); - } else if (!clientState.isVerified) { - return VerifyPage(); - } else { - return ref.watch(InitCompleteController.provider) - ? ChatPage() - : Loading(); - } - }, - ), + body: Loading(), ), + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + provider: ClientController.provider, + ), + }, + ), ), ), ), diff --git a/lib/models/account.dart b/lib/models/account.dart new file mode 100644 index 0000000..a171bda --- /dev/null +++ b/lib/models/account.dart @@ -0,0 +1,26 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "account.freezed.dart"; +part "account.g.dart"; + +/// An account slot, each with its own gomuks directories. +/// +/// Everything but [id] is cached from the last time the account was active, so +/// the account switcher and push routing work while it isn't. +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Account({ + required final String id, + final String? userId, + final String? deviceId, + final String? displayName, + final Uri? avatar, +}) with _$Account { + /// The slot accounts from before account switching existed + static const String legacyId = "default"; + + Map toJson() => _$AccountToJson(this); + + factory Account.fromJson(Map json) => + _$AccountFromJson(json); +} diff --git a/lib/models/account_data.dart b/lib/models/account_data.dart index a325ffe..51176cd 100644 --- a/lib/models/account_data.dart +++ b/lib/models/account_data.dart @@ -1,16 +1,144 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/push_rules.dart"; + part "account_data.freezed.dart"; part "account_data.g.dart"; -@freezed -abstract class AccountData with _$AccountData { - const factory AccountData({ - required String userId, - required String? roomId, - required String type, - required dynamic content, - }) = _AccountData; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const AccountData({ + @JsonKey(name: AccountData.invitePermissionConfigKey) + final InvitePermissionConfig invitePermissionConfig = + const InvitePermissionConfig(), + + @JsonKey(name: AccountData.directKey) + final IMap> directMessages = const .empty(), + + @JsonKey(name: AccountData.recentEmojiKey) + final RecentEmojiData recentEmoji = const RecentEmojiData(), + + /// Room image packs the user has enabled everywhere + @JsonKey(name: AccountData.imagePackRoomsKey) + final ImagePackRooms? imagePackRooms, + @JsonKey(name: AccountData.legacyImagePackRoomsKey) + final ImagePackRooms? legacyImagePackRooms, + @JsonKey(name: AccountData.pushRulesKey) + final PushRules pushRules = const .new(), + @JsonKey(name: AccountData.ignoredUserListKey) + final IgnoredUserList ignoredUserList = const .new(), +}) with _$AccountData { + static const invitePermissionConfigKey = "m.invite_permission_config"; + static const directKey = "m.direct"; + static const recentEmojiKey = "m.recent_emoji"; + static const imagePackRoomsKey = "m.image_pack.rooms"; + static const legacyImagePackRoomsKey = "im.ponies.emote_rooms"; + static const pushRulesKey = "m.push_rules"; + static const ignoredUserListKey = "m.ignored_user_list"; + + ImagePackRooms get enabledImagePacks => + imagePackRooms ?? legacyImagePackRooms ?? const .new(); + + Map toJson() => _$AccountDataToJson(this); factory AccountData.fromJson(Map json) => _$AccountDataFromJson(json); } + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RecentEmojiData({ + final IList recentEmoji = const .empty(), +}) with _$RecentEmojiData { + Map toJson() => _$RecentEmojiDataToJson(this); + + factory RecentEmojiData.fromJson(Map json) => + _$RecentEmojiDataFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ImagePackRooms({ + /// Room IDs to the state keys of their enabled packs, each to a (currently + /// empty) object + final IMap>> rooms = const .empty(), +}) with _$ImagePackRooms { + bool contains(String roomId, String stateKey) => + rooms[roomId]?.containsKey(stateKey) ?? false; + + ImagePackRooms withPack( + String roomId, + String stateKey, { + required bool enabled, + }) { + final packs = + rooms[roomId] ?? const IMap>.empty(); + final updated = switch ((enabled, packs.containsKey(stateKey))) { + (true, false) => packs.add(stateKey, const {}), + (false, true) => packs.remove(stateKey), + _ => packs, + }; + + return copyWith( + rooms: updated.isEmpty + ? rooms.remove(roomId) + : rooms.add(roomId, updated), + ); + } + + Map toJson() => _$ImagePackRoomsToJson(this); + + factory ImagePackRooms.fromJson(Map json) => + _$ImagePackRoomsFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const IgnoredUserList({ + /// Blocked user IDs, each to a (currently empty) object + @JsonKey(name: "ignored_users") + final IMap> ignoredUsers = const .empty(), +}) with _$IgnoredUserList { + bool contains(String userId) => ignoredUsers.containsKey(userId); + + IgnoredUserList withUser(String userId, {required bool ignored}) => copyWith( + ignoredUsers: ignored + ? ignoredUsers.add(userId, const {}) + : ignoredUsers.remove(userId), + ); + + Map toJson() => _$IgnoredUserListToJson(this); + + factory IgnoredUserList.fromJson(Map json) => + _$IgnoredUserListFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const InvitePermissionConfig({ + @JsonKey(unknownEnumValue: DefaultInviteAction.allow) + final DefaultInviteAction defaultAction = DefaultInviteAction.allow, +}) with _$InvitePermissionConfig { + Map toJson() => _$InvitePermissionConfigToJson(this); + + factory InvitePermissionConfig.fromJson(Map json) => + _$InvitePermissionConfigFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RecentEmoji({required final String emoji, required final int total}) + with _$RecentEmoji { + Map toJson() => _$RecentEmojiToJson(this); + + factory RecentEmoji.fromJson(Map json) => + _$RecentEmojiFromJson(json); +} + +@JsonEnum(fieldRename: .snake) +enum DefaultInviteAction { + allow, + deny, + @JsonValue("uk.timedout.msc4494.deny_public") + denyPublic, +} diff --git a/lib/models/bot_argument_suggestion.dart b/lib/models/bot_argument_suggestion.dart new file mode 100644 index 0000000..4b27cce --- /dev/null +++ b/lib/models/bot_argument_suggestion.dart @@ -0,0 +1,13 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/room.dart"; + +part "bot_argument_suggestion.freezed.dart"; + +@Freezed(toJson: false, fromJson: false) +class const BotArgumentSuggestion({ + required final String title, + final String? subtitle, + final Uri? avatar, + required final String text, + final Room? room, +}) with _$BotArgumentSuggestion; diff --git a/lib/models/bot_command.dart b/lib/models/bot_command.dart new file mode 100644 index 0000000..e25f4dd --- /dev/null +++ b/lib/models/bot_command.dart @@ -0,0 +1,38 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/bot_command_description.dart"; + +part "bot_command.freezed.dart"; + +@Freezed(toJson: false, fromJson: false) +class const BotCommand({ + required final String sender, + required final String botName, + final Uri? botAvatar, + required final BotCommandDescriptionContent description, +}) with _$BotCommand { + /// Commands gomuks handles itself, which bots can't override. + static const builtIn = ISetConst({ + "me", + "notice", + "timestamp", + "unencrypted", + "rawinputbody", + "rainbow", + "plain", + "html", + "htmlmd", + }); + + static const gomuksSender = "@gomuks"; + + String get prefix => "/${description.command}"; + String get about => [botName, ?description.description].join(" · "); + + bool completes(String text) => + text.startsWith("/") && + (prefix.startsWith(text) || argumentsIn(text) != null); + + String? argumentsIn(String text) => + text.startsWith("$prefix ") ? text.substring(prefix.length + 1) : null; +} diff --git a/lib/models/bot_command_draft.dart b/lib/models/bot_command_draft.dart new file mode 100644 index 0000000..d8339e0 --- /dev/null +++ b/lib/models/bot_command_draft.dart @@ -0,0 +1,104 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/bot_command.dart"; +import "package:nexus/models/composer_params.dart"; +import "package:nexus/models/content/bot_command.dart"; +import "package:nexus/models/content/bot_command_description.dart"; + +part "bot_command_draft.freezed.dart"; + +@Freezed(toJson: false, fromJson: false) +class const BotCommandDraft({ + required final BotCommand command, + required final ComposerParams composer, + final IMap texts = const .empty(), + + // The servers to join rooms picked from suggestions through, by room ID + final IMap> vias = const .empty(), + final ISet options = const .empty(), + final String? focused, +}) with _$BotCommandDraft { + IList get shown => command.description.parameters + .where( + (parameter) => !parameter.optional || options.contains(parameter.key), + ) + .toIList(); + + IList get hiddenOptions => command.description.parameters + .where( + (parameter) => parameter.optional && !options.contains(parameter.key), + ) + .toIList(); + + BotParameter? get focusedParameter => command.description.parameters + .firstWhereOrNull((parameter) => parameter.key == focused); + + ({Object? value, String? error}) parse(BotParameter parameter) => parameter + .schema + .parseText(texts[parameter.key] ?? "", roomId: composer.roomId); + + IMap get arguments => .fromEntries( + shown + .map( + (parameter) => switch (parse(parameter).value) { + final value? => MapEntry(parameter.key, withVias(value)), + null => null, + }, + ) + .nonNulls, + ); + + IMap get errors => .fromEntries( + shown + .map( + (parameter) => switch (parse(parameter).error) { + final error? => MapEntry(parameter.key, error), + null => null, + }, + ) + .nonNulls, + ); + + IList get missing => shown + .where( + (parameter) => !parameter.optional && parse(parameter).value == null, + ) + .toIList(); + + String? get problem => errors.isNotEmpty + ? errors.entries.map((error) => "${error.key}: ${error.value}").join("\n") + : missing.isNotEmpty + ? "Fill in ${missing.map((parameter) => parameter.key).join(", ")} first." + : null; + + BotCommandInput get input => + .new(command: command.description.command, arguments: arguments); + + String get body => [ + command.prefix, + ...shown + .map( + (parameter) => switch (texts[parameter.key] + ?.split(BotParameterSchema.separator) + .where((part) => part.isNotEmpty) + .join(" ")) { + final text? when text.isEmpty => null, + final text? when parameter.optional => "--${parameter.key} $text", + final text => text, + }, + ) + .nonNulls, + ].join(" "); + + Object withVias(Object value) => switch (value) { + List() => value.whereType().map(withVias).toList(), + final Map room && {"type": "room_id", "id": final String id} + when !room.containsKey("via") => + switch (vias[id]) { + final via? => {...room, "via": via.unlock}, + null => room, + }, + _ => value, + }; +} diff --git a/lib/models/capabilities.dart b/lib/models/capabilities.dart new file mode 100644 index 0000000..cdf86b7 --- /dev/null +++ b/lib/models/capabilities.dart @@ -0,0 +1,54 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "capabilities.freezed.dart"; +part "capabilities.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Capabilities({ + @JsonKey(name: "org.matrix.msc4174.webpush") final WebPush? webpush, + @JsonKey(name: "m.room_versions") final RoomVersions? roomVersions, +}) with _$Capabilities { + Map toJson() => _$CapabilitiesToJson(this); + + factory Capabilities.fromJson(Map json) => + _$CapabilitiesFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const WebPush({required final bool enabled, final String? vapid}) + with _$WebPush { + Map toJson() => _$WebPushToJson(this); + + factory WebPush.fromJson(Map json) => + _$WebPushFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RoomVersions({ + @JsonKey(name: "default") required final String defaultVersion, + @JsonKey(fromJson: RoomVersionStability.mapFromJson) + required final IMap available, +}) with _$RoomVersions { + Map toJson() => _$RoomVersionsToJson(this); + + factory RoomVersions.fromJson(Map json) => + _$RoomVersionsFromJson(json); +} + +enum RoomVersionStability { + stable, + unstable, + unknown; + + static IMap mapFromJson( + Map json, + ) => IMap.fromJson( + json, + (version) => version as String, + (stability) => values.asNameMap()[stability] ?? unknown, + ); +} diff --git a/lib/models/client_state.dart b/lib/models/client_state.dart index 1e15136..38f1e5b 100644 --- a/lib/models/client_state.dart +++ b/lib/models/client_state.dart @@ -1,17 +1,34 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "client_state.freezed.dart"; part "client_state.g.dart"; -@freezed -abstract class ClientState with _$ClientState { - const factory ClientState({ - required bool isInitialized, - required bool isLoggedIn, - required bool isVerified, - required String? userId, - required String? homeserverUrl, - }) = _ClientState; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ClientState({ + required final bool isInitialized, + required final bool isLoggedIn, + required final bool isVerified, + required final String? userId, + required final String? deviceId, + required final String? homeserverUrl, + final VerificationState verificationState = const .new(), +}) with _$ClientState { + Map toJson() => _$ClientStateToJson(this); factory ClientState.fromJson(Map json) => _$ClientStateFromJson(json); } + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const VerificationState({ + final bool stateChecked = false, + final bool hasCrossSigning = false, + @JsonKey(name: "has_ssss") final bool hasSecretStorage = false, +}) with _$VerificationState { + Map toJson() => _$VerificationStateToJson(this); + + factory VerificationState.fromJson(Map json) => + _$VerificationStateFromJson(json); +} diff --git a/lib/models/composer_draft.dart b/lib/models/composer_draft.dart new file mode 100644 index 0000000..ea72f30 --- /dev/null +++ b/lib/models/composer_draft.dart @@ -0,0 +1,31 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:fluttertagger/fluttertagger.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/bot_command_draft.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/poll_start.dart"; +import "package:nexus/models/relation_type.dart"; +import "package:nexus/models/text_command.dart"; + +part "composer_draft.freezed.dart"; + +@Freezed(toJson: false, fromJson: false) +class const ComposerDraft({ + // Formatted like ":id#name#", see the composer's tagTextFormatter + final String text = "", + final IList tags = const .empty(), + final IList textCommands = const .empty(), + final int? relatedEvent, + final RelationType relationType = .reply, + + // Replace the text field until they are sent or removed + final (String, PackImage)? sticker, + final PollStart? poll, + final LocationMessageContent? location, + + // The draft from before an edit, restored once it is sent or cancelled + final ({ComposerDraft draft, BotCommandDraft? botCommand})? beforeEdit, +}) with _$ComposerDraft { + bool get isEditing => beforeEdit != null; +} diff --git a/lib/models/composer_params.dart b/lib/models/composer_params.dart new file mode 100644 index 0000000..12edb46 --- /dev/null +++ b/lib/models/composer_params.dart @@ -0,0 +1 @@ +typedef ComposerParams = ({String roomId, String? threadRoot}); diff --git a/lib/models/configs/event.dart b/lib/models/configs/event.dart new file mode 100644 index 0000000..9f41fbe --- /dev/null +++ b/lib/models/configs/event.dart @@ -0,0 +1,16 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "event.freezed.dart"; +part "event.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const EventConfig({ + required final String roomId, + required final int eventRowId, +}) with _$EventConfig { + Map toJson() => _$EventConfigToJson(this); + + factory EventConfig.fromJson(Map json) => + _$EventConfigFromJson(json); +} diff --git a/lib/models/configs/members_by_status.dart b/lib/models/configs/members_by_status.dart new file mode 100644 index 0000000..6b4aec4 --- /dev/null +++ b/lib/models/configs/members_by_status.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/membership_status.dart"; + +part "members_by_status.freezed.dart"; +part "members_by_status.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const MembersByStatusConfig({ + required final String roomId, + required final MembershipStatus status, +}) with _$MembersByStatusConfig { + Map toJson() => _$MembersByStatusConfigToJson(this); + + factory MembersByStatusConfig.fromJson(Map json) => + _$MembersByStatusConfigFromJson(json); +} diff --git a/lib/models/configs/power_level.dart b/lib/models/configs/power_level.dart new file mode 100644 index 0000000..5722db7 --- /dev/null +++ b/lib/models/configs/power_level.dart @@ -0,0 +1,29 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/membership_action.dart"; + +part "power_level.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/user.dart b/lib/models/configs/user.dart new file mode 100644 index 0000000..bbbc83d --- /dev/null +++ b/lib/models/configs/user.dart @@ -0,0 +1,14 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "user.freezed.dart"; +part "user.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const UserConfig({final String? roomId, required final String userId}) + with _$UserConfig { + Map toJson() => _$UserConfigToJson(this); + + factory UserConfig.fromJson(Map json) => + _$UserConfigFromJson(json); +} diff --git a/lib/models/content/avatar.dart b/lib/models/content/avatar.dart new file mode 100644 index 0000000..9efdb64 --- /dev/null +++ b/lib/models/content/avatar.dart @@ -0,0 +1,17 @@ +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(toJson: false, fromJson: false) +@JsonSerializable() +class const AvatarContent({final ImageInfo? info, final Uri? url}) + extends Content + with _$AvatarContent { + @override + Map toJson() => _$AvatarContentToJson(this); + + factory AvatarContent.fromJson(Map json) => + _$AvatarContentFromJson(json); +} diff --git a/lib/models/content/bot_command.dart b/lib/models/content/bot_command.dart new file mode 100644 index 0000000..b5ce39d --- /dev/null +++ b/lib/models/content/bot_command.dart @@ -0,0 +1,33 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; + +part "bot_command.freezed.dart"; +part "bot_command.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const BotCommandMessageContent({ + required final String body, + @JsonKey(name: "org.matrix.msc4391.command") + required final BotCommandInput command, + final String msgtype = "m.text", +}) extends Content with _$BotCommandMessageContent { + @override + Map toJson() => _$BotCommandMessageContentToJson(this); + + factory BotCommandMessageContent.fromJson(Map json) => + _$BotCommandMessageContentFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const BotCommandInput({ + required final String command, + final IMap arguments = const .empty(), +}) with _$BotCommandInput { + Map toJson() => _$BotCommandInputToJson(this); + + factory BotCommandInput.fromJson(Map json) => + _$BotCommandInputFromJson(json); +} diff --git a/lib/models/content/bot_command_description.dart b/lib/models/content/bot_command_description.dart new file mode 100644 index 0000000..f563d9a --- /dev/null +++ b/lib/models/content/bot_command_description.dart @@ -0,0 +1,226 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/helpers/extensions/get_link_info.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/models/content/content.dart"; + +part "bot_command_description.freezed.dart"; +part "bot_command_description.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const BotCommandDescriptionContent({ + required final String command, + final IList parameters = const .empty(), + @JsonKey(fromJson: BotCommandDescriptionContent.plainText) + final String? description, +}) extends Content with _$BotCommandDescriptionContent { + // The plain text from an extensible events m.text block + static String? plainText(Map? json) => + switch (json?["m.text"]) { + final List representations => + representations + .map( + (representation) => switch (representation) { + {"mimetype": final String mimetype} + when mimetype != "text/plain" => + null, + {"body": final String body} => body, + _ => null, + }, + ) + .nonNulls + .firstOrNull, + _ => null, + }; + + bool get isValid => + command.trim().isNotEmpty && + parameters.map((parameter) => parameter.key).toISet().length == + parameters.length && + parameters.every((parameter) => parameter.schema.isValid()); + + @override + Map toJson() => _$BotCommandDescriptionContentToJson(this); + + factory BotCommandDescriptionContent.fromJson(Map json) => + _$BotCommandDescriptionContentFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const BotParameter({ + required final String key, + required final BotParameterSchema schema, + final bool optional = false, + @JsonKey(fromJson: BotCommandDescriptionContent.plainText) + final String? description, +}) with _$BotParameter { + Map toJson() => _$BotParameterToJson(this); + + factory BotParameter.fromJson(Map json) => + _$BotParameterFromJson(json); +} + +@Freezed(unionKey: "schema_type") +sealed class BotParameterSchema with _$BotParameterSchema { + const BotParameterSchema._(); + + static final separator = RegExp(r"[\s,]+"); + + const factory BotParameterSchema.primitive({required BotPrimitiveType type}) = + PrimitiveSchema; + + const factory BotParameterSchema.literal({required Object value}) = + LiteralSchema; + + const factory BotParameterSchema.union({ + required IList variants, + }) = UnionSchema; + + const factory BotParameterSchema.array({required BotParameterSchema items}) = + ArraySchema; + + bool isValid({BotParameterSchema? parent}) => switch (this) { + PrimitiveSchema() => true, + LiteralSchema(:final value) => + value is String || value is int || value is bool, + UnionSchema(:final variants) => + (parent == null || parent is ArraySchema) && + variants.isNotEmpty && + variants.every( + (variant) => + variant is! UnionSchema && variant.isValid(parent: this), + ), + ArraySchema(:final items) => parent == null && items.isValid(parent: this), + }; + + String get label => switch (this) { + PrimitiveSchema(:final type) => type.label, + LiteralSchema(:final value) => "$value", + UnionSchema(:final variants) => + variants.map((variant) => variant.label).join(" | "), + ArraySchema(:final items) => "${items.label}...", + }; + + String get expected => switch (this) { + PrimitiveSchema(:final type) => type.expected, + LiteralSchema(:final value) => '"$value"', + UnionSchema(:final variants) => + variants.map((variant) => variant.expected).join(" or "), + ArraySchema(:final items) => items.expected, + }; + + bool allows(BotPrimitiveType type) => switch (this) { + PrimitiveSchema(type: final allowed) => allowed == type, + LiteralSchema() => false, + UnionSchema(:final variants) => variants.any( + (variant) => variant.allows(type), + ), + ArraySchema(:final items) => items.allows(type), + }; + + IList get literals => switch (this) { + PrimitiveSchema() => const .empty(), + LiteralSchema(:final value) => [value].lock, + UnionSchema(:final variants) => + variants.expand((variant) => variant.literals).toIList(), + ArraySchema(:final items) => items.literals, + }; + + Object? parse(String text, {required String roomId}) => switch (this) { + PrimitiveSchema(:final type) => type.parse(text, roomId: roomId), + LiteralSchema(:final value) => "$value" == text ? value : null, + UnionSchema(:final variants) => + variants + .map((variant) => variant.parse(text, roomId: roomId)) + .nonNulls + .firstOrNull, + ArraySchema(:final items) => items.parse(text, roomId: roomId), + }; + + ({Object? value, String? error}) parseText( + String text, { + required String roomId, + }) { + final parts = (this is ArraySchema ? text.split(separator) : [text.trim()]) + .where((part) => part.isNotEmpty) + .map((part) => (part, parse(part, roomId: roomId))) + .toIList(); + + return switch (parts.firstWhereOrNull((part) => part.$2 == null)) { + (final part, _) => (value: null, error: '"$part" isn\'t $expected.'), + null when parts.isEmpty => (value: null, error: null), + null when this is ArraySchema => ( + value: parts.map((part) => part.$2).nonNulls.toList(), + error: null, + ), + null => (value: parts.first.$2, error: null), + }; + } + + factory BotParameterSchema.fromJson(Map json) => + _$BotParameterSchemaFromJson(json); +} + +enum BotPrimitiveType(final String label, final String expected) { + @JsonValue("string") + string("text", "text"), + @JsonValue("integer") + integer("number", "a whole number"), + @JsonValue("boolean") + boolean("yes/no", "yes or no"), + @JsonValue("user_id") + userId("user", "a user ID like @alice:example.org"), + @JsonValue("server_name") + serverName("server", "a server name like example.org"), + @JsonValue("room_alias") + roomAlias("room alias", "a room alias like #room:example.org"), + @JsonValue("room_id") + roomId("room", "a room ID or link like !abc:example.org"), + @JsonValue("event_id") + eventId("event", r"a message link or event ID like $abc"); + + Object? parse(String text, {required String roomId}) { + final (:identifier, :eventId, :via) = + text.linkInfo ?? + (identifier: text, eventId: null, via: const IList.empty()); + + return switch (this) { + .string => text, + // In the range canonical JSON allows + .integer => switch (int.tryParse(text, radix: 10)) { + final value? when value.abs() <= 9007199254740991 => value, + _ => null, + }, + .boolean => switch (text.toLowerCase()) { + "true" || "yes" => true, + "false" || "no" => false, + _ => null, + }, + .userId when identifier.startsWith("@") && identifier.contains(":") => + identifier, + .roomAlias when identifier.startsWith("#") && identifier.contains(":") => + identifier, + .serverName when text.isValidServerName => text, + .roomId when identifier.startsWith("!") && eventId == null => { + "type": "room_id", + "id": identifier, + if (via.isNotEmpty) "via": via.unlock, + }, + .eventId when identifier.startsWith("!") && eventId != null => { + "type": "event_id", + "id": identifier, + "event_id": eventId, + if (via.isNotEmpty) "via": via.unlock, + }, + .eventId when identifier.startsWith(r"$") => { + "type": "event_id", + "id": roomId, + "event_id": identifier, + }, + _ => null, + }; + } +} diff --git a/lib/models/content/canonical_alias.dart b/lib/models/content/canonical_alias.dart new file mode 100644 index 0000000..058c007 --- /dev/null +++ b/lib/models/content/canonical_alias.dart @@ -0,0 +1,18 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "canonical_alias.freezed.dart"; +part "canonical_alias.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const CanonicalAliasContent({ + @JsonKey(includeIfNull: false) final String? alias, + final ISet altAliases = const .empty(), +}) extends Content with _$CanonicalAliasContent { + @override + Map toJson() => _$CanonicalAliasContentToJson(this); + + factory CanonicalAliasContent.fromJson(Map json) => + _$CanonicalAliasContentFromJson(json); +} diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart new file mode 100644 index 0000000..46e87bd --- /dev/null +++ b/lib/models/content/content.dart @@ -0,0 +1,85 @@ +import "package:collection/collection.dart"; +import "package:nexus/models/content/avatar.dart"; +import "package:nexus/models/content/bot_command_description.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/space_child.dart"; +import "package:nexus/models/content/topic.dart"; +import "package:nexus/models/content/tombstone.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/content/history_visibility.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/content/poll_end.dart"; +import "package:nexus/models/content/poll_response.dart"; +import "package:nexus/models/content/poll_start.dart"; + +class const Content({final String? parseError, final StackTrace? stackTrace}) { + factory Content.fromJson(Map json) => const 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, stackTrace) { + return Content(parseError: error.toString(), stackTrace: stackTrace); + } + } +} + +enum EventType( + final String type, + final Content Function(Map json) contentFromJson, +) { + 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), + tombstone("m.room.tombstone", TombstoneContent.fromJson), + reaction("m.reaction", ReactionContent.fromJson), + pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson), + spaceChild("m.space.child", SpaceChildContent.fromJson), + imagePack("m.room.image_pack", ImagePackContent.fromJson), + legacyImagePack("im.ponies.room_emotes", ImagePackContent.fromJson), + pollStart("org.matrix.msc3381.poll.start", PollStartContent.fromJson), + pollResponse( + "org.matrix.msc3381.poll.response", + PollResponseContent.fromJson, + ), + pollEnd("org.matrix.msc3381.poll.end", PollEndContent.fromJson), + botCommandDescription( + "org.matrix.msc4391.command_description", + BotCommandDescriptionContent.fromJson, + ), + message("m.room.message", MessageContent.fromJson), +} diff --git a/lib/models/content/create.dart b/lib/models/content/create.dart new file mode 100644 index 0000000..1f9f125 --- /dev/null +++ b/lib/models/content/create.dart @@ -0,0 +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"; +part "create.freezed.dart"; +part "create.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const CreateContent({ + @JsonKey(name: "additional_creators") + final IList additionalCreatorIds = const .empty(), + final PreviousRoom? predecessor, + @JsonKey(name: "m.federate") final bool federated = true, + final String roomVersion = "1", + @JsonKey(unknownEnumValue: RoomType.room) final RoomType? type, +}) extends Content with _$CreateContent { + @override + Map toJson() => _$CreateContentToJson(this); + + factory CreateContent.fromJson(Map json) => + _$CreateContentFromJson(json); +} + +enum RoomType { + room, + @JsonValue("m.space") + space, +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PreviousRoom({required final String roomId}) with _$PreviousRoom { + Map toJson() => _$PreviousRoomToJson(this); + + factory PreviousRoom.fromJson(Map json) => + _$PreviousRoomFromJson(json); +} diff --git a/lib/models/content/encrypted.dart b/lib/models/content/encrypted.dart new file mode 100644 index 0000000..2fd7e4a --- /dev/null +++ b/lib/models/content/encrypted.dart @@ -0,0 +1,14 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "encrypted.freezed.dart"; +part "encrypted.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const EncryptedContent() extends Content with _$EncryptedContent { + @override + Map toJson() => _$EncryptedContentToJson(this); + + factory EncryptedContent.fromJson(Map json) => + _$EncryptedContentFromJson(json); +} diff --git a/lib/models/content/encryption.dart b/lib/models/content/encryption.dart new file mode 100644 index 0000000..cb7f1cd --- /dev/null +++ b/lib/models/content/encryption.dart @@ -0,0 +1,18 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "encryption.freezed.dart"; +part "encryption.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const EncryptionContent({ + required final String algorithm, + @JsonKey(name: "rotation_period_ms") final int rotationPeriodMS = 604800000, + @JsonKey(name: "rotation_period_msgs") final int rotationPeriodMessages = 100, +}) extends Content with _$EncryptionContent { + @override + Map toJson() => _$EncryptionContentToJson(this); + + factory EncryptionContent.fromJson(Map json) => + _$EncryptionContentFromJson(json); +} diff --git a/lib/models/content/history_visibility.dart b/lib/models/content/history_visibility.dart new file mode 100644 index 0000000..516cac8 --- /dev/null +++ b/lib/models/content/history_visibility.dart @@ -0,0 +1,20 @@ +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(toJson: false, fromJson: false) +@JsonSerializable() +class const HistoryVisibilityContent({ + @JsonKey(unknownEnumValue: HistoryVisibility.unknown) + required final HistoryVisibility historyVisibility, +}) extends Content with _$HistoryVisibilityContent { + @override + Map toJson() => _$HistoryVisibilityContentToJson(this); + + factory HistoryVisibilityContent.fromJson(Map json) => + _$HistoryVisibilityContentFromJson(json); +} + +@JsonEnum(fieldRename: FieldRename.snake) +enum HistoryVisibility { invited, joined, shared, worldReadable, unknown } diff --git a/lib/models/content/image_pack.dart b/lib/models/content/image_pack.dart new file mode 100644 index 0000000..5bcbb54 --- /dev/null +++ b/lib/models/content/image_pack.dart @@ -0,0 +1,76 @@ +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/info/image.dart"; +part "image_pack.freezed.dart"; +part "image_pack.g.dart"; + +/// An image pack (MSC2545), from a room's state. +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const ImagePackContent({ + final IMap images = const .empty(), + final PackInfo? pack, +}) extends Content with _$ImagePackContent { + /// Whether the pack has images or a name, as deleted packs are left empty. + bool get hasContent => images.isNotEmpty || pack?.displayName != null; + + IMap imagesFor(ImageUsage usage) => images.where( + (_, image) => switch ((image.usage, pack?.usage ?? const .empty())) { + (final usages, _) when usages.isNotEmpty => usages.contains(usage), + (_, final packUsages) when packUsages.isNotEmpty => packUsages.contains( + usage, + ), + _ => true, + }, + ); + + @override + Map toJson() => _$ImagePackContentToJson(this); + + factory ImagePackContent.fromJson(Map json) => + _$ImagePackContentFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const PackImage({ + required final Uri url, + final String? body, + final ImageInfo? info, + @JsonKey(fromJson: ImageUsage.setFromJson, toJson: ImageUsage.setToJson) + final ISet usage = const .empty(), +}) with _$PackImage { + Map toJson() => _$PackImageToJson(this); + + factory PackImage.fromJson(Map json) => + _$PackImageFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const PackInfo({ + final String? displayName, + final Uri? avatarUrl, + final String? attribution, + @JsonKey(fromJson: ImageUsage.setFromJson, toJson: ImageUsage.setToJson) + final ISet usage = const .empty(), +}) with _$PackInfo { + Map toJson() => _$PackInfoToJson(this); + + factory PackInfo.fromJson(Map json) => + _$PackInfoFromJson(json); +} + +enum ImageUsage { + emoticon, + sticker; + + /// Ignores usages we don't know about. + static ISet setFromJson(List? json) => + ISet(values.where((value) => json?.contains(value.name) ?? false)); + + /// Omits an empty set, which means every usage. + static List? setToJson(ISet usages) => + usages.isEmpty ? null : usages.map((usage) => usage.name).toList(); +} diff --git a/lib/models/content/join_rules.dart b/lib/models/content/join_rules.dart new file mode 100644 index 0000000..4f1a929 --- /dev/null +++ b/lib/models/content/join_rules.dart @@ -0,0 +1,39 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/join_rule.dart"; +part "join_rules.freezed.dart"; +part "join_rules.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const JoinRulesContent({ + @JsonKey(unknownEnumValue: JoinRule.unknown) required final JoinRule joinRule, + final ISet allow = const .empty(), +}) extends Content with _$JoinRulesContent { + @override + Map toJson() => _$JoinRulesContentToJson(this); + + factory JoinRulesContent.fromJson(Map json) => + _$JoinRulesContentFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const AllowCondition({ + final String? roomId, + + @JsonKey(unknownEnumValue: AllowConditionType.unknown) + required final AllowConditionType type, +}) with _$AllowCondition { + Map toJson() => _$AllowConditionToJson(this); + + factory AllowCondition.fromJson(Map json) => + _$AllowConditionFromJson(json); +} + +enum AllowConditionType { + @JsonValue("m.room_membership") + membership, + unknown, +} diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart new file mode 100644 index 0000000..e082bf6 --- /dev/null +++ b/lib/models/content/membership.dart @@ -0,0 +1,26 @@ +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(toJson: false, fromJson: false) +@JsonSerializable() +class const MembershipContent({ + @JsonKey(name: "displayname", fromJson: MembershipContent.displaynameFromJson) + required final String? displayName, + @JsonKey(name: "membership", unknownEnumValue: MembershipStatus.unknown) + required final MembershipStatus status, + final Uri? avatarUrl, + final String? reason, + final bool isDirect = false, +}) extends Content with _$MembershipContent { + static String? displaynameFromJson(String? displayName) => + displayName?.isEmpty == true ? null : displayName; + + @override + Map toJson() => _$MembershipContentToJson(this); + + factory MembershipContent.fromJson(Map json) => + _$MembershipContentFromJson(json); +} diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart new file mode 100644 index 0000000..3e7c373 --- /dev/null +++ b/lib/models/content/message.dart @@ -0,0 +1,111 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/bot_command.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"; +import "package:nexus/models/location.dart"; +part "message.freezed.dart"; +part "message.g.dart"; + +typedef EncryptedFile = Map; + +@Freezed(unionKey: "msgtype", fallbackUnion: "default") +sealed class MessageContent extends Content with _$MessageContent { + MessageContent._(); + static String? mediaUrlFromJson(Map json, String key) => + json[key] ?? json["file"]?[key]; + + factory MessageContent({String? body}) = UnknownMessageContent; + + @FreezedUnionValue("m.text") + factory MessageContent.text({ + required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) + MessageFormat? format, + String? formattedBody, + @JsonKey(name: "org.matrix.msc4391.command") BotCommandInput? botCommand, + }) = TextMessageContent; + + @FreezedUnionValue("m.notice") + factory MessageContent.notice({ + required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) + MessageFormat? format, + String? formattedBody, + }) = NoticeMessageContent; + + @FreezedUnionValue("m.emote") + factory MessageContent.emote({ + required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) + MessageFormat? format, + String? formattedBody, + }) = EmoteMessageContent; + + @FreezedUnionValue("m.image") + factory MessageContent.image({ + required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) + MessageFormat? format, + String? formattedBody, + EncryptedFile? file, + String? filename, + ImageInfo? info, + @JsonKey(readValue: MessageContent.mediaUrlFromJson) required Uri url, + }) = ImageMessageContent; + + @FreezedUnionValue("m.file") + factory MessageContent.file({ + required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) + MessageFormat? format, + String? formattedBody, + EncryptedFile? file, + String? filename, + FileInfo? info, + @JsonKey(readValue: MessageContent.mediaUrlFromJson) required Uri url, + }) = FileMessageContent; + + @FreezedUnionValue("m.audio") + factory MessageContent.audio({ + required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) + MessageFormat? format, + String? formattedBody, + EncryptedFile? file, + String? filename, + AudioInfo? info, + @JsonKey(readValue: MessageContent.mediaUrlFromJson) required Uri url, + }) = AudioMessageContent; + + @FreezedUnionValue("m.video") + factory MessageContent.video({ + required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) + MessageFormat? format, + String? formattedBody, + EncryptedFile? file, + String? filename, + VideoInfo? info, + @JsonKey(readValue: MessageContent.mediaUrlFromJson) required Uri url, + }) = VideoMessageContent; + + @FreezedUnionValue("m.location") + factory MessageContent.location({ + required String body, + required Uri geoUri, + @JsonKey(name: "m.location", includeIfNull: false) LocationInfo? location, + @JsonKey(name: "m.asset", includeIfNull: false) LocationAsset? asset, + }) = LocationMessageContent; + + factory MessageContent.fromJson(Map json) => + _$MessageContentFromJson(json); +} + +@JsonEnum() +enum MessageFormat { + @JsonValue("org.matrix.custom.html") + html, +} diff --git a/lib/models/content/name.dart b/lib/models/content/name.dart new file mode 100644 index 0000000..74f583d --- /dev/null +++ b/lib/models/content/name.dart @@ -0,0 +1,16 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "name.freezed.dart"; +part "name.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const NameContent({required final String name}) + extends Content + with _$NameContent { + @override + Map toJson() => _$NameContentToJson(this); + + factory NameContent.fromJson(Map json) => + _$NameContentFromJson(json); +} diff --git a/lib/models/content/pinned_events.dart b/lib/models/content/pinned_events.dart new file mode 100644 index 0000000..cf322af --- /dev/null +++ b/lib/models/content/pinned_events.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/content/content.dart"; +part "pinned_events.freezed.dart"; +part "pinned_events.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PinnedEventsContent({ + @JsonKey(name: "pinned") final IList pinnedEvents = const .empty(), +}) extends Content with _$PinnedEventsContent { + @override + Map toJson() => _$PinnedEventsContentToJson(this); + + factory PinnedEventsContent.fromJson(Map json) => + _$PinnedEventsContentFromJson(json); +} diff --git a/lib/models/content/poll_end.dart b/lib/models/content/poll_end.dart new file mode 100644 index 0000000..51fe58c --- /dev/null +++ b/lib/models/content/poll_end.dart @@ -0,0 +1,12 @@ +import "package:nexus/models/content/content.dart"; + +class const PollEndContent() extends Content { + factory PollEndContent.fromJson(Map json) => + const PollEndContent(); + + @override + Map toJson() => { + "org.matrix.msc3381.poll.end": {}, + "org.matrix.msc1767.text": "Ended the poll", + }; +} diff --git a/lib/models/content/poll_response.dart b/lib/models/content/poll_response.dart new file mode 100644 index 0000000..f151cdf --- /dev/null +++ b/lib/models/content/poll_response.dart @@ -0,0 +1,23 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "poll_response.freezed.dart"; +part "poll_response.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const PollResponseContent({ + @JsonKey(readValue: PollResponseContent.answersFromJson) + final IList answers = const .empty(), +}) extends Content with _$PollResponseContent { + static List? answersFromJson(Map json, _) => + json["org.matrix.msc3381.poll.response"]?["answers"]; + + @override + Map toJson() => { + "org.matrix.msc3381.poll.response": {"answers": answers.toList()}, + }; + + factory PollResponseContent.fromJson(Map json) => + _$PollResponseContentFromJson(json); +} diff --git a/lib/models/content/poll_start.dart b/lib/models/content/poll_start.dart new file mode 100644 index 0000000..197ca69 --- /dev/null +++ b/lib/models/content/poll_start.dart @@ -0,0 +1,90 @@ +import "dart:math"; + +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "poll_start.freezed.dart"; +part "poll_start.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const PollStartContent({ + @JsonKey(name: "org.matrix.msc3381.poll.start") required final PollStart poll, +}) extends Content with _$PollStartContent { + @override + Map toJson() => { + "org.matrix.msc3381.poll.start": poll.toJson(), + // Shown by clients without poll support + "org.matrix.msc1767.text": [ + poll.question, + ...poll.answers.mapIndexed( + (index, answer) => "${index + 1}. ${answer.text}", + ), + ].join("\n"), + }; + + factory PollStartContent.fromJson(Map json) => + _$PollStartContentFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PollStart({ + @JsonKey(readValue: PollStart.questionFromJson) final String question = "", + final int? maxSelections, + @JsonKey(readValue: PollStart.answersFromJson) + final IList answers = const .empty(), +}) with _$PollStart { + static const maxAnswers = 20; + + static String? extensibleTextFromJson(Object? json) => switch (json) { + {"org.matrix.msc1767.text": final String text} => text, + {"body": final String body} => body, + {"org.matrix.msc1767.message": final List message} => + message + .whereType() + .map((part) => part["body"]) + .whereType() + .firstOrNull, + _ => null, + }; + + static String? questionFromJson(Map json, String key) => + PollStart.extensibleTextFromJson(json[key]); + + static List? answersFromJson( + Map json, + String key, + ) => (json[key] as List?)?.take(maxAnswers).toList(); + + /// [maxSelections], defaulting to 1, and at most the number of answers + int get selectionLimit => + (maxSelections ?? 1).clamp(1, max(answers.length, 1)); + + Map toJson() => { + ..._$PollStartToJson(this), + // We don't allow undisclosed polls as they make no sense + "kind": "org.matrix.msc3381.poll.disclosed", + "question": {"org.matrix.msc1767.text": question}, + "answers": answers.map((answer) => answer.toJson()).toList(), + }; + + factory PollStart.fromJson(Map json) => + _$PollStartFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const PollAnswer({ + required final String id, + @JsonKey(readValue: PollAnswer.textFromJson) final String text = "", +}) with _$PollAnswer { + static String? textFromJson(Map json, _) => + PollStart.extensibleTextFromJson(json); + + Map toJson() => {"id": id, "org.matrix.msc1767.text": text}; + + factory PollAnswer.fromJson(Map json) => + _$PollAnswerFromJson(json); +} diff --git a/lib/models/content/power_levels.dart b/lib/models/content/power_levels.dart new file mode 100644 index 0000000..cdffd68 --- /dev/null +++ b/lib/models/content/power_levels.dart @@ -0,0 +1,35 @@ +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(toJson: false, fromJson: false) +@JsonSerializable() +class const PowerLevelsContent({ + final IMap events = const .empty(), + final IMap users = const .empty(), + @JsonKey(includeIfNull: false) final Notifications? notifications, + final int ban = 50, + final int eventsDefault = 0, + final int invite = 0, + final int kick = 50, + final int redact = 50, + final int stateDefault = 50, + final int usersDefault = 0, +}) extends Content with _$PowerLevelsContent { + @override + Map toJson() => _$PowerLevelsContentToJson(this); + + factory PowerLevelsContent.fromJson(Map json) => + _$PowerLevelsContentFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Notifications({final int room = 50}) with _$Notifications { + Map toJson() => _$NotificationsToJson(this); + + factory Notifications.fromJson(Map json) => + _$NotificationsFromJson(json); +} diff --git a/lib/models/content/reaction.dart b/lib/models/content/reaction.dart new file mode 100644 index 0000000..3990788 --- /dev/null +++ b/lib/models/content/reaction.dart @@ -0,0 +1,25 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "reaction.freezed.dart"; +part "reaction.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ReactionContent({ + @JsonKey(readValue: ReactionContent.keyJsonFromJson) final String? key, + + /// For custom emoji reactions + @JsonKey(name: "com.beeper.reaction.shortcode") final String? shortcode, +}) extends Content with _$ReactionContent { + static String? keyJsonFromJson(Map json, String key) => + json["m.relates_to"]?["key"]; + + @override + Map toJson() => { + "m.relates_to": {"key": key}, + "com.beeper.reaction.shortcode": ?shortcode, + }; + + factory ReactionContent.fromJson(Map json) => + _$ReactionContentFromJson(json); +} diff --git a/lib/models/content/redaction.dart b/lib/models/content/redaction.dart new file mode 100644 index 0000000..9864c1f --- /dev/null +++ b/lib/models/content/redaction.dart @@ -0,0 +1,16 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "redaction.freezed.dart"; +part "redaction.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RedactionContent({final String? reason, final String? redacts}) + extends Content + with _$RedactionContent { + @override + Map toJson() => _$RedactionContentToJson(this); + + factory RedactionContent.fromJson(Map json) => + _$RedactionContentFromJson(json); +} diff --git a/lib/models/content/server_acl.dart b/lib/models/content/server_acl.dart new file mode 100644 index 0000000..3c33072 --- /dev/null +++ b/lib/models/content/server_acl.dart @@ -0,0 +1,19 @@ +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(toJson: false, fromJson: false) +@JsonSerializable() +class const ServerACLContent({ + final IList allow = const .empty(), + final IList deny = const .empty(), + final bool allowIpLiterals = true, +}) extends Content with _$ServerACLContent { + @override + Map toJson() => _$ServerACLContentToJson(this); + + factory ServerACLContent.fromJson(Map json) => + _$ServerACLContentFromJson(json); +} diff --git a/lib/models/content/space_child.dart b/lib/models/content/space_child.dart new file mode 100644 index 0000000..07ed5c6 --- /dev/null +++ b/lib/models/content/space_child.dart @@ -0,0 +1,20 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; + +part "space_child.freezed.dart"; +part "space_child.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const SpaceChildContent({ + final IList via = const .empty(), + final String? order, + final bool suggested = false, +}) extends Content with _$SpaceChildContent { + @override + Map toJson() => _$SpaceChildContentToJson(this); + + factory SpaceChildContent.fromJson(Map json) => + _$SpaceChildContentFromJson(json); +} diff --git a/lib/models/content/sticker.dart b/lib/models/content/sticker.dart new file mode 100644 index 0000000..a47a576 --- /dev/null +++ b/lib/models/content/sticker.dart @@ -0,0 +1,19 @@ +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(toJson: false, fromJson: false) +@JsonSerializable() +class const StickerContent({ + required final String body, + required final ImageInfo info, + required final Uri url, +}) extends Content with _$StickerContent { + @override + Map toJson() => _$StickerContentToJson(this); + + factory StickerContent.fromJson(Map json) => + _$StickerContentFromJson(json); +} diff --git a/lib/models/content/tombstone.dart b/lib/models/content/tombstone.dart new file mode 100644 index 0000000..220d0b1 --- /dev/null +++ b/lib/models/content/tombstone.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "tombstone.freezed.dart"; +part "tombstone.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const TombstoneContent({ + final String body = "", + required final String replacementRoom, +}) extends Content with _$TombstoneContent { + @override + Map toJson() => _$TombstoneContentToJson(this); + + factory TombstoneContent.fromJson(Map json) => + _$TombstoneContentFromJson(json); +} diff --git a/lib/models/content/topic.dart b/lib/models/content/topic.dart new file mode 100644 index 0000000..cd187fa --- /dev/null +++ b/lib/models/content/topic.dart @@ -0,0 +1,42 @@ +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(toJson: false, fromJson: false) +@JsonSerializable() +class const TopicContent({ + required final String topic, + @JsonKey(name: "m.topic") final TopicContentBlock? content, +}) extends Content with _$TopicContent { + @override + Map toJson() => _$TopicContentToJson(this); + + factory TopicContent.fromJson(Map json) => + _$TopicContentFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const TopicContentBlock({ + final IList representations = const .empty(), +}) with _$TopicContentBlock { + Map toJson() => _$TopicContentBlockToJson(this); + + factory TopicContentBlock.fromJson(Map json) => + _$TopicContentBlockFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const TextualRepresentation({ + required final String body, + final String mimetype = "text/plain", +}) with _$TextualRepresentation { + Map toJson() => _$TextualRepresentationToJson(this); + + factory TextualRepresentation.fromJson(Map json) => + _$TextualRepresentationFromJson(json); +} diff --git a/lib/models/direction.dart b/lib/models/direction.dart new file mode 100644 index 0000000..8c4947e --- /dev/null +++ b/lib/models/direction.dart @@ -0,0 +1,8 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +enum Direction { + @JsonValue("f") + forward, + @JsonValue("b") + backward, +} diff --git a/lib/models/event.dart b/lib/models/event.dart index 734f667..1467750 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -1,70 +1,112 @@ 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_response.dart"; + part "event.freezed.dart"; part "event.g.dart"; -@freezed -abstract class Event with _$Event { - const factory Event({ - @JsonKey(name: "rowid") required int rowId, - @JsonKey(name: "timeline_rowid") required int timelineRowId, - required String roomId, - required String eventId, - @JsonKey(name: "sender") required String authorId, - required String type, - 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? decryptionError, - String? sendError, - @Default(IMap.empty()) IMap reactions, - @JsonKey(name: "last_edit_rowid") int? lastEditRowId, - @UnreadTypeConverter() UnreadType? unreadType, - }) = _Event; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Event({ + @JsonKey(name: "rowid") required final int rowId, + @JsonKey(name: "timeline_rowid") required final int timelineRowId, + final String? stateKey, + required final String roomId, + required final String eventId, + required final String sender, + @JsonKey(readValue: Event.typeJsonFromJson) required final String type, + @EpochDateTimeConverter() required final DateTime timestamp, + final IMap unsigned = const .empty(), + final LocalContent? localContent, + final String? transactionId, + final String? redactedBy, + final String? relatesTo, + final String? relationType, + final String? replyTo, + final String? decryptionError, + final String? sendError, + final IMap reactions = const .empty(), + @JsonKey(name: "last_edit_rowid") final int lastEditRowId = 0, + final int threadCount = 0, + @JsonKey(name: "thread_latest_rowid") final int threadLatestRowId = 0, + final UnreadType? unreadType, + final Profile? pmp, + required final Content content, + required final Content? previousContent, +}) with _$Event { + Map toJson() => _$EventToJson(this); - factory Event.fromJson(Map json) => _$EventFromJson(json); + String? get threadRoot => relationType == "m.thread" ? relatesTo : null; + + 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; + } + + static String? replyToFromJson(Map json) { + try { + final relatesTo = json["m.relates_to"]; + + // Thread fallbacks are only for clients without thread support + if (relatesTo?["rel_type"] == "m.thread" && + relatesTo?["is_falling_back"] == true) { + return null; + } + + return relatesTo?["m.in_reply_to"]?["event_id"]; + } catch (_) { + return null; + } + } + + factory Event.fromJson(Map json) => + _$EventFromJson(json).copyWith( + replyTo: replyToFromJson(getContentFromJson(json)), + pmp: json["content"]?["com.beeper.per_message_profile"] == null + ? null + : .fromJsonWithCatch( + json["content"]?["com.beeper.per_message_profile"], + ), + content: .fromEventJson( + getContentFromJson(json), + json["decrypted_type"] ?? json["type"], + ), + previousContent: json["unsigned"]?["prev_content"] == null + ? null + : .fromEventJson( + json["unsigned"]?["prev_content"], + json["decrypted_type"] ?? json["type"], + ), + ); } -@freezed -abstract class LocalContent with _$LocalContent { - const factory LocalContent({ - String? sanitizedHtml, - String? editSource, - bool? wasPlaintext, - bool? bigEmoji, - bool? hasMath, - bool? replyFallbackRemoved, - }) = _LocalContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const LocalContent({ + final String? sanitizedHtml, + final String? editSource, + final bool? wasPlaintext, + final bool? bigEmoji, + final bool? hasMath, + final bool? replyFallbackRemoved, +}) with _$LocalContent { + Map toJson() => _$LocalContentToJson(this); + @override factory LocalContent.fromJson(Map json) => _$LocalContentFromJson(json); } -class UnreadTypeConverter implements JsonConverter { - const UnreadTypeConverter(); - - @override - UnreadType? fromJson(int? json) => json == null ? null : UnreadType(json); - - @override - int? toJson(UnreadType? object) => object?.value; -} - -// I think this is correct but I'm not sure, its some type of bitmask. -@immutable -class UnreadType { - final int value; - - const UnreadType(this.value); +@Freezed(toJson: false, fromJson: false) +class const UnreadType(final int value) with _$UnreadType { + factory UnreadType.fromJson(int json) => UnreadType(json); + int toJson() => value; static const none = UnreadType(0); static const normal = UnreadType(1); @@ -72,9 +114,6 @@ class UnreadType { static const highlight = UnreadType(4); static const sound = UnreadType(8); - bool get isNone => value == 0; - bool get isNormal => (value & 1) != 0; - bool get shouldNotify => (value & 2) != 0; - bool get isHighlighted => (value & 4) != 0; - bool get playsSound => (value & 8) != 0; + bool has(UnreadType flag) => value & flag.value != 0; + UnreadType operator |(UnreadType other) => UnreadType(value | other.value); } diff --git a/lib/models/event_context.dart b/lib/models/event_context.dart new file mode 100644 index 0000000..e4ae517 --- /dev/null +++ b/lib/models/event_context.dart @@ -0,0 +1,22 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/event.dart"; + +part "event_context.freezed.dart"; +part "event_context.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const EventContext({ + required final Event event, + required final IList before, + required final IList after, + required final String start, + required final String end, + final IList relatedEvents = const .empty(), +}) with _$EventContext { + Map toJson() => _$EventContextToJson(this); + + factory EventContext.fromJson(Map json) => + _$EventContextFromJson(json); +} diff --git a/lib/models/events_decrypted.dart b/lib/models/events_decrypted.dart new file mode 100644 index 0000000..f35c8aa --- /dev/null +++ b/lib/models/events_decrypted.dart @@ -0,0 +1,23 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/room.dart"; + +part "events_decrypted.freezed.dart"; +part "events_decrypted.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const EventsDecrypted({ + required final String roomId, + @JsonKey(name: "preview_event_rowid") final int? previewEventRowID, + @EpochDateTimeConverter() final DateTime? sortingTimestamp, + + /// [events] is an IMap of eventRowId to event + @JsonKey(fromJson: Room.eventsJsonToIMap) + required final IMap events, +}) with _$EventsDecrypted { + factory EventsDecrypted.fromJson(Map json) => + _$EventsDecryptedFromJson(json); +} diff --git a/lib/models/gomuks_config.dart b/lib/models/gomuks_config.dart new file mode 100644 index 0000000..e6a8ce7 --- /dev/null +++ b/lib/models/gomuks_config.dart @@ -0,0 +1,55 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "gomuks_config.freezed.dart"; +part "gomuks_config.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const GomuksConfig({ + final MatrixConfig? matrix, + final PushConfig? push, + final MediaConfig? media, +}) with _$GomuksConfig { + Map toJson() => _$GomuksConfigToJson(this); + + factory GomuksConfig.fromJson(Map json) => + _$GomuksConfigFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const MatrixConfig({ + @JsonKey(name: "disable_http2") final bool disableHttp2 = false, + @JsonKey(name: "set_presence") final String? setPresence, + @JsonKey(name: "initial_device_display_name") + required final String initialDeviceDisplayName, +}) with _$MatrixConfig { + Map toJson() => _$MatrixConfigToJson(this); + + factory MatrixConfig.fromJson(Map json) => + _$MatrixConfigFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PushConfig({ + @JsonKey(name: "fcm_gateway") required final String fcmGateway, + @JsonKey(name: "vapid_private_key") required final String vapidPrivateKey, + @JsonKey(name: "vapid_public_key") required final String vapidPublicKey, +}) with _$PushConfig { + Map toJson() => _$PushConfigToJson(this); + + factory PushConfig.fromJson(Map json) => + _$PushConfigFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const MediaConfig({ + @JsonKey(name: "thumbnail_size") required final int thumbnailSize, +}) with _$MediaConfig { + Map toJson() => _$MediaConfigToJson(this); + + factory MediaConfig.fromJson(Map json) => + _$MediaConfigFromJson(json); +} diff --git a/lib/models/gomuks_event.dart b/lib/models/gomuks_event.dart new file mode 100644 index 0000000..821ea4a --- /dev/null +++ b/lib/models/gomuks_event.dart @@ -0,0 +1,34 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/client_state.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/events_decrypted.dart"; +import "package:nexus/models/sync_data.dart"; +import "package:nexus/models/sync_status.dart"; +import "package:nexus/models/typing.dart"; + +part "gomuks_event.freezed.dart"; + +@freezed +sealed class GomuksEvent with _$GomuksEvent { + const factory GomuksEvent.clientState(ClientState state) = ClientStateEvent; + const factory GomuksEvent.syncStatus(SyncStatus status) = SyncStatusEvent; + const factory GomuksEvent.initComplete() = InitCompleteEvent; + const factory GomuksEvent.sendComplete(Event event) = SendCompleteEvent; + const factory GomuksEvent.typing(Typing typing) = TypingEvent; + const factory GomuksEvent.eventsDecrypted(EventsDecrypted decrypted) = + EventsDecryptedEvent; + const factory GomuksEvent.syncComplete(SyncData sync) = SyncCompleteEvent; + const factory GomuksEvent.unknown(String type) = UnknownGomuksEvent; + + factory GomuksEvent.parse(String type, Map json) => + switch (type) { + "client_state" => .clientState(.fromJson(json)), + "sync_status" => .syncStatus(.fromJson(json)), + "init_complete" => const .initComplete(), + "send_complete" => .sendComplete(.fromJson(json["event"])), + "typing" => .typing(.fromJson(json)), + "events_decrypted" => .eventsDecrypted(.fromJson(json)), + "sync_complete" => .syncComplete(.fromJson(json)), + _ => .unknown(type), + }; +} diff --git a/lib/models/gomuks_exception.dart b/lib/models/gomuks_exception.dart new file mode 100644 index 0000000..0ae33be --- /dev/null +++ b/lib/models/gomuks_exception.dart @@ -0,0 +1,17 @@ +class GomuksException implements Exception { + final String message; + const GomuksException(this.message); + + /// The Matrix error code, if gomuks returned an error in the form `M_CODE (HTTP 404): message` + String? get errorCode => + RegExp(r"^(M_[A-Z_]+)").firstMatch(message)?.group(1); + + /// The HTTP status code of the failed request + int? get statusCode => switch (RegExp(r"HTTP (\d{3})").firstMatch(message)) { + final match? => int.parse(match.group(1)!), + null => null, + }; + + @override + String toString() => message; +} diff --git a/lib/models/homeserver.dart b/lib/models/homeserver.dart index 903e23d..5504941 100644 --- a/lib/models/homeserver.dart +++ b/lib/models/homeserver.dart @@ -1,12 +1,11 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "homeserver.freezed.dart"; @freezed -abstract class Homeserver with _$Homeserver { - const factory Homeserver({ - required String name, - required String description, - required Uri url, - required String iconUrl, - }) = _Homeserver; -} +class const Homeserver({ + required final String name, + required final String description, + required final Uri url, + required final String iconUrl, +}) with _$Homeserver; diff --git a/lib/models/image_pack.dart b/lib/models/image_pack.dart new file mode 100644 index 0000000..33528c2 --- /dev/null +++ b/lib/models/image_pack.dart @@ -0,0 +1,54 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/image_pack.dart"; + +part "image_pack.freezed.dart"; + +@Freezed(toJson: false, fromJson: false) +class const ImagePack({ + required final String id, + required final String name, + final Uri? avatar, + + /// Map of shortcode to images + required final IMap emoticons, + required final IMap stickers, + + /// The state event the pack is from, for editing it. + required final ImagePackContent content, +}) with _$ImagePack { + /// The pack's avatar, or its first image if it has none. + Uri? get icon => avatar ?? content.images.values.firstOrNull?.url; + + String get imageCountLabel => content.images.length == 1 + ? "1 image" + : "${content.images.length} images"; + + /// The name of a pack without one, from its room's name and state key. + static String fallbackName(String roomName, String stateKey) => + stateKey.isEmpty ? roomName : "$roomName - $stateKey"; + + static String emojiMarkdown( + String shortcode, + String url, { + String? description, + }) => + '![:$shortcode:]($url "Emoji: ${switch (description) { + final description? when description.isNotEmpty => description, + _ => ":$shortcode:", + }.replaceAll(r"\", r"\\").replaceAll('"', r'\"')}")'; + + factory ImagePack.fromContent( + ImagePackContent content, { + required String roomId, + required String roomName, + required String stateKey, + }) => .new( + id: "$roomId/$stateKey", + name: content.pack?.displayName ?? fallbackName(roomName, stateKey), + avatar: content.pack?.avatarUrl, + emoticons: content.imagesFor(.emoticon), + stickers: content.imagesFor(.sticker), + content: content, + ); +} diff --git a/lib/models/info/audio.dart b/lib/models/info/audio.dart new file mode 100644 index 0000000..ac90282 --- /dev/null +++ b/lib/models/info/audio.dart @@ -0,0 +1,18 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/ms_duration.dart"; + +part "audio.freezed.dart"; +part "audio.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const AudioInfo({ + @MSDuration() final Duration? duration, + @JsonKey(name: "mimetype") final String? mimeType, + final int? size, +}) with _$AudioInfo { + Map toJson() => _$AudioInfoToJson(this); + + factory AudioInfo.fromJson(Map json) => + _$AudioInfoFromJson(json); +} diff --git a/lib/models/info/file.dart b/lib/models/info/file.dart new file mode 100644 index 0000000..ce0aa84 --- /dev/null +++ b/lib/models/info/file.dart @@ -0,0 +1,16 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "file.freezed.dart"; +part "file.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const FileInfo({ + @JsonKey(name: "mimetype") final String? mimeType, + final int? size, +}) with _$FileInfo { + Map toJson() => _$FileInfoToJson(this); + + factory FileInfo.fromJson(Map json) => + _$FileInfoFromJson(json); +} diff --git a/lib/models/info/image.dart b/lib/models/info/image.dart new file mode 100644 index 0000000..cc09d1c --- /dev/null +++ b/lib/models/info/image.dart @@ -0,0 +1,19 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "image.freezed.dart"; +part "image.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ImageInfo({ + @JsonKey(name: "h") final int? height, + @JsonKey(name: "w") final int? width, + @JsonKey(name: "mimetype") final String? mimeType, + @JsonKey(name: "xyz.amorgan.blurhash") final String? blurHash, + final int? size, +}) with _$ImageInfo { + Map toJson() => _$ImageInfoToJson(this); + + factory ImageInfo.fromJson(Map json) => + _$ImageInfoFromJson(json); +} diff --git a/lib/models/info/video.dart b/lib/models/info/video.dart new file mode 100644 index 0000000..31b1fb6 --- /dev/null +++ b/lib/models/info/video.dart @@ -0,0 +1,20 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/ms_duration.dart"; + +part "video.freezed.dart"; +part "video.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const VideoInfo({ + @JsonKey(name: "h") final int? height, + @JsonKey(name: "w") final int? width, + @JsonKey(name: "mimetype") final String? mimeType, + @MSDuration() final Duration? duration, + final int? size, +}) with _$VideoInfo { + Map toJson() => _$VideoInfoToJson(this); + + factory VideoInfo.fromJson(Map json) => + _$VideoInfoFromJson(json); +} diff --git a/lib/models/invited_room.dart b/lib/models/invited_room.dart new file mode 100644 index 0000000..6dba1d9 --- /dev/null +++ b/lib/models/invited_room.dart @@ -0,0 +1,16 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/matrix_event.dart"; + +part "invited_room.freezed.dart"; +part "invited_room.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const InvitedRoom({ + required final String roomId, + final IList inviteState = const .empty(), +}) with _$InvitedRoom { + factory InvitedRoom.fromJson(Map json) => + _$InvitedRoomFromJson(json); +} diff --git a/lib/models/join_rule.dart b/lib/models/join_rule.dart new file mode 100644 index 0000000..a82c62a --- /dev/null +++ b/lib/models/join_rule.dart @@ -0,0 +1,13 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +@JsonEnum(fieldRename: .snake) +enum JoinRule { + public, + knock, + invite, + private, + restricted, + knockRestricted, + + unknown, +} diff --git a/lib/models/lazy_load_summary.dart b/lib/models/lazy_load_summary.dart index 0cd250f..fba1c98 100644 --- a/lib/models/lazy_load_summary.dart +++ b/lib/models/lazy_load_summary.dart @@ -1,15 +1,18 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; + part "lazy_load_summary.freezed.dart"; part "lazy_load_summary.g.dart"; -@freezed -abstract class LazyLoadSummary with _$LazyLoadSummary { - const factory LazyLoadSummary({ - required IList? heroes, - required int? joinedMemberCount, - required int? invitedMemberCount, - }) = _LazyLoadSummary; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const LazyLoadSummary({ + @JsonKey(name: "m.heroes") required final IList? heroes, + @JsonKey(name: "m.joined_member_count") required final int? joinedMemberCount, + @JsonKey(name: "m.invited_member_count") + required final int? invitedMemberCount, +}) with _$LazyLoadSummary { + Map toJson() => _$LazyLoadSummaryToJson(this); factory LazyLoadSummary.fromJson(Map json) => _$LazyLoadSummaryFromJson(json); diff --git a/lib/models/location.dart b/lib/models/location.dart new file mode 100644 index 0000000..767e2dc --- /dev/null +++ b/lib/models/location.dart @@ -0,0 +1,84 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:latlong2/latlong.dart"; +import "package:nexus/models/content/message.dart"; + +part "location.freezed.dart"; +part "location.g.dart"; + +/// A location message's `m.location` block. +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const LocationInfo({required final Uri uri, final String? description}) + with _$LocationInfo { + Map toJson() => _$LocationInfoToJson(this); + + factory LocationInfo.fromJson(Map json) => + _$LocationInfoFromJson(json); +} + +/// A location message's `m.asset` block. +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const LocationAsset({ + @JsonKey(unknownEnumValue: LocationAssetType.pin) + final LocationAssetType type = .self, +}) with _$LocationAsset { + Map toJson() => _$LocationAssetToJson(this); + + factory LocationAsset.fromJson(Map json) => + _$LocationAssetFromJson(json); +} + +enum LocationAssetType { + @JsonValue("m.self") + self, + + @JsonValue("m.pin") + pin, +} + +extension LocationMessagePoint on LocationMessageContent { + LatLng? get point => location?.uri.latLng ?? geoUri.latLng; + + String? get description => switch (location?.description?.trim()) { + final description? when description.isNotEmpty => description, + _ => null, + }; + + LocationAssetType get assetType => asset?.type ?? .self; +} + +extension FormatLatLng on LatLng { + String get formatted => + "${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}"; +} + +extension GeoUri on Uri { + /// The coordinates of a geo: URI, like `geo:51.5,-0.12;u=35`. + LatLng? get latLng { + if (scheme != "geo") return null; + final coordinates = path.split(";").first.split(","); + if (coordinates.length < 2) return null; + + return switch (( + double.tryParse(coordinates[0]), + double.tryParse(coordinates[1]), + )) { + (final latitude?, final longitude?) + when latitude.abs() <= 90 && longitude.abs() <= 180 => + LatLng(latitude, longitude), + _ => null, + }; + } + + static Uri openStreetMap(LatLng point) => Uri.https( + "www.openstreetmap.org", + "/", + {"mlat": "${point.latitude}", "mlon": "${point.longitude}"}, + ).replace(fragment: "map=16/${point.latitude}/${point.longitude}"); + + static Uri fromLatLng(LatLng point, {double? uncertainty}) => Uri.parse( + "geo:${point.latitude},${point.longitude}" + "${uncertainty == null ? "" : ";u=${uncertainty.round()}"}", + ); +} diff --git a/lib/models/location_exception.dart b/lib/models/location_exception.dart new file mode 100644 index 0000000..107cc55 --- /dev/null +++ b/lib/models/location_exception.dart @@ -0,0 +1,11 @@ +enum LocationException(final String message) implements Exception { + servicesOff("Location services are turned off."), + denied("Nexus doesn't have permission to see your location."), + timedOut("Couldn't get your location in time."), + failed( + "Couldn't get your location. Check that location services are on, and that Nexus is allowed to use them.", + ); + + @override + String toString() => message; +} diff --git a/lib/models/matrix_event.dart b/lib/models/matrix_event.dart new file mode 100644 index 0000000..2eddbff --- /dev/null +++ b/lib/models/matrix_event.dart @@ -0,0 +1,28 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; + +part "matrix_event.freezed.dart"; +part "matrix_event.g.dart"; + +/// A plain Matrix event, as sent by the server (e.g. stripped invite state). +/// +/// Unlike [Event], this isn't stored in the gomuks database, so it has no row +/// IDs or local content. +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const MatrixEvent({ + required final String type, + final String? stateKey, + final String? sender, + final String? eventId, + final String? roomId, + @JsonKey(name: "origin_server_ts") + @EpochDateTimeConverter() + final DateTime? timestamp, + required final Content content, +}) with _$MatrixEvent { + factory MatrixEvent.fromJson(Map json) => + _$MatrixEventFromJson(json) + .copyWith(content: .fromEventJson(json["content"], json["type"])); +} diff --git a/lib/models/membership_action.dart b/lib/models/membership_action.dart new file mode 100644 index 0000000..f03ca99 --- /dev/null +++ b/lib/models/membership_action.dart @@ -0,0 +1 @@ +enum MembershipAction { ban, kick, unban, invite } diff --git a/lib/models/membership_status.dart b/lib/models/membership_status.dart new file mode 100644 index 0000000..f8197cb --- /dev/null +++ b/lib/models/membership_status.dart @@ -0,0 +1 @@ +enum MembershipStatus { leave, invite, ban, join, knock, unknown } diff --git a/lib/models/mentions_response.dart b/lib/models/mentions_response.dart new file mode 100644 index 0000000..27b6d10 --- /dev/null +++ b/lib/models/mentions_response.dart @@ -0,0 +1,16 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/event.dart"; + +part "mentions_response.freezed.dart"; +part "mentions_response.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const MentionsResponse({ + required final IList events, + final IList relatedEvents = const .empty(), +}) with _$MentionsResponse { + factory MentionsResponse.fromJson(Map json) => + _$MentionsResponseFromJson(json); +} diff --git a/lib/models/message_config.dart b/lib/models/message_config.dart deleted file mode 100644 index 9020f78..0000000 --- a/lib/models/message_config.dart +++ /dev/null @@ -1,28 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/room.dart"; -part "message_config.freezed.dart"; -part "message_config.g.dart"; - -@freezed -abstract class MessageConfig with _$MessageConfig { - const MessageConfig._(); - const factory MessageConfig({ - @Default(false) bool alwaysReturn, - @Default(false) bool includeEdits, - required Room room, - required Event event, - }) = _MessageConfig; - - @override - bool operator ==(Object other) => - other.runtimeType == runtimeType && - other is MessageConfig && - other.event.eventId == event.eventId; - - @override - int get hashCode => Object.hash(runtimeType, event.eventId); - - factory MessageConfig.fromJson(Map json) => - _$MessageConfigFromJson(json); -} diff --git a/lib/models/messages_config.dart b/lib/models/messages_config.dart deleted file mode 100644 index b33a71c..0000000 --- a/lib/models/messages_config.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/room.dart"; -part "messages_config.freezed.dart"; -part "messages_config.g.dart"; - -@freezed -abstract class MessagesConfig with _$MessagesConfig { - const factory MessagesConfig({ - required Room room, - required IList events, - }) = _MessagesConfig; - - factory MessagesConfig.fromJson(Map json) => - _$MessagesConfigFromJson(json); -} diff --git a/lib/models/ms_duration.dart b/lib/models/ms_duration.dart new file mode 100644 index 0000000..de12943 --- /dev/null +++ b/lib/models/ms_duration.dart @@ -0,0 +1,11 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +class MSDuration implements JsonConverter { + const MSDuration(); + + @override + Duration fromJson(int ms) => Duration(milliseconds: ms); + + @override + int toJson(Duration duration) => duration.inMilliseconds; +} diff --git a/lib/models/name_color.dart b/lib/models/name_color.dart new file mode 100644 index 0000000..f67429e --- /dev/null +++ b/lib/models/name_color.dart @@ -0,0 +1,2 @@ +/// What users' name colors are hashed from. +enum NameColor { powerLevel, user } diff --git a/lib/models/nav_page.dart b/lib/models/nav_page.dart new file mode 100644 index 0000000..083ead4 --- /dev/null +++ b/lib/models/nav_page.dart @@ -0,0 +1,10 @@ +import "package:flutter/widgets.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +abstract class const NavPage({super.key}) extends Widget { + String get title; + IconData get icon; + + /// Null hides the badge, and 0 shows a dot. + int? badgeCount(WidgetRef ref) => null; +} diff --git a/lib/models/oauth_auth_code_response.dart b/lib/models/oauth_auth_code_response.dart new file mode 100644 index 0000000..3074335 --- /dev/null +++ b/lib/models/oauth_auth_code_response.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "oauth_auth_code_response.freezed.dart"; +part "oauth_auth_code_response.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const OAuthAuthCodeResponse({ + required final String state, + required final String codeVerifier, + required final Uri url, +}) with _$OAuthAuthCodeResponse { + Map toJson() => _$OAuthAuthCodeResponseToJson(this); + + factory OAuthAuthCodeResponse.fromJson(Map json) => + _$OAuthAuthCodeResponseFromJson(json); +} diff --git a/lib/models/open_graph_data.dart b/lib/models/open_graph_data.dart new file mode 100644 index 0000000..a183ef7 --- /dev/null +++ b/lib/models/open_graph_data.dart @@ -0,0 +1,19 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "open_graph_data.freezed.dart"; +part "open_graph_data.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const OpenGraphData({ + @JsonKey(name: "og:title") required final String? title, + @JsonKey(name: "og:description") required final String? description, + @JsonKey(name: "og:image") required final Uri? imageUrl, + @JsonKey(name: "og:image:width") required final double? width, + @JsonKey(name: "og:image:height") required final double? height, +}) with _$OpenGraphData { + Map toJson() => _$OpenGraphDataToJson(this); + + factory OpenGraphData.fromJson(Map json) => + _$OpenGraphDataFromJson(json); +} diff --git a/lib/models/paginate.dart b/lib/models/paginate.dart index df0a0f6..b7c6bf8 100644 --- a/lib/models/paginate.dart +++ b/lib/models/paginate.dart @@ -1,16 +1,22 @@ 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/read_receipt.dart"; + part "paginate.freezed.dart"; part "paginate.g.dart"; -@freezed -abstract class Paginate with _$Paginate { - const factory Paginate({ - required IList events, - required IList relatedEvents, - required bool hasMore, - }) = _Paginate; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Paginate({ + required final IList events, + required final IList relatedEvents, + + /// Read receipts for the events, by event ID. + final IMap> receipts = const .empty(), + required final bool hasMore, +}) with _$Paginate { + Map toJson() => _$PaginateToJson(this); factory Paginate.fromJson(Map json) => _$PaginateFromJson(json); diff --git a/lib/models/paginate_manual.dart b/lib/models/paginate_manual.dart new file mode 100644 index 0000000..fbd3fa7 --- /dev/null +++ b/lib/models/paginate_manual.dart @@ -0,0 +1,19 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/event.dart"; + +part "paginate_manual.freezed.dart"; +part "paginate_manual.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PaginateManual({ + final IList events = const .empty(), + final IList relatedEvents = const .empty(), + required final String? nextBatch, +}) with _$PaginateManual { + Map toJson() => _$PaginateManualToJson(this); + + factory PaginateManual.fromJson(Map json) => + _$PaginateManualFromJson(json); +} diff --git a/lib/models/poll_votes.dart b/lib/models/poll_votes.dart new file mode 100644 index 0000000..47895b9 --- /dev/null +++ b/lib/models/poll_votes.dart @@ -0,0 +1,25 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +class const PollVotes({ + /// Each user's answers, by user ID + final IMap> votes = const .empty(), + final String? endEventId, + final int undecryptable = 0, +}) { + bool get hasEnded => endEventId != null; + + IMap> get votersByAnswer => votes.entries.fold( + const .empty(), + (acc, entry) => entry.value.fold( + acc, + (acc, answer) => acc.update( + answer, + (voters) => voters.add(entry.key), + ifAbsent: () => .new([entry.key]), + ), + ), + ); + + int get voterCount => + votes.values.where((answers) => answers.isNotEmpty).length; +} diff --git a/lib/models/power_level_permission.dart b/lib/models/power_level_permission.dart new file mode 100644 index 0000000..8c26ee2 --- /dev/null +++ b/lib/models/power_level_permission.dart @@ -0,0 +1,247 @@ +import "dart:math"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/power_levels.dart"; + +enum PowerLevelPermission( + final String title, + final String description, + final IconData icon, +) { + usersDefault( + "Default Power Level", + "The power level of users who aren't given one.", + Icons.person, + ), + eventsDefault( + "Default for Events", + "The power level needed to send events without their own.", + Icons.more_horiz, + ), + stateDefault( + "Default for State Events", + "The power level needed to send state events without their own.", + Icons.tune, + ), + invite( + "Invite Users", + "The power level needed to invite users.", + Icons.person_add, + ), + kick( + "Kick Users", + "The power level needed to kick users.", + Icons.sports_martial_arts, + ), + ban( + "Ban Users", + "The power level needed to ban and unban users.", + Icons.gavel, + ), + redact( + "Delete Others' Messages", + "The power level needed to remove others' messages.", + Icons.delete, + ), + notifyRoom( + "Notify Everyone", + "The power level needed to notify everyone in the room with @room.", + Icons.campaign, + ); + + int levelIn(PowerLevelsContent content) => switch (this) { + .usersDefault => content.usersDefault, + .eventsDefault => content.eventsDefault, + .stateDefault => content.stateDefault, + .invite => content.invite, + .kick => content.kick, + .ban => content.ban, + .redact => content.redact, + .notifyRoom => content.notifications?.room ?? 50, + }; + + PowerLevelsContent withLevel(PowerLevelsContent content, int level) => + switch (this) { + .usersDefault => content.copyWith(usersDefault: level), + .eventsDefault => content.copyWith(eventsDefault: level), + .stateDefault => content.copyWith(stateDefault: level), + .invite => content.copyWith(invite: level), + .kick => content.copyWith(kick: level), + .ban => content.copyWith(ban: level), + .redact => content.copyWith(redact: level), + .notifyRoom => content.copyWith( + notifications: (content.notifications ?? const Notifications()) + .copyWith(room: level), + ), + }; +} + +enum PowerLevelEvent( + final EventType type, + final String title, + final String description, + final IconData icon, +) { + message( + .message, + "Send Messages", + "The power level needed to send messages.", + Icons.message, + ), + // Everything a client encrypts is sent as this, so its level is what limits + // messages, stickers, and the like in encrypted rooms + encrypted( + .encrypted, + "Send Encrypted Events", + "The power level needed to send messages, stickers, files and anything else encrypted.", + Icons.lock, + ), + sticker( + .sticker, + "Send Stickers", + "The power level needed to send stickers.", + Icons.sticky_note_2, + ), + reaction( + .reaction, + "Send Reactions", + "The power level needed to react to messages.", + Icons.add_reaction, + ), + pinnedEvents( + .pinnedEvents, + "Pin Messages", + "The power level needed to pin messages.", + Icons.push_pin, + ), + name( + .name, + "Change Name", + "The power level needed to change the room's name.", + Icons.title, + ), + topic( + .topic, + "Change Topic", + "The power level needed to change the room's topic.", + Icons.description, + ), + avatar( + .avatar, + "Change Avatar", + "The power level needed to change the room's avatar.", + Icons.image, + ), + canonicalAlias( + .canonicalAlias, + "Change Aliases", + "The power level needed to change the room's canonical alias and published aliases.", + Icons.numbers, + ), + powerLevels( + .powerLevels, + "Change Power Levels", + "The power level needed to change power levels, including these.", + Icons.admin_panel_settings, + ), + joinRules( + .joinRules, + "Change Join Rule", + "The power level needed to change who can join the room.", + Icons.login, + ), + historyVisibility( + .historyVisibility, + "Change History Visibility", + "The power level needed to change who can read the room's history.", + Icons.history, + ), + serverACL( + .serverACL, + "Change Server ACL", + "The power level needed to change which servers can take part in the room.", + Icons.dns, + ), + encryption( + .encryption, + "Enable Encryption", + "The power level needed to enable encryption, which can't be turned off.", + Icons.lock, + ), + tombstone( + .tombstone, + "Upgrade Room", + "The power level needed to replace the room with an upgraded one.", + Icons.upgrade, + ); + + static IList shownIn({required bool encrypted}) => .new([ + if (encrypted) .encrypted else ...[.message, .sticker], + .reaction, + ]); + + static PowerLevelEvent? of(String type) => + values.where((event) => event.type.type == type).firstOrNull; +} + +/// Power levels set together, like everything moderators need, to save +/// setting each one. Each can still be set on its own, by expanding it. +enum PowerLevelGroup(final String title, final IconData icon) { + moderation("Moderation", Icons.shield), + roomDetails("Room Details", Icons.edit), + administration("Administration", Icons.admin_panel_settings); + + IList get permissions => .new(switch (this) { + .moderation => [.kick, .ban, .redact, .notifyRoom], + .roomDetails || .administration => [], + }); + + IList get events => .new(switch (this) { + .moderation => [], + .roomDetails => [.name, .topic, .avatar, .canonicalAlias, .pinnedEvents], + .administration => [ + .powerLevels, + .joinRules, + .historyVisibility, + .serverACL, + .encryption, + .tombstone, + ], + }); + + String get description => [ + ...permissions.map((permission) => permission.title), + ...events.map((event) => event.title), + ].join(", "); + + int? levelIn(PowerLevelsContent content) { + final levels = { + ...permissions.map((permission) => permission.levelIn(content)), + ...events.map( + (event) => content.events[event.type.type] ?? content.stateDefault, + ), + }; + return levels.length == 1 ? levels.single : null; + } + + int highestIn(PowerLevelsContent content) => [ + ...permissions.map((permission) => permission.levelIn(content)), + ...events.map( + (event) => content.events[event.type.type] ?? content.stateDefault, + ), + ].reduce(max); + + PowerLevelsContent withLevel(PowerLevelsContent content, int level) => + permissions + .fold( + content, + (content, permission) => permission.withLevel(content, level), + ) + .copyWith( + events: content.events.addEntries( + events.map((event) => MapEntry(event.type.type, level)), + ), + ); +} diff --git a/lib/models/profile.dart b/lib/models/profile.dart deleted file mode 100644 index d92b4f6..0000000 --- a/lib/models/profile.dart +++ /dev/null @@ -1,29 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -part "profile.freezed.dart"; -part "profile.g.dart"; - -@freezed -abstract class Profile with _$Profile { - const factory Profile({ - String? avatarUrl, - @JsonKey(name: "displayname") String? displayName, - @JsonKey(name: "us.cloke.msc4175.tz") String? timezone, - - @Default(IList.empty()) - @JsonKey(name: "io.fsky.nyx.pronouns") - IList pronouns, - }) = _Profile; - - factory Profile.fromJson(Map json) => - _$ProfileFromJson(json); -} - -@freezed -abstract class Pronoun with _$Pronoun { - const factory Pronoun({required String language, required String summary}) = - _Pronoun; - - factory Pronoun.fromJson(Map json) => - _$PronounFromJson(json); -} diff --git a/lib/models/profile_response.dart b/lib/models/profile_response.dart new file mode 100644 index 0000000..aa1b9f8 --- /dev/null +++ b/lib/models/profile_response.dart @@ -0,0 +1,92 @@ +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_response.freezed.dart"; +part "profile_response.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ProfileResponse({ + @JsonKey(fromJson: Profile.fromJson) required final Profile profile, + final Bio? bio, +}) with _$ProfileResponse { + Map toJson() => _$ProfileResponseToJson(this); + + factory ProfileResponse.fromJson(Map json) => + _$ProfileResponseFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Bio({required final String html, final String? editSource}) + with _$Bio { + Map toJson() => _$BioToJson(this); + + factory Bio.fromJson(Map json) => _$BioFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Profile({ + final String? id, + final String? parseError, + @JsonKey(name: Profile.avatarUrlKey) final Uri? avatarUrl, + + @JsonKey( + name: Profile.displayNameKey, + fromJson: MembershipContent.displaynameFromJson, + ) + final String? displayName, + + @JsonKey(name: Profile.timezoneKey) final String? timezone, + + @JsonKey(readValue: Profile.readPronouns, name: Profile.pronounsKey) + final IList pronouns = const .empty(), +}) with _$Profile { + static const avatarUrlKey = "avatar_url"; + static const displayNameKey = "displayname"; + static const timezoneKey = "m.tz"; + static const pronounsKey = "m.pronouns"; + static const unstablePronounsKey = "io.fsky.nyx.pronouns"; + + Map toJson() => _$ProfileToJson(this); + + Profile withPronouns(String language, Iterable summaries) => copyWith( + pronouns: pronouns + .where((pronoun) => pronoun.language != language) + .toIList() + .addAll( + summaries.map( + (summary) => Pronoun(language: language, summary: summary), + ), + ), + ); + + static Object? readPronouns(Map map, String key) => + /* until stable, commented out: map[key] ?? */ map[Profile + .unstablePronounsKey]; + + factory Profile.fromJson(Map json) => + _$ProfileFromJson(json); + + factory Profile.fromJsonWithCatch(Map json) { + try { + return Profile.fromJson(json); + } catch (error) { + return Profile(parseError: error.toString()); + } + } +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Pronoun({ + required final String language, + required final String summary, +}) with _$Pronoun { + Map toJson() => _$PronounToJson(this); + + factory Pronoun.fromJson(Map json) => + _$PronounFromJson(json); +} diff --git a/lib/models/push_notification.dart b/lib/models/push_notification.dart new file mode 100644 index 0000000..859b457 --- /dev/null +++ b/lib/models/push_notification.dart @@ -0,0 +1,44 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; + +part "push_notification.freezed.dart"; +part "push_notification.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const PushNotification({ + @EpochDateTimeConverter() required final DateTime timestamp, + + required final String eventId, + @JsonKey(name: "event_rowid") required final int eventRowId, + + required final String roomId, + required final String roomName, + final Uri? roomAvatar, + final bool isDm = false, + + required final NotificationUser sender, + required final NotificationUser self, + final NotificationUser? perMessageProfile, + + required final String text, + final Uri? image, + + final bool mention = false, + final bool reply = false, + final bool sound = false, +}) with _$PushNotification { + factory PushNotification.fromJson(Map json) => + _$PushNotificationFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const NotificationUser({ + required final String id, + required final String name, + final Uri? avatar, +}) with _$NotificationUser { + factory NotificationUser.fromJson(Map json) => + _$NotificationUserFromJson(json); +} diff --git a/lib/models/push_rule_switch.dart b/lib/models/push_rule_switch.dart new file mode 100644 index 0000000..1c385d2 --- /dev/null +++ b/lib/models/push_rule_switch.dart @@ -0,0 +1,150 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/push_rules.dart"; + +enum PushRuleSection( + final String title, + final String description, + final IconData icon, +) { + messages("Messages", "Which messages notify you.", Icons.chat), + otherEvents("Other Events", "Events other than messages.", Icons.more_horiz), +} + +/// Default push rules turned on or off together, as some match the same events +/// in different ways, like encrypted ones, which the server can't read. +enum PushRuleSwitch( + final PushRuleSection section, + final String title, + final String description, + final IconData icon, + + /// What the rules do when on. + final PushActions actions, + + /// Whether turning it off disables the rules, so the next rule that matches + /// decides, rather than not notifying. + final bool disableWhenOff, +) { + mentions( + .messages, + "Mentions", + "Messages that mention you, or contain your name.", + Icons.alternate_email, + .highlightWithSound, + true, + ), + roomMentions( + .messages, + "Room Mentions", + "Messages that notify everyone in the room with @room.", + Icons.campaign, + .highlightWithSound, + true, + ), + directMessages( + .messages, + "Direct Messages", + "Messages in rooms with just you and one other person.", + Icons.person, + .notifyWithSound, + false, + ), + groupMessages( + .messages, + "Group Messages", + "Messages in rooms with more people, rather than only mentions and keywords.", + Icons.group, + .notifyWithSound, + false, + ), + invites( + .otherEvents, + "Invites", + "Invites for you to join rooms.", + Icons.mail, + .notifyWithSound, + false, + ), + calls( + .otherEvents, + "Calls", + "Incoming calls.", + Icons.call, + .new(notify: true, sound: "ring"), + false, + ), + tombstones( + .otherEvents, + "Room Tombstones", + "Rooms being upgraded or locked.", + Icons.lock, + .notifyWithSound, + false, + ); + + IList<({PushRuleKind kind, String ruleId})> get rules => .new(switch (this) { + .mentions => [ + (kind: .override, ruleId: ".m.rule.is_user_mention"), + (kind: .override, ruleId: ".m.rule.contains_display_name"), + (kind: .content, ruleId: ".m.rule.contains_user_name"), + ], + .roomMentions => [ + (kind: .override, ruleId: ".m.rule.is_room_mention"), + (kind: .override, ruleId: ".m.rule.roomnotif"), + ], + .directMessages => [ + (kind: .underride, ruleId: ".m.rule.room_one_to_one"), + (kind: .underride, ruleId: ".m.rule.encrypted_room_one_to_one"), + ], + .groupMessages => [ + (kind: .underride, ruleId: ".m.rule.message"), + (kind: .underride, ruleId: ".m.rule.encrypted"), + ], + .invites => [(kind: .override, ruleId: ".m.rule.invite_for_me")], + .calls => [(kind: .underride, ruleId: ".m.rule.call")], + .tombstones => [(kind: .override, ruleId: ".m.rule.tombstone")], + }); + + /// The rules in [ruleset], as servers may not have them all. + IList<({PushRuleKind kind, PushRule rule})> rulesIn(PushRuleset ruleset) => + rules + .map( + (rule) => switch (ruleset.find(rule.kind, rule.ruleId)) { + final found? => (kind: rule.kind, rule: found), + null => null, + }, + ) + .nonNulls + .toIList(); + + /// Whether the rules in [ruleset] are on or off, or null if they're set up + /// some other way. + bool? valueIn(PushRuleset ruleset) => switch (rulesIn(ruleset) + .map((entry) => entry.rule)) { + final rules + when rules.every((rule) => rule.enabled && rule.actions == actions) => + true, + final rules + when rules.every((rule) => !rule.enabled || !rule.actions.notify) => + false, + _ => null, + }; + + /// The rules in [ruleset], turned on or off. + IList<({PushRuleKind kind, PushRule rule})> changesIn( + PushRuleset ruleset, + bool value, + ) => rulesIn(ruleset) + .map( + (entry) => ( + kind: entry.kind, + rule: value + ? entry.rule.copyWith(enabled: true, actions: actions) + : disableWhenOff + ? entry.rule.copyWith(enabled: false) + : entry.rule.copyWith(enabled: true, actions: const .new()), + ), + ) + .toIList(); +} diff --git a/lib/models/push_rules.dart b/lib/models/push_rules.dart new file mode 100644 index 0000000..88fb050 --- /dev/null +++ b/lib/models/push_rules.dart @@ -0,0 +1,128 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "push_rules.freezed.dart"; +part "push_rules.g.dart"; + +/// The content of the m.push_rules account data. +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PushRules({final PushRuleset global = const .new()}) + with _$PushRules { + Map toJson() => _$PushRulesToJson(this); + + factory PushRules.fromJson(Map json) => + _$PushRulesFromJson(json); +} + +/// Push rules by kind, apart from sender rules, which Nexus doesn't use. The +/// first rule that matches decides what happens. +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PushRuleset({ + final IList override = const .empty(), + final IList content = const .empty(), + final IList room = const .empty(), + final IList underride = const .empty(), +}) with _$PushRuleset { + IList ofKind(PushRuleKind kind) => switch (kind) { + .override => override, + .content => content, + .room => room, + .underride => underride, + }; + + PushRule? find(PushRuleKind kind, String ruleId) => + ofKind(kind).where((rule) => rule.ruleId == ruleId).firstOrNull; + + /// Mutes everything when enabled, as it matches every event. + PushRule? get master => find(.override, ".m.rule.master"); + + /// The content rules the user added, which match these words. + IList get keywords => + content.where((rule) => !rule.isDefault).toIList(); + + Map toJson() => _$PushRulesetToJson(this); + + factory PushRuleset.fromJson(Map json) => + _$PushRulesetFromJson(json); +} + +enum PushRuleKind { override, content, room, underride } + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PushRule({ + required final String ruleId, + final PushActions actions = const .new(), + + /// Whether the server made the rule, so it can't be deleted. + @JsonKey(name: "default") final bool isDefault = false, + final bool enabled = true, + + /// What content rules match in messages' bodies. + final String? pattern, + + /// What override and underride rules match. + final IList conditions = const .empty(), +}) with _$PushRule { + Map toJson() => _$PushRuleToJson(this); + + factory PushRule.fromJson(Map json) => + _$PushRuleFromJson(json); +} + +/// Only the fields of event_match conditions are kept. +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PushCondition({ + required final String kind, + final String? key, + final String? pattern, +}) with _$PushCondition { + /// Matches events in the room with [roomId]. + factory PushCondition.inRoom(String roomId) => + .new(kind: "event_match", key: "room_id", pattern: roomId); + + Map toJson() => _$PushConditionToJson(this); + + factory PushCondition.fromJson(Map json) => + _$PushConditionFromJson(json); +} + +/// What happens when a push rule matches. Actions Nexus doesn't support are +/// dropped, so they're replaced when these are changed. +@Freezed(toJson: false, fromJson: false) +class const PushActions({ + final bool notify = false, + final bool highlight = false, + final String? sound, +}) with _$PushActions { + static const defaultSound = "default"; + static const notifyWithSound = PushActions(notify: true, sound: defaultSound); + static const highlightWithSound = PushActions( + notify: true, + highlight: true, + sound: defaultSound, + ); + + List toJson() => [ + if (notify) "notify", + if (highlight) {"set_tweak": "highlight"}, + if (sound != null) {"set_tweak": "sound", "value": sound}, + ]; + + factory PushActions.fromJson(List json) => json.fold( + const .new(), + (actions, action) => switch (action) { + "notify" => actions.copyWith(notify: true), + {"set_tweak": "highlight", "value": final bool highlight} => + actions.copyWith(highlight: highlight), + {"set_tweak": "highlight"} => actions.copyWith(highlight: true), + {"set_tweak": "sound", "value": final String sound} => actions.copyWith( + sound: sound, + ), + _ => actions, + }, + ); +} diff --git a/lib/models/reaction_group.dart b/lib/models/reaction_group.dart new file mode 100644 index 0000000..b3ea32c --- /dev/null +++ b/lib/models/reaction_group.dart @@ -0,0 +1,10 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "reaction_group.freezed.dart"; + +@Freezed(toJson: false, fromJson: false) +class const ReactionGroup({ + required final IList senders, + final String? shortcode, +}) with _$ReactionGroup; diff --git a/lib/models/read_receipt.dart b/lib/models/read_receipt.dart index d533e2d..4d62635 100644 --- a/lib/models/read_receipt.dart +++ b/lib/models/read_receipt.dart @@ -1,17 +1,24 @@ import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/epoch_date_time_converter.dart"; + part "read_receipt.freezed.dart"; part "read_receipt.g.dart"; -@freezed -abstract class ReadReceipt with _$ReadReceipt { - const factory ReadReceipt({ - String? roomId, - required String userId, - String? threadId, - required String eventId, - @EpochDateTimeConverter() required DateTime timestamp, - }) = _ReadReceipt; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ReadReceipt({ + final String? roomId, + required final String userId, + final String? threadId, + required final String eventId, + @EpochDateTimeConverter() required final DateTime timestamp, +}) with _$ReadReceipt { + String get thread => switch (threadId) { + final threadId? when threadId.isNotEmpty => threadId, + _ => "main", + }; + + Map toJson() => _$ReadReceiptToJson(this); factory ReadReceipt.fromJson(Map json) => _$ReadReceiptFromJson(json); diff --git a/lib/models/recovery_key.dart b/lib/models/recovery_key.dart new file mode 100644 index 0000000..e6d6aa4 --- /dev/null +++ b/lib/models/recovery_key.dart @@ -0,0 +1,19 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "recovery_key.freezed.dart"; +part "recovery_key.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const RecoveryKey({ + required final String recoveryKey, + + required final IMap crossSigningKeys, + final IMap? passphraseMeta, +}) with _$RecoveryKey { + Map toJson() => _$RecoveryKeyToJson(this); + + factory RecoveryKey.fromJson(Map json) => + _$RecoveryKeyFromJson(json); +} diff --git a/lib/models/requests/create_room.dart b/lib/models/requests/create_room.dart new file mode 100644 index 0000000..262ee73 --- /dev/null +++ b/lib/models/requests/create_room.dart @@ -0,0 +1,52 @@ +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/content/encryption.dart"; + +part "create_room.freezed.dart"; +part "create_room.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const CreateRoomRequest({ + final RoomVisibility? visibility, + final String? roomAliasName, + final String? name, + final String? topic, + final IList invite = const .empty(), + @JsonKey(name: "uk.timedout.msc4491.invite_reason") + final String? inviteReason, + final IList initialState = const .empty(), + final RoomPreset? preset, + final bool isDirect = false, + final IMap? creationContent, +}) with _$CreateRoomRequest { + Map toJson() => _$CreateRoomRequestToJson(this); + + factory CreateRoomRequest.fromJson(Map json) => + _$CreateRoomRequestFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const InitialStateEvent({ + required final String type, + final String stateKey = "", + required final Content content, +}) with _$InitialStateEvent { + static final encryption = InitialStateEvent( + type: EventType.encryption.type, + content: EncryptionContent(algorithm: "m.megolm.v1.aes-sha2"), + ); + + Map toJson() => _$InitialStateEventToJson(this); + + factory InitialStateEvent.fromJson(Map json) => + _$InitialStateEventFromJson(json); +} + +@JsonEnum(fieldRename: .snake) +enum RoomVisibility { public, private } + +@JsonEnum(fieldRename: .snake) +enum RoomPreset { privateChat, publicChat, trustedPrivateChat } diff --git a/lib/models/requests/deregister_pusher.dart b/lib/models/requests/deregister_pusher.dart new file mode 100644 index 0000000..1416add --- /dev/null +++ b/lib/models/requests/deregister_pusher.dart @@ -0,0 +1,13 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "deregister_pusher.freezed.dart"; +part "deregister_pusher.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const DeregisterPusherRequest({ + required final String appId, + @JsonKey(name: "pushkey") required final String pushKey, +}) with _$DeregisterPusherRequest { + Map toJson() => _$DeregisterPusherRequestToJson(this); +} diff --git a/lib/models/requests/download_media.dart b/lib/models/requests/download_media.dart new file mode 100644 index 0000000..c9751f8 --- /dev/null +++ b/lib/models/requests/download_media.dart @@ -0,0 +1,18 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "download_media.freezed.dart"; +part "download_media.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const DownloadMediaRequest({ + required final Uri mxc, + final bool encrypted = false, + final bool isAvatar = false, + final bool thumbnailAvatar = false, +}) with _$DownloadMediaRequest { + Map toJson() => _$DownloadMediaRequestToJson(this); + + factory DownloadMediaRequest.fromJson(Map json) => + _$DownloadMediaRequestFromJson(json); +} diff --git a/lib/models/requests/get_event.dart b/lib/models/requests/get_event.dart new file mode 100644 index 0000000..b4692ee --- /dev/null +++ b/lib/models/requests/get_event.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_event.freezed.dart"; +part "get_event.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const GetEventRequest({ + required final String roomId, + required final String eventId, + final bool unredact = false, +}) with _$GetEventRequest { + Map toJson() => _$GetEventRequestToJson(this); + + factory GetEventRequest.fromJson(Map json) => + _$GetEventRequestFromJson(json); +} diff --git a/lib/models/requests/get_event_context.dart b/lib/models/requests/get_event_context.dart new file mode 100644 index 0000000..68f23a0 --- /dev/null +++ b/lib/models/requests/get_event_context.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_event_context.freezed.dart"; +part "get_event_context.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const GetEventContextRequest({ + required final String roomId, + required final String eventId, + final int limit = 20, +}) with _$GetEventContextRequest { + Map toJson() => _$GetEventContextRequestToJson(this); + + factory GetEventContextRequest.fromJson(Map json) => + _$GetEventContextRequestFromJson(json); +} diff --git a/lib/models/requests/get_event_request.dart b/lib/models/requests/get_event_request.dart deleted file mode 100644 index 9374f3a..0000000 --- a/lib/models/requests/get_event_request.dart +++ /dev/null @@ -1,32 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/room.dart"; -part "get_event_request.freezed.dart"; -part "get_event_request.g.dart"; - -@Freezed(toJson: false) -abstract class GetEventRequest with _$GetEventRequest { - const GetEventRequest._(); - const factory GetEventRequest({ - 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_mentions.dart b/lib/models/requests/get_mentions.dart new file mode 100644 index 0000000..1512b6f --- /dev/null +++ b/lib/models/requests/get_mentions.dart @@ -0,0 +1,19 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; +import "package:nexus/models/event.dart"; +part "get_mentions.freezed.dart"; +part "get_mentions.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class GetMentionsRequest({ + @EpochDateTimeConverter() required final DateTime maxTimestamp, + @JsonKey(name: "type") required final UnreadType unreadType, + final int limit = 20, + final String? roomId, +}) with _$GetMentionsRequest { + Map toJson() => _$GetMentionsRequestToJson(this); + + factory GetMentionsRequest.fromJson(Map json) => + _$GetMentionsRequestFromJson(json); +} diff --git a/lib/models/requests/get_related_events.dart b/lib/models/requests/get_related_events.dart new file mode 100644 index 0000000..08be426 --- /dev/null +++ b/lib/models/requests/get_related_events.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_related_events.freezed.dart"; +part "get_related_events.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const GetRelatedEventsRequest({ + required final String roomId, + required final String eventId, + required final String relationType, +}) with _$GetRelatedEventsRequest { + Map toJson() => _$GetRelatedEventsRequestToJson(this); + + factory GetRelatedEventsRequest.fromJson(Map json) => + _$GetRelatedEventsRequestFromJson(json); +} diff --git a/lib/models/requests/get_related_events_request.dart b/lib/models/requests/get_related_events_request.dart deleted file mode 100644 index 7e2244f..0000000 --- a/lib/models/requests/get_related_events_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "get_related_events_request.freezed.dart"; -part "get_related_events_request.g.dart"; - -@freezed -abstract class GetRelatedEventsRequest with _$GetRelatedEventsRequest { - const factory GetRelatedEventsRequest({ - required String roomId, - required String eventId, - required String relationType, - }) = _GetRelatedEventsRequest; - - factory GetRelatedEventsRequest.fromJson(Map json) => - _$GetRelatedEventsRequestFromJson(json); -} diff --git a/lib/models/requests/get_room_directory.dart b/lib/models/requests/get_room_directory.dart new file mode 100644 index 0000000..e46f203 --- /dev/null +++ b/lib/models/requests/get_room_directory.dart @@ -0,0 +1,28 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_room_directory.freezed.dart"; +part "get_room_directory.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class const GetRoomDirectoryRequest({ + final String? server, + final int limit = 48, + final String? since, + final PublicRoomsFilter? filter, +}) with _$GetRoomDirectoryRequest { + Map toJson() => _$GetRoomDirectoryRequestToJson(this); + + factory GetRoomDirectoryRequest.fromJson(Map json) => + _$GetRoomDirectoryRequestFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const PublicRoomsFilter({final String? genericSearchTerm}) + with _$PublicRoomsFilter { + Map toJson() => _$PublicRoomsFilterToJson(this); + + factory PublicRoomsFilter.fromJson(Map json) => + _$PublicRoomsFilterFromJson(json); +} diff --git a/lib/models/requests/get_room_state.dart b/lib/models/requests/get_room_state.dart new file mode 100644 index 0000000..66504da --- /dev/null +++ b/lib/models/requests/get_room_state.dart @@ -0,0 +1,18 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_room_state.freezed.dart"; +part "get_room_state.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const GetRoomStateRequest({ + required final String roomId, + final bool refetch = false, + final bool fetchMembers = false, + final bool includeMembers = false, +}) with _$GetRoomStateRequest { + Map toJson() => _$GetRoomStateRequestToJson(this); + + factory GetRoomStateRequest.fromJson(Map json) => + _$GetRoomStateRequestFromJson(json); +} diff --git a/lib/models/requests/get_room_state_request.dart b/lib/models/requests/get_room_state_request.dart deleted file mode 100644 index a154d5f..0000000 --- a/lib/models/requests/get_room_state_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "get_room_state_request.freezed.dart"; -part "get_room_state_request.g.dart"; - -@freezed -abstract class GetRoomStateRequest with _$GetRoomStateRequest { - const factory GetRoomStateRequest({ - required String roomId, - required bool fetchMembers, - @Default(false) bool includeMembers, - }) = _GetRoomStateRequest; - - factory GetRoomStateRequest.fromJson(Map json) => - _$GetRoomStateRequestFromJson(json); -} diff --git a/lib/models/requests/get_space_hierarchy.dart b/lib/models/requests/get_space_hierarchy.dart new file mode 100644 index 0000000..c0bdc04 --- /dev/null +++ b/lib/models/requests/get_space_hierarchy.dart @@ -0,0 +1,19 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_space_hierarchy.freezed.dart"; +part "get_space_hierarchy.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const GetSpaceHierarchyRequest({ + required final String roomId, + final String? from, + final int limit = 50, + final int? maxDepth, + final bool? suggestedOnly, +}) with _$GetSpaceHierarchyRequest { + Map toJson() => _$GetSpaceHierarchyRequestToJson(this); + + factory GetSpaceHierarchyRequest.fromJson(Map json) => + _$GetSpaceHierarchyRequestFromJson(json); +} diff --git a/lib/models/requests/get_specific_room_state.dart b/lib/models/requests/get_specific_room_state.dart new file mode 100644 index 0000000..1559bc0 --- /dev/null +++ b/lib/models/requests/get_specific_room_state.dart @@ -0,0 +1,29 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_specific_room_state.freezed.dart"; +part "get_specific_room_state.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const GetSpecificRoomStateRequest({ + required final IList keys, +}) with _$GetSpecificRoomStateRequest { + Map toJson() => _$GetSpecificRoomStateRequestToJson(this); + + factory GetSpecificRoomStateRequest.fromJson(Map json) => + _$GetSpecificRoomStateRequestFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RoomStateKey({ + required final String roomId, + required final String type, + required final String stateKey, +}) with _$RoomStateKey { + Map toJson() => _$RoomStateKeyToJson(this); + + factory RoomStateKey.fromJson(Map json) => + _$RoomStateKeyFromJson(json); +} diff --git a/lib/models/requests/get_threads.dart b/lib/models/requests/get_threads.dart new file mode 100644 index 0000000..e88227b --- /dev/null +++ b/lib/models/requests/get_threads.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_threads.freezed.dart"; +part "get_threads.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const GetThreadsRequest({ + required final String roomId, + final String? since, + final int limit = 20, +}) with _$GetThreadsRequest { + Map toJson() => _$GetThreadsRequestToJson(this); + + factory GetThreadsRequest.fromJson(Map json) => + _$GetThreadsRequestFromJson(json); +} diff --git a/lib/models/requests/join_room.dart b/lib/models/requests/join_room.dart new file mode 100644 index 0000000..b8319cd --- /dev/null +++ b/lib/models/requests/join_room.dart @@ -0,0 +1,21 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "join_room.freezed.dart"; +part "join_room.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const JoinRoomRequest({ + required final String roomIdOrAlias, + final IList via = const .empty(), + final String? reason, + + /// Set when accepting an invite, so gomuks can mark DMs as such. + final bool fromInvite = false, +}) with _$JoinRoomRequest { + Map toJson() => _$JoinRoomRequestToJson(this); + + factory JoinRoomRequest.fromJson(Map json) => + _$JoinRoomRequestFromJson(json); +} diff --git a/lib/models/requests/join_room_request.dart b/lib/models/requests/join_room_request.dart deleted file mode 100644 index d6b411e..0000000 --- a/lib/models/requests/join_room_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -part "join_room_request.freezed.dart"; -part "join_room_request.g.dart"; - -@freezed -abstract class JoinRoomRequest with _$JoinRoomRequest { - const factory JoinRoomRequest({ - required String roomIdOrAlias, - required IList via, - }) = _JoinRoomRequest; - - factory JoinRoomRequest.fromJson(Map json) => - _$JoinRoomRequestFromJson(json); -} diff --git a/lib/models/requests/login_request.dart b/lib/models/requests/login_request.dart deleted file mode 100644 index b3704fa..0000000 --- a/lib/models/requests/login_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "login_request.freezed.dart"; -part "login_request.g.dart"; - -@freezed -abstract class LoginRequest with _$LoginRequest { - const factory LoginRequest({ - required String username, - required String password, - required String homeserverUrl, - }) = _LoginRequest; - - factory LoginRequest.fromJson(Map json) => - _$LoginRequestFromJson(json); -} diff --git a/lib/models/requests/oauth/exchange_token.dart b/lib/models/requests/oauth/exchange_token.dart new file mode 100644 index 0000000..fb47358 --- /dev/null +++ b/lib/models/requests/oauth/exchange_token.dart @@ -0,0 +1,19 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "exchange_token.freezed.dart"; +part "exchange_token.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const OAuthExchangeTokenRequest({ + required final Uri homeserverUrl, + required final String codeVerifier, + required final Uri redirectUri, + required final String code, + required final String clientId, +}) with _$OAuthExchangeTokenRequest { + Map toJson() => _$OAuthExchangeTokenRequestToJson(this); + + factory OAuthExchangeTokenRequest.fromJson(Map json) => + _$OAuthExchangeTokenRequestFromJson(json); +} diff --git a/lib/models/requests/oauth/get_auth_url.dart b/lib/models/requests/oauth/get_auth_url.dart new file mode 100644 index 0000000..1ff166a --- /dev/null +++ b/lib/models/requests/oauth/get_auth_url.dart @@ -0,0 +1,45 @@ +import "dart:math"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_auth_url.freezed.dart"; +part "get_auth_url.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const OAuthGetAuthUrl({ + required final ResponseMode responseMode, + required final Uri homeserverUrl, + required final Uri redirectUri, + required final IList scopes, + required final String clientId, + final String? userIdHint, +}) with _$OAuthGetAuthUrl { + Map toJson() => _$OAuthGetAuthUrlToJson(this); + + factory OAuthGetAuthUrl.fromJson(Map json) => + _$OAuthGetAuthUrlFromJson(json); +} + +sealed class Scope { + static final openid = "openid"; + static final email = "email"; + static final clientApi = "urn:matrix:client:api:*"; + + static final _deviceChars = IList( + ("ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789" + "-._~") + .split(""), + ); + + static String get _deviceId => + _deviceChars.shuffle(Random.secure()).sublist(0, 10).join(); + + static String get device => "urn:matrix:client:device:$_deviceId"; +} + +@JsonEnum(fieldRename: .snake) +enum ResponseMode { query, fragment } diff --git a/lib/models/requests/oauth/register_client.dart b/lib/models/requests/oauth/register_client.dart new file mode 100644 index 0000000..e5c1a70 --- /dev/null +++ b/lib/models/requests/oauth/register_client.dart @@ -0,0 +1,51 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "register_client.freezed.dart"; +part "register_client.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const OAuthRegisterClientRequest({ + required final Uri homeserverUrl, + final ApplicationType applicationType = ApplicationType.web, + final String? clientName, + required final Uri clientUri, + final Uri? logoUri, + final Uri? policyUri, + final Uri? tosUri, + final IList? grantTypes, + final IList? redirectUris, + final IList? responseTypes, + + @JsonKey(name: "token_endpoint_auth_method") + final AuthMethod? authMethod = AuthMethod.none, +}) with _$OAuthRegisterClientRequest { + Map toJson() => _$OAuthRegisterClientRequestToJson(this); + + factory OAuthRegisterClientRequest.fromJson(Map json) => + _$OAuthRegisterClientRequestFromJson(json); +} + +enum ApplicationType { native, web } + +@JsonEnum(fieldRename: .snake) +enum ResponseType { code, idToken } + +@JsonEnum(fieldRename: .snake) +enum AuthMethod { + clientSecretPost, + clientSecretBasic, + clientSecretJwt, + privateKeyJwt, + none, +} + +@JsonEnum(fieldRename: .snake) +enum GrantType { + authorizationCode, + refreshToken, + clientCredentials, + @JsonValue("urn:ietf:params:oauth:grant-type:device_code") + deviceCode, +} diff --git a/lib/models/requests/paginate.dart b/lib/models/requests/paginate.dart new file mode 100644 index 0000000..b3903c6 --- /dev/null +++ b/lib/models/requests/paginate.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "paginate.freezed.dart"; +part "paginate.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PaginateRequest({ + required final String roomId, + required final int? maxTimelineId, + final int limit = 20, +}) with _$PaginateRequest { + Map toJson() => _$PaginateRequestToJson(this); + + factory PaginateRequest.fromJson(Map json) => + _$PaginateRequestFromJson(json); +} diff --git a/lib/models/requests/paginate_manual.dart b/lib/models/requests/paginate_manual.dart new file mode 100644 index 0000000..9f8a1d3 --- /dev/null +++ b/lib/models/requests/paginate_manual.dart @@ -0,0 +1,22 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/direction.dart"; + +part "paginate_manual.freezed.dart"; +part "paginate_manual.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PaginateManualRequest({ + required final String roomId, + // Root event ID of a thread to paginate + final String? threadRoot, + // Can be null for starting pagination of a thread + final String? since, + required final Direction direction, + final int limit = 20, +}) with _$PaginateManualRequest { + Map toJson() => _$PaginateManualRequestToJson(this); + + factory PaginateManualRequest.fromJson(Map json) => + _$PaginateManualRequestFromJson(json); +} diff --git a/lib/models/requests/paginate_request.dart b/lib/models/requests/paginate_request.dart deleted file mode 100644 index 44cf8ec..0000000 --- a/lib/models/requests/paginate_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "paginate_request.freezed.dart"; -part "paginate_request.g.dart"; - -@freezed -abstract class PaginateRequest with _$PaginateRequest { - const factory PaginateRequest({ - required String roomId, - required int? maxTimelineId, - @Default(20) int limit, - }) = _PaginateRequest; - - factory PaginateRequest.fromJson(Map json) => - _$PaginateRequestFromJson(json); -} diff --git a/lib/models/requests/redact_event.dart b/lib/models/requests/redact_event.dart new file mode 100644 index 0000000..3a01673 --- /dev/null +++ b/lib/models/requests/redact_event.dart @@ -0,0 +1,3 @@ +import "package:nexus/models/requests/report.dart"; + +typedef RedactEventRequest = ReportRequest; diff --git a/lib/models/requests/redact_event_request.dart b/lib/models/requests/redact_event_request.dart deleted file mode 100644 index fed2255..0000000 --- a/lib/models/requests/redact_event_request.dart +++ /dev/null @@ -1,3 +0,0 @@ -import "package:nexus/models/requests/report_request.dart"; - -typedef RedactEventRequest = ReportRequest; diff --git a/lib/models/requests/register_pusher.dart b/lib/models/requests/register_pusher.dart new file mode 100644 index 0000000..f38ecc0 --- /dev/null +++ b/lib/models/requests/register_pusher.dart @@ -0,0 +1,54 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "register_pusher.freezed.dart"; +part "register_pusher.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RegisterPusherRequest({ + required final String appDisplayName, + required final String appId, + final bool append = false, + required final PusherData data, + required final String deviceDisplayName, + required final PusherKind kind, + required final String lang, + final String? profileTag, + + @JsonKey(name: "pushkey") required final String pushKey, +}) with _$RegisterPusherRequest { + Map toJson() => _$RegisterPusherRequestToJson(this); +} + +@freezed +sealed class PusherData with _$PusherData { + const factory PusherData.http({ + required Uri url, + @Default(PushFormat.eventIdOnly) PushFormat format, + }) = HttpPusherData; + + const factory PusherData.webPush({ + required Uri url, + @Default(PushFormat.eventIdOnly) PushFormat format, + + /// `data.auth`: RFC8291 authentication secret. + required String auth, + }) = WebPushPusherData; + + factory PusherData.fromJson(Map json) => + _$PusherDataFromJson(json); +} + +@JsonEnum(fieldRename: .snake) +enum PushFormat { + @JsonValue(null) + all, + eventIdOnly, +} + +enum PusherKind { + http, + email, + @JsonValue("org.matrix.msc4174.webpush") + webPush, +} diff --git a/lib/models/requests/report.dart b/lib/models/requests/report.dart new file mode 100644 index 0000000..ed05715 --- /dev/null +++ b/lib/models/requests/report.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "report.freezed.dart"; +part "report.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ReportRequest({ + required final String roomId, + required final String eventId, + final String? reason, +}) with _$ReportRequest { + Map toJson() => _$ReportRequestToJson(this); + + factory ReportRequest.fromJson(Map json) => + _$ReportRequestFromJson(json); +} diff --git a/lib/models/requests/report_request.dart b/lib/models/requests/report_request.dart deleted file mode 100644 index 749ad60..0000000 --- a/lib/models/requests/report_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "report_request.freezed.dart"; -part "report_request.g.dart"; - -@freezed -abstract class ReportRequest with _$ReportRequest { - const factory ReportRequest({ - required String roomId, - required String eventId, - String? reason, - }) = _ReportRequest; - - factory ReportRequest.fromJson(Map json) => - _$ReportRequestFromJson(json); -} diff --git a/lib/models/requests/search.dart b/lib/models/requests/search.dart new file mode 100644 index 0000000..8a19d3e --- /dev/null +++ b/lib/models/requests/search.dart @@ -0,0 +1,21 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "search.freezed.dart"; +part "search.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const SearchRequest({ + required final String searchTerm, + final IList roomIds = const .empty(), + final int limit = 20, + final bool sortByTime = true, + + final String? nextBatch, +}) with _$SearchRequest { + Map toJson() => _$SearchRequestToJson(this); + + factory SearchRequest.fromJson(Map json) => + _$SearchRequestFromJson(json); +} diff --git a/lib/models/requests/send_event.dart b/lib/models/requests/send_event.dart new file mode 100644 index 0000000..13d8f86 --- /dev/null +++ b/lib/models/requests/send_event.dart @@ -0,0 +1,45 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/requests/send_message.dart"; + +part "send_event.freezed.dart"; +part "send_event.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SendEventRequest({ + required final String roomId, + required final String type, + required final Content content, + // Merged into the content by toJson, as gomuks doesn't take these + @JsonKey(includeToJson: false) final String? relatesTo, + @JsonKey(includeToJson: false) final String? relationType, + @JsonKey(includeToJson: false) final Relation? reply, + @JsonKey(includeToJson: false) final Mentions? mentions, + final bool synchronous = false, + final bool disableEncryption = false, +}) with _$SendEventRequest { + Map toJson() { + final content = this.content.toJson(); + + return { + ..._$SendEventRequestToJson(this), + "content": { + ...content, + "m.mentions": ?mentions?.toJson(), + if (relatesTo != null || reply != null) + "m.relates_to": { + ...((content["m.relates_to"] as Map?) ?? {}), + if (relatesTo != null) ...{ + "event_id": relatesTo, + "rel_type": relationType, + }, + ...?reply?.toJson(), + }, + }, + }; + } + + factory SendEventRequest.fromJson(Map json) => + _$SendEventRequestFromJson(json); +} diff --git a/lib/models/requests/send_message.dart b/lib/models/requests/send_message.dart new file mode 100644 index 0000000..f6b61ac --- /dev/null +++ b/lib/models/requests/send_message.dart @@ -0,0 +1,63 @@ +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/relation_type.dart"; + +part "send_message.freezed.dart"; +part "send_message.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SendMessageRequest({ + required final String roomId, + required final String text, + final Content? baseContent, + @JsonKey(name: "mentions") final Mentions mentions = const Mentions(), + @JsonKey(name: "relates_to") final Relation? relation, +}) with _$SendMessageRequest { + Map toJson() => _$SendMessageRequestToJson(this); + + factory SendMessageRequest.fromJson(Map json) => + _$SendMessageRequestFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Mentions({ + final bool room = false, + final IList userIds = const .empty(), +}) with _$Mentions { + Map toJson() => _$MentionsToJson(this); + + factory Mentions.fromJson(Map json) => + _$MentionsFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const Relation({ + required final String eventId, + required final RelationType relationType, + final String? threadRoot, + final bool isFallingBack = false, +}) with _$Relation { + Map toJson() { + switch (relationType) { + case RelationType.reply: + return { + if (threadRoot != null) ...{ + "rel_type": "m.thread", + "event_id": threadRoot, + "is_falling_back": isFallingBack, + }, + "m.in_reply_to": {"event_id": eventId}, + }; + + case RelationType.edit: + return {"rel_type": "m.replace", "event_id": eventId}; + } + } + + factory Relation.fromJson(Map json) => + _$RelationFromJson(json); +} diff --git a/lib/models/requests/send_message_request.dart b/lib/models/requests/send_message_request.dart deleted file mode 100644 index 883c585..0000000 --- a/lib/models/requests/send_message_request.dart +++ /dev/null @@ -1,54 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/relation_type.dart"; -part "send_message_request.freezed.dart"; -part "send_message_request.g.dart"; - -@freezed -abstract class SendMessageRequest with _$SendMessageRequest { - const factory SendMessageRequest({ - required String roomId, - required String text, - @Default(Mentions()) @JsonKey(name: "mentions") Mentions mentions, - @JsonKey(name: "relates_to") Relation? relation, - }) = _SendMessageRequest; - - factory SendMessageRequest.fromJson(Map json) => - _$SendMessageRequestFromJson(json); -} - -@freezed -abstract class Mentions with _$Mentions { - const factory Mentions({ - @Default(false) bool room, - @Default(IList.empty()) IList userIds, - }) = _Mentions; - - factory Mentions.fromJson(Map json) => - _$MentionsFromJson(json); -} - -@Freezed(toJson: false) -abstract class Relation with _$Relation { - const Relation._(); - - const factory Relation({ - required String eventId, - required RelationType relationType, - }) = _Relation; - - Map toJson() { - switch (relationType) { - case RelationType.reply: - return { - "m.in_reply_to": {"event_id": eventId}, - }; - - case RelationType.edit: - return {"rel_type": "m.replace", "event_id": eventId}; - } - } - - factory Relation.fromJson(Map json) => - _$RelationFromJson(json); -} diff --git a/lib/models/requests/set_account_data.dart b/lib/models/requests/set_account_data.dart new file mode 100644 index 0000000..95ef75c --- /dev/null +++ b/lib/models/requests/set_account_data.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "set_account_data.freezed.dart"; +part "set_account_data.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SetAccountDataRequest({ + required final String type, + required final dynamic content, + final String? roomId, +}) with _$SetAccountDataRequest { + Map toJson() => _$SetAccountDataRequestToJson(this); + + factory SetAccountDataRequest.fromJson(Map json) => + _$SetAccountDataRequestFromJson(json); +} diff --git a/lib/models/requests/set_membership.dart b/lib/models/requests/set_membership.dart new file mode 100644 index 0000000..9b8a03f --- /dev/null +++ b/lib/models/requests/set_membership.dart @@ -0,0 +1,20 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/membership_action.dart"; + +part "set_membership.freezed.dart"; +part "set_membership.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SetMembershipRequest({ + required final String userId, + required final String roomId, + final String? reason, + required final MembershipAction action, + @JsonKey(name: "msc4293_redact_events") final bool redact = false, +}) with _$SetMembershipRequest { + Map toJson() => _$SetMembershipRequestToJson(this); + + factory SetMembershipRequest.fromJson(Map json) => + _$SetMembershipRequestFromJson(json); +} diff --git a/lib/models/requests/set_state.dart b/lib/models/requests/set_state.dart new file mode 100644 index 0000000..d0e3e95 --- /dev/null +++ b/lib/models/requests/set_state.dart @@ -0,0 +1,24 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/ms_duration.dart"; + +part "set_state.freezed.dart"; +part "set_state.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SetStateRequest({ + required final String roomId, + required final String type, + required final String stateKey, + required final Content content, + + @JsonKey(name: "delay_ms", includeIfNull: false) + @MSDuration() + final Duration? delay, +}) with _$SetStateRequest { + Map toJson() => _$SetStateRequestToJson(this); + + factory SetStateRequest.fromJson(Map json) => + _$SetStateRequestFromJson(json); +} diff --git a/lib/models/requests/update_push_rule.dart b/lib/models/requests/update_push_rule.dart new file mode 100644 index 0000000..3a4fde3 --- /dev/null +++ b/lib/models/requests/update_push_rule.dart @@ -0,0 +1,27 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/push_rules.dart"; + +part "update_push_rule.freezed.dart"; +part "update_push_rule.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const UpdatePushRuleRequest({ + required final PushRuleKind kind, + required final String ruleId, + required final UpdatePushRuleAction action, + + /// The new rule, when putting it. Only its actions and pattern are used. + final PushRule? newContent, + + /// The new actions, when putting only them. + final PushActions? actions, +}) with _$UpdatePushRuleRequest { + Map toJson() => _$UpdatePushRuleRequestToJson(this); + + factory UpdatePushRuleRequest.fromJson(Map json) => + _$UpdatePushRuleRequestFromJson(json); +} + +@JsonEnum(fieldRename: .snake) +enum UpdatePushRuleAction { enable, disable, put, putActions, delete } diff --git a/lib/models/requests/upload_media.dart b/lib/models/requests/upload_media.dart new file mode 100644 index 0000000..d6e69ac --- /dev/null +++ b/lib/models/requests/upload_media.dart @@ -0,0 +1,27 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "upload_media.freezed.dart"; +part "upload_media.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const UploadMediaRequest({ + required final String path, + required final bool encrypt, + final String? filename, + @JsonKey(name: "voice_message") final bool isVoiceMessage = false, + final bool forceFile = false, + final + // Below params only work if encodeTo is set + String? + encodeTo, + final int? resizeWidth, + final int? resizeHeight, + final int? resizePercent, + final int quality = 80, +}) with _$UploadMediaRequest { + Map toJson() => _$UploadMediaRequestToJson(this); + + factory UploadMediaRequest.fromJson(Map json) => + _$UploadMediaRequestFromJson(json); +} diff --git a/lib/models/room.dart b/lib/models/room.dart index 3c3eec0..b809a23 100644 --- a/lib/models/room.dart +++ b/lib/models/room.dart @@ -3,34 +3,73 @@ import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/read_receipt.dart"; import "package:nexus/models/room_metadata.dart"; + part "room.freezed.dart"; part "room.g.dart"; -@freezed -abstract class Room with _$Room { - const factory Room({ - @JsonKey(name: "meta") RoomMetadata? metadata, - @Default(IList.empty()) IList timeline, - @Default(false) bool reset, - @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 IList notifications, - }) = _Room; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Room({ + @JsonKey(name: "meta") final RoomMetadata? metadata, + + /// [timeline] is an IMap of timelineRowId to eventRowId + @JsonKey(fromJson: Room.timelineTupleJsonToIMap) + final IMap timeline = const .empty(), + + /// [clientSticky] is an ISet of eventRowId + @JsonKey(includeFromJson: false, includeToJson: false) + final ISet clientSticky = const .empty(), + + /// [events] is an IMap of eventRowId to event + @JsonKey(fromJson: Room.eventsJsonToIMap) + final IMap events = const .empty(), + + /// [relations] is an IMap of event ID to the eventRowIds relating to it + @JsonKey(includeFromJson: false, includeToJson: false) + final IMap> relations = const .empty(), + + /// [eventIds] is an IMap of event ID to eventRowId + @JsonKey(includeFromJson: false, includeToJson: false) + final IMap eventIds = const .empty(), + + final bool reset = false, + @JsonKey(includeFromJson: false, includeToJson: false) + final bool resetClientSticky = false, + + @JsonKey(includeFromJson: false, includeToJson: false) + final bool hasFetchedState = false, + + @JsonKey(includeFromJson: false, includeToJson: false) + final bool hasFetchedMembers = false, + + final IMap> state = const .empty(), + + final IMap> receipts = const .empty(), + final bool dismissNotifications = false, + final bool hasMore = true, + + // IMap accountData, + // IList notifications, +}) 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); + }), + ); + + Map toJson() => _$RoomToJson(this); 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/room_chat.dart b/lib/models/room_chat.dart new file mode 100644 index 0000000..e784222 --- /dev/null +++ b/lib/models/room_chat.dart @@ -0,0 +1,32 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "room_chat.freezed.dart"; +part "room_chat.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RoomChat({ + /// List of eventRowId + required final IList timeline, + required final bool hasMoreForward, + required final bool hasMoreBackward, + final HistoricalData? historicalData, +}) with _$RoomChat { + Map toJson() => _$RoomChatToJson(this); + + factory RoomChat.fromJson(Map json) => + _$RoomChatFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const HistoricalData({ + required final String start, + required final String end, +}) with _$HistoricalData { + Map toJson() => _$HistoricalDataToJson(this); + + factory HistoricalData.fromJson(Map json) => + _$HistoricalDataFromJson(json); +} diff --git a/lib/models/room_directory.dart b/lib/models/room_directory.dart new file mode 100644 index 0000000..fa58e20 --- /dev/null +++ b/lib/models/room_directory.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/room_summary.dart"; + +part "room_directory.freezed.dart"; +part "room_directory.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const RoomDirectory({ + @JsonKey(name: "chunk") final IList rooms = const .empty(), + final String? nextBatch, + final int? totalRoomCountEstimate, +}) with _$RoomDirectory { + factory RoomDirectory.fromJson(Map json) => + _$RoomDirectoryFromJson(json); +} diff --git a/lib/models/room_metadata.dart b/lib/models/room_metadata.dart index 7c16cae..7621650 100644 --- a/lib/models/room_metadata.dart +++ b/lib/models/room_metadata.dart @@ -1,29 +1,31 @@ import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/encryption.dart"; import "package:nexus/models/epoch_date_time_converter.dart"; import "package:nexus/models/lazy_load_summary.dart"; + part "room_metadata.freezed.dart"; part "room_metadata.g.dart"; -@freezed -abstract class RoomMetadata with _$RoomMetadata { - const factory RoomMetadata({ - @JsonKey(name: "room_id") required String id, +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RoomMetadata({ + @JsonKey(name: "room_id") required final String id, + final String? name, + final Uri? avatar, + final String? dmUserId, + final String? topic, + final String? canonicalAlias, + final LazyLoadSummary? lazyLoadSummary, - // required CreateEventContent creationContent, - // required TombstoneEventContent tombstoneEventContent, - String? name, - Uri? avatar, - String? dmUserId, - String? topic, - String? canonicalAlias, - LazyLoadSummary? lazyLoadSummary, - required bool hasMemberList, - @JsonKey(name: "preview_event_rowid") required int previewEventRowID, - @EpochDateTimeConverter() required DateTime sortingTimestamp, - required int unreadHighlights, - required int unreadNotifications, - required int unreadMessages, - }) = _RoomMetadata; + final EncryptionContent? encryptionEvent, + required final bool hasMemberList, + @JsonKey(name: "preview_event_rowid") required final int previewEventRowID, + @EpochDateTimeConverter() required final DateTime sortingTimestamp, + required final int unreadHighlights, + required final int unreadNotifications, + required final int unreadMessages, +}) with _$RoomMetadata { + Map toJson() => _$RoomMetadataToJson(this); factory RoomMetadata.fromJson(Map json) => _$RoomMetadataFromJson(json); diff --git a/lib/models/room_notification_level.dart b/lib/models/room_notification_level.dart new file mode 100644 index 0000000..36a5029 --- /dev/null +++ b/lib/models/room_notification_level.dart @@ -0,0 +1,49 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/push_rules.dart"; + +enum RoomNotificationLevel( + final String title, + final String description, + final IconData icon, +) { + global( + "Default", + "Follows your global notification rules.", + Icons.notifications, + ), + allMessages( + "All Messages", + "Every message notifies you.", + Icons.notifications_active, + ), + mentionsAndKeywords( + "Mentions & Keywords", + "Only messages that mention you or contain your keywords notify you.", + Icons.alternate_email, + ), + mute("Mute", "Nothing in this room notifies you.", Icons.notifications_off); + + static RoomNotificationLevel of(PushRuleset ruleset, String roomId) => + switch ((ruleset.find(.override, roomId), ruleset.find(.room, roomId))) { + (PushRule(enabled: true, actions: PushActions(notify: false)), _) => + .mute, + (_, PushRule(enabled: true, actions: PushActions(notify: true))) => + .allMessages, + (_, PushRule(enabled: true)) => .mentionsAndKeywords, + _ => .global, + }; + + ({PushRuleKind kind, PushRule rule})? ruleFor(String roomId) => + switch (this) { + .global => null, + .allMessages => ( + kind: .room, + rule: .new(ruleId: roomId, actions: .notifyWithSound), + ), + .mentionsAndKeywords => (kind: .room, rule: .new(ruleId: roomId)), + .mute => ( + kind: .override, + rule: .new(ruleId: roomId, conditions: .new([.inRoom(roomId)])), + ), + }; +} diff --git a/lib/models/room_order.dart b/lib/models/room_order.dart new file mode 100644 index 0000000..c0b5166 --- /dev/null +++ b/lib/models/room_order.dart @@ -0,0 +1 @@ +enum RoomOrder { unreadFirst, recentActivity } diff --git a/lib/models/room_power_levels.dart b/lib/models/room_power_levels.dart new file mode 100644 index 0000000..a40c1a1 --- /dev/null +++ b/lib/models/room_power_levels.dart @@ -0,0 +1,14 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:nexus/models/content/power_levels.dart"; + +class const RoomPowerLevels({ + required final PowerLevelsContent content, + final ISet creators = const .empty(), +}) { + double levelOf(String userId) => creators.contains(userId) + ? double.infinity + : (content.users[userId] ?? content.usersDefault).toDouble(); + + bool canEndPoll(String userId, {required String pollSender}) => + userId == pollSender || levelOf(userId) >= content.redact; +} diff --git a/lib/models/room_summary.dart b/lib/models/room_summary.dart new file mode 100644 index 0000000..2688ca3 --- /dev/null +++ b/lib/models/room_summary.dart @@ -0,0 +1,68 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/join_rule.dart"; +import "package:nexus/models/membership_status.dart"; +import "package:nexus/models/room_metadata.dart"; + +part "room_summary.freezed.dart"; +part "room_summary.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const RoomSummary({ + required final String roomId, + @JsonKey(name: "num_joined_members") final int? joinedMembers, + @JsonKey(unknownEnumValue: JoinRule.unknown) final JoinRule? joinRule, + + /// Rooms whose members may join without an invite, for restricted rooms. + final IList allowedRoomIds = const .empty(), + final String? name, + final Uri? avatarUrl, + final String? canonicalAlias, + final String? topic, + final String? roomVersion, + @JsonKey(unknownEnumValue: RoomType.room) final RoomType? roomType, + + /// The encryption algorithm, if the room is encrypted. + final String? encryption, + final bool? worldReadable, + + /// The current user's membership in the room, if any. + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) + final MembershipStatus? membership, + + /// Who invited the current user, for invites. + @JsonKey(includeFromJson: false) final String? invitedBy, + + /// The reason given for the invite, if any. + @JsonKey(includeFromJson: false) final String? inviteReason, + + /// The inviter's membership, for their display name and avatar. + @JsonKey(includeFromJson: false) final MembershipContent? inviterProfile, + + /// Whether this is an invite to a direct message. + @JsonKey(includeFromJson: false) final bool isDirect = false, +}) with _$RoomSummary { + /// Whether the user has to knock rather than join, given the rooms they've + /// joined. Restricted knock rooms can be joined directly from an allowed room. + bool mustKnock(ISet joinedRooms) => switch (joinRule) { + .knock => true, + .knockRestricted => !allowedRoomIds.any(joinedRooms.contains), + _ => false, + }; + + /// A summary of a room the user is in, from its synced metadata. + factory RoomSummary.fromMetadata(RoomMetadata metadata) => .new( + roomId: metadata.id, + joinedMembers: metadata.lazyLoadSummary?.joinedMemberCount, + avatarUrl: metadata.avatar, + canonicalAlias: metadata.canonicalAlias, + name: metadata.name, + topic: metadata.topic, + ); + + factory RoomSummary.fromJson(Map json) => + _$RoomSummaryFromJson(json); +} diff --git a/lib/models/setting.dart b/lib/models/setting.dart new file mode 100644 index 0000000..2ff541d --- /dev/null +++ b/lib/models/setting.dart @@ -0,0 +1,13 @@ +import "package:material_ui/material_ui.dart"; + +class Setting({ + required final String title, + required final String description, + required final IconData icon, + required final Widget Function( + String title, + String description, + IconData icon, + ) + builder, +}); diff --git a/lib/models/settings.dart b/lib/models/settings.dart new file mode 100644 index 0000000..79c3d64 --- /dev/null +++ b/lib/models/settings.dart @@ -0,0 +1,38 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/name_color.dart"; +import "package:nexus/models/room_order.dart"; +import "package:nexus/models/show_and_send.dart"; + +part "settings.freezed.dart"; +part "settings.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Settings({ + final ThemeMode theme = ThemeMode.system, + final bool useDynamicTheming = true, + final bool linuxMobileMode = false, + final bool showAllInHome = false, + final bool showMinimizeButton = false, + final bool swipeToReply = false, + final bool hideMembershipEvents = false, + final bool hideServerAclEvents = true, + final RoomOrder roomOrder = .unreadFirst, + final NameColor nameColor = .powerLevel, + final ShowAndSend typingIndicators = .showAndSend, + + /// When not sending, read receipts are still sent privately, so that the + /// user's other devices know what has been read. + final ShowAndSend readReceipts = .showAndSend, + final IList directoryServers = const IListConst([ + "matrix.org", + "matrixrooms.info", + ]), +}) with _$Settings { + Map toJson() => _$SettingsToJson(this); + + factory Settings.fromJson(Map json) => + _$SettingsFromJson(json); +} diff --git a/lib/models/settings_category.dart b/lib/models/settings_category.dart new file mode 100644 index 0000000..2dc7076 --- /dev/null +++ b/lib/models/settings_category.dart @@ -0,0 +1,13 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/setting.dart"; + +part "settings_category.freezed.dart"; + +@freezed +class const SettingsCategory({ + required final String title, + required final IconData icon, + required final IList settings, +}) with _$SettingsCategory; diff --git a/lib/models/show_and_send.dart b/lib/models/show_and_send.dart new file mode 100644 index 0000000..f9663cc --- /dev/null +++ b/lib/models/show_and_send.dart @@ -0,0 +1,2 @@ +/// Whether to show something from others, and send it to them, e.g. typing indicators or read receipts. +enum ShowAndSend { showAndSend, show, neither } diff --git a/lib/models/sidebar_page.dart b/lib/models/sidebar_page.dart new file mode 100644 index 0000000..18ee417 --- /dev/null +++ b/lib/models/sidebar_page.dart @@ -0,0 +1 @@ +enum SidebarPage { rooms, notifications, invites } diff --git a/lib/models/space.dart b/lib/models/space.dart index 631759a..97efe7a 100644 --- a/lib/models/space.dart +++ b/lib/models/space.dart @@ -2,15 +2,16 @@ 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 -abstract class Space with _$Space { - const factory Space({ - required String id, - required String title, - IconData? icon, - Room? room, - required IList children, - }) = _Space; -} +class const Space({ + required final String id, + required final String title, + final IconData? icon, + final Room? room, + required final IList children, + required final IList subSpaces, +}) with _$Space; diff --git a/lib/models/space_edge.dart b/lib/models/space_edge.dart index 192af31..ea74130 100644 --- a/lib/models/space_edge.dart +++ b/lib/models/space_edge.dart @@ -1,13 +1,15 @@ import "package:freezed_annotation/freezed_annotation.dart"; + part "space_edge.freezed.dart"; part "space_edge.g.dart"; -@freezed -abstract class SpaceEdge with _$SpaceEdge { - const factory SpaceEdge({ - required String childId, - @Default(false) bool suggested, - }) = _SpaceEdge; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SpaceEdge({ + required final String childId, + final bool suggested = false, +}) with _$SpaceEdge { + Map toJson() => _$SpaceEdgeToJson(this); factory SpaceEdge.fromJson(Map json) => _$SpaceEdgeFromJson(json); diff --git a/lib/models/space_hierarchy.dart b/lib/models/space_hierarchy.dart new file mode 100644 index 0000000..76bb380 --- /dev/null +++ b/lib/models/space_hierarchy.dart @@ -0,0 +1,45 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/room_summary.dart"; + +part "space_hierarchy.freezed.dart"; +part "space_hierarchy.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const SpaceHierarchy({ + final IList rooms = const .empty(), + final String? nextBatch, +}) with _$SpaceHierarchy { + factory SpaceHierarchy.fromJson(Map json) => + _$SpaceHierarchyFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const SpaceHierarchyRoom({ + @JsonKey(readValue: SpaceHierarchyRoom.readEntry) + required final RoomSummary summary, + @JsonKey(name: "children_state") + final IList children = const .empty(), +}) with _$SpaceHierarchyRoom { + static Object? readEntry(Map json, String _) => json; + + factory SpaceHierarchyRoom.fromJson(Map json) => + _$SpaceHierarchyRoomFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const SpaceHierarchyChild({ + @JsonKey(name: "state_key") required final String roomId, + @JsonKey(readValue: SpaceHierarchyChild.readContent) + final IList via = const .empty(), + @JsonKey(readValue: SpaceHierarchyChild.readContent) + final bool suggested = false, +}) with _$SpaceHierarchyChild { + static Object? readContent(Map json, String key) => json["content"]?[key]; + + factory SpaceHierarchyChild.fromJson(Map json) => + _$SpaceHierarchyChildFromJson(json); +} diff --git a/lib/models/spec_versions_response.dart b/lib/models/spec_versions_response.dart new file mode 100644 index 0000000..1f414d2 --- /dev/null +++ b/lib/models/spec_versions_response.dart @@ -0,0 +1,28 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "spec_versions_response.freezed.dart"; +part "spec_versions_response.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SpecVersionsResponse({ + required final IList versions, + required final UnstableFeatures unstableFeatures, +}) with _$SpecVersionsResponse { + Map toJson() => _$SpecVersionsResponseToJson(this); + + factory SpecVersionsResponse.fromJson(Map json) => + _$SpecVersionsResponseFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const UnstableFeatures({ + @JsonKey(name: "uk.timedout.msc4494") final bool msc4494 = false, +}) with _$UnstableFeatures { + Map toJson() => _$UnstableFeaturesToJson(this); + + factory UnstableFeatures.fromJson(Map json) => + _$UnstableFeaturesFromJson(json); +} diff --git a/lib/models/subspace.dart b/lib/models/subspace.dart new file mode 100644 index 0000000..97d1de2 --- /dev/null +++ b/lib/models/subspace.dart @@ -0,0 +1,11 @@ +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 +class const Subspace({ + required final Room room, + required final IList children, +}) with _$Subspace; diff --git a/lib/models/sync_data.dart b/lib/models/sync_data.dart index 0f98bb2..4639956 100644 --- a/lib/models/sync_data.dart +++ b/lib/models/sync_data.dart @@ -1,23 +1,23 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/account_data.dart"; +import "package:nexus/models/invited_room.dart"; import "package:nexus/models/room.dart"; import "package:nexus/models/space_edge.dart"; + part "sync_data.freezed.dart"; part "sync_data.g.dart"; -@freezed -abstract class SyncData with _$SyncData { - const factory SyncData({ - @Default(false) bool clearState, - @Default(IMap.empty()) IMap accountData, - @Default(IMap.empty()) IMap rooms, - @Default(ISet.empty()) ISet leftRooms, - // required IList invitedRooms, - IMap>? spaceEdges, - IList? topLevelSpaces, - }) = _SyncData; - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const SyncData({ + final bool clearState = false, + final IMap> accountData = const .empty(), + final IMap rooms = const .empty(), + final ISet leftRooms = const .empty(), + final IList invitedRooms = const .empty(), + final IMap>? spaceEdges, + final IList? topLevelSpaces, +}) with _$SyncData { factory SyncData.fromJson(Map json) => _$SyncDataFromJson(json); } diff --git a/lib/models/sync_status.dart b/lib/models/sync_status.dart index 42c5f2a..237cf3e 100644 --- a/lib/models/sync_status.dart +++ b/lib/models/sync_status.dart @@ -1,18 +1,22 @@ import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; + part "sync_status.freezed.dart"; part "sync_status.g.dart"; -@freezed -abstract class SyncStatus with _$SyncStatus { - const factory SyncStatus({ - required SyncStatusType type, - String? error, - required int errorCount, - }) = _SyncStatus; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const SyncStatus({ + required final SyncStatusType type, + required final int errorCount, + final String? error, + @EpochDateTimeConverter() final DateTime? lastSync, +}) with _$SyncStatus { + Map toJson() => _$SyncStatusToJson(this); factory SyncStatus.fromJson(Map json) => _$SyncStatusFromJson(json); } -@JsonEnum(fieldRename: FieldRename.snake) +@JsonEnum(fieldRename: FieldRename.kebab) enum SyncStatusType { ok, waiting, erroring, permanentlyFailed } diff --git a/lib/models/text_command.dart b/lib/models/text_command.dart new file mode 100644 index 0000000..7ee2f77 --- /dev/null +++ b/lib/models/text_command.dart @@ -0,0 +1,48 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +enum TextCommandGroup { messageType, format } + +// Prefixes gomuks parses out of sent text, in the order it expects them +enum TextCommand(final String description, {final TextCommandGroup? group}) { + me("Send as an emote", group: .messageType), + notice("Send as a notice", group: .messageType), + unencrypted("Send unencrypted, even if the room is encrypted"), + rawinputbody("Use the text as the body as-is, rather than from the HTML"), + plain("Send without any formatting", group: .format), + html("Send with only HTML, without markdown", group: .format), + htmlmd("Send with both HTML and markdown", group: .format), + rainbow("Send in rainbow colors, with markdown", group: .format); + + String get prefix => "/$name"; + + bool completes(String text) => + text.startsWith("/") && prefix.startsWith(text.toLowerCase()); + + // Replaces any command from the same group, as gomuks only uses one + IList addTo(IList commands) => .new( + commands + .where( + (other) => other != this && (group == null || other.group != group), + ) + .toIList() + .add(this) + .sortedBy((command) => command.index), + ); + + static String prefixOf(IList commands) => + commands.map((command) => "${command.prefix} ").join(); + + static ({IList commands, String text}) parse( + String text, [ + IList commands = const .empty(), + ]) => switch (values.firstWhereOrNull( + (command) => text.toLowerCase().startsWith("${command.prefix} "), + )) { + final command? => parse( + text.substring(command.prefix.length + 1), + command.addTo(commands), + ), + null => (commands: commands, text: text), + }; +} diff --git a/lib/models/typing.dart b/lib/models/typing.dart new file mode 100644 index 0000000..bd0198c --- /dev/null +++ b/lib/models/typing.dart @@ -0,0 +1,14 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "typing.freezed.dart"; +part "typing.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const Typing({ + required final String roomId, + final IList userIds = const .empty(), +}) with _$Typing { + factory Typing.fromJson(Map json) => _$TypingFromJson(json); +} diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart deleted file mode 100644 index ee2f4d0..0000000 --- a/lib/pages/chat_page.dart +++ /dev/null @@ -1,33 +0,0 @@ -import "package:flutter/material.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}); - - @override - Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder( - builder: (context, constraints) { - final isDesktop = constraints.maxWidth > 650; - final showMembersByDefault = constraints.maxWidth > 1000; - - return Scaffold( - body: Builder( - builder: (context) => Row( - children: [ - if (isDesktop) Sidebar(), - Expanded( - child: RoomChat( - isDesktop: isDesktop, - showMembersByDefault: showMembersByDefault, - ), - ), - ], - ), - ), - drawer: isDesktop ? null : Sidebar(), - ); - }, - ); -} diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart deleted file mode 100644 index bd41d51..0000000 --- a/lib/pages/login_page.dart +++ /dev/null @@ -1,210 +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/models/requests/login_request.dart"; -import "package:nexus/widgets/appbar.dart"; -import "package:nexus/widgets/divider_text.dart"; -import "package:nexus/widgets/loading.dart"; - -class LoginPage extends HookConsumerWidget { - 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(); - - return Scaffold( - appBar: Appbar(), - body: Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: 600), - child: ListView( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 64), - children: [ - Row( - children: [ - SvgPicture.asset("assets/icon.svg"), - SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Nexus", style: theme.textTheme.displayMedium), - Text( - "A Simple Matrix Client", - style: theme.textTheme.headlineMedium, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - 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", - ), - 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"), - ), - ], - ], - ), - ), - ), - ); - } -} diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart deleted file mode 100644 index 505904c..0000000 --- a/lib/pages/settings_page.dart +++ /dev/null @@ -1,11 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -class SettingsPage extends ConsumerWidget { - const SettingsPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Placeholder(); - } -} diff --git a/lib/pages/verify_page.dart b/lib/pages/verify_page.dart deleted file mode 100644 index 1011f80..0000000 --- a/lib/pages/verify_page.dart +++ /dev/null @@ -1,82 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/widgets/form_text_input.dart"; - -class VerifyPage extends HookConsumerWidget { - const VerifyPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final passphraseController = useTextEditingController(); - 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.", - ), - 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/account_scope.dart b/lib/widgets/account_scope.dart new file mode 100644 index 0000000..3f89b4b --- /dev/null +++ b/lib/widgets/account_scope.dart @@ -0,0 +1,41 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/active_account.dart"; +import "package:nexus/controllers/notification_request.dart"; + +/// Runs [child] as one account, in a [ProviderScope] of its own that's +/// replaced when switching accounts, so no state leaks between them. +class const AccountScope({ + required final String initialAccountId, + required final Widget child, + final List? observers, + final Duration? Function(int retryCount, Object error)? retry, + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final active = useState(( + accountId: initialAccountId, + notificationRowId: null, + )); + + return ProviderScope( + key: ValueKey(active.value.accountId), + retry: retry, + observers: observers, + overrides: [ + ActiveAccountController.provider.overrideWith( + () => ActiveAccountController( + active.value.accountId, + (next) => active.value = next, + ), + ), + NotificationRequestController.provider.overrideWithBuild( + (_, _) => active.value.notificationRowId, + ), + ], + child: child, + ); + } +} diff --git a/lib/widgets/appbar.dart b/lib/widgets/appbar.dart deleted file mode 100644 index 5b14244..0000000 --- a/lib/widgets/appbar.dart +++ /dev/null @@ -1,66 +0,0 @@ -import "dart:io"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:window_manager/window_manager.dart"; - -class Appbar extends StatelessWidget implements PreferredSizeWidget { - final Widget? leading; - final Widget? title; - final Color? backgroundColor; - final double? scrolledUnderElevation; - final IList actions; - - const Appbar({ - super.key, - this.title, - this.backgroundColor, - this.scrolledUnderElevation, - this.leading, - this.actions = const IList.empty(), - }); - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); - - @override - Widget build(BuildContext context) { - Future maximize() async { - final isMaximized = await windowManager.isMaximized(); - - if (isMaximized) { - return windowManager.unmaximize(); - } - - return windowManager.maximize(); - } - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onDoubleTap: maximize, - onPanStart: (_) => windowManager.startDragging(), - child: AppBar( - leading: leading, - backgroundColor: backgroundColor, - scrolledUnderElevation: scrolledUnderElevation, - actionsPadding: const EdgeInsets.symmetric(horizontal: 8), - title: title, - actions: [ - ...actions, - if (!(Platform.isAndroid || Platform.isIOS)) ...[ - if (!Platform.isLinux) - IconButton( - tooltip: "Maximize window", - onPressed: maximize, - icon: const Icon(Icons.fullscreen), - ), - IconButton( - tooltip: "Close window", - onPressed: () => exit(0), - icon: const Icon(Icons.close), - ), - ], - ], - ), - ); - } -} diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatar_or_hash.dart deleted file mode 100644 index 8e93b6b..0000000 --- a/lib/widgets/avatar_or_hash.dart +++ /dev/null @@ -1,73 +0,0 @@ -import "package:color_hash/color_hash.dart"; -import "package:cross_cache/cross_cache.dart"; -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; - -class AvatarOrHash extends ConsumerWidget { - final Uri? avatar; - final String title; - final Widget? fallback; - final bool hasBadge; - final int badgeNumber; - final double height; - const AvatarOrHash( - this.avatar, - this.title, { - this.fallback, - this.badgeNumber = 0, - this.hasBadge = false, - this.height = 24, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final box = ColoredBox( - color: ColorHash(title).color, - child: Center(child: Text(title.isEmpty ? "" : title[0])), - ); - return SizedBox( - width: height, - height: height, - child: Center( - child: Badge( - isLabelVisible: hasBadge, - label: badgeNumber != 0 ? Text(badgeNumber.toString()) : null, - smallSize: 12, - backgroundColor: Theme.of(context).colorScheme.primary, - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular((height - 8) / 2.5)), - child: SizedBox( - width: height, - height: height, - child: avatar == null - ? fallback ?? box - : Image( - image: CachedNetworkImage( - avatar! - .mxcToHttps( - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - "", - ) - .toString(), - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - fit: BoxFit.contain, - errorBuilder: (_, _, _) => box, - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/widgets/avatars/avatar_or_hash.dart b/lib/widgets/avatars/avatar_or_hash.dart new file mode 100644 index 0000000..bdaf657 --- /dev/null +++ b/lib/widgets/avatars/avatar_or_hash.dart @@ -0,0 +1,42 @@ +import "package:color_hash/color_hash.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/helpers/mxc_image.dart"; + +final class const AvatarOrHash( + final Uri? avatar, + final String title, { + final Widget? fallback, + final double dimension = 24, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final box = ColoredBox( + color: ColorHash(title).color, + child: Center(child: Icon(Icons.person, size: dimension / 2)), + ); + + return SizedBox( + width: dimension, + height: dimension, + child: Center( + child: ClipRRect( + borderRadius: .all(.circular((dimension - 8) / 2.5)), + child: SizedBox.square( + dimension: dimension, + child: avatar == null + ? fallback ?? box + : Image( + image: MxcImage(ref, .new(mxc: avatar!, isAvatar: true)), + fit: .cover, + loadingBuilder: (_, child, loadingProgress) => + loadingProgress == null ? child : fallback ?? box, + errorBuilder: (_, _, _) => fallback ?? box, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/avatars/avatar_ring.dart b/lib/widgets/avatars/avatar_ring.dart new file mode 100644 index 0000000..f410978 --- /dev/null +++ b/lib/widgets/avatars/avatar_ring.dart @@ -0,0 +1,39 @@ +import "package:material_ui/material_ui.dart"; + +class const AvatarRing({ + required final String tooltip, + required final VoidCallback onTap, + required final Widget child, + final double dimension = 24, + super.key, +}) extends StatelessWidget { + static const ringWidth = 1.5; + + @override + Widget build(BuildContext context) => Tooltip( + message: tooltip, + child: Container( + padding: .all(ringWidth), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + shape: .circle, + ), + child: ClipOval( + child: SizedBox.square( + dimension: dimension, + child: Stack( + fit: .expand, + children: [ + child, + // Over the child, so the ripple shows on top of avatars + Material( + type: .transparency, + child: InkWell(onTap: onTap), + ), + ], + ), + ), + ), + ), + ); +} diff --git a/lib/widgets/avatars/editable_avatar.dart b/lib/widgets/avatars/editable_avatar.dart new file mode 100644 index 0000000..5640342 --- /dev/null +++ b/lib/widgets/avatars/editable_avatar.dart @@ -0,0 +1,86 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/file_dialog_helper.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/message.dart"; + +/// Shows [child], an avatar, with a button to upload a new one or remove it. +class const EditableAvatar({ + required final Widget child, + + /// Whether there's an avatar to remove. + required final bool hasAvatar, + + /// Called with the uploaded image, or null when the avatar is removed. + required final Future Function(Uri? url) onChanged, + final double dimension = 200, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final saving = Saving.use(); + Future save(Future Function() action) => + saving.run(() => action().onError(showError)); + + Future pick() async { + final file = await ref + .read(FileDialogHelper.provider) + .pickFile(images: true); + if (file == null || !context.mounted) return; + + await save(() async { + final content = await ref + .read(ClientController.provider.notifier) + .uploadMedia(.new(path: file.path, encrypt: false)); + if (content case ImageMessageContent(:final url)) { + await onChanged(url); + } else { + throw Exception("The file isn't an image"); + } + }); + } + + return SizedBox.square( + dimension: dimension, + child: Stack( + children: [ + child, + if (saving.active) const Center(child: CircularProgressIndicator()), + Positioned( + right: dimension * 0.01, + bottom: dimension * 0.01, + child: Builder( + builder: (context) => IconButton.filled( + tooltip: "Edit avatar", + icon: Icon(Icons.edit), + onPressed: saving.active + ? null + : !hasAvatar + ? pick + : () => context.showAdaptiveMenu([ + PopupMenuItem( + onTap: pick, + child: ListTile( + leading: Icon(Icons.upload), + title: Text("Upload new avatar"), + ), + ), + PopupMenuItem( + onTap: () => save(() => onChanged(null)), + child: ListTile( + leading: Icon(Icons.delete), + title: Text("Remove avatar"), + ), + ), + ]), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/avatars/image_pack_avatar.dart b/lib/widgets/avatars/image_pack_avatar.dart new file mode 100644 index 0000000..42504ae --- /dev/null +++ b/lib/widgets/avatars/image_pack_avatar.dart @@ -0,0 +1,14 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/emoji/mxc_emoji.dart"; + +class const ImagePackAvatar(final Uri? icon, {super.key}) + extends StatelessWidget { + @override + Widget build(BuildContext context) => SizedBox.square( + dimension: 40, + child: switch (icon) { + final icon? => MxcEmoji(icon), + null => Icon(Icons.emoji_emotions), + }, + ); +} diff --git a/lib/widgets/chat_page/chat_box.dart b/lib/widgets/chat_page/chat_box.dart deleted file mode 100644 index b9e7dbb..0000000 --- a/lib/widgets/chat_page/chat_box.dart +++ /dev/null @@ -1,178 +0,0 @@ -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/mention_overlay.dart"; -import "package:nexus/widgets/chat_page/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( - shouldMention: shouldMention.value, - toggleShouldMention: () => - shouldMention.value = !shouldMention.value, - relatedMessage: relatedMessage, - 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/html/html.dart b/lib/widgets/chat_page/html/html.dart deleted file mode 100644 index 1e1ab82..0000000 --- a/lib/widgets/chat_page/html/html.dart +++ /dev/null @@ -1,139 +0,0 @@ -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/helpers/extensions/get_headers.dart"; -import "package:nexus/helpers/extensions/link_to_mention.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/helpers/launch_helper.dart"; -import "package:nexus/widgets/chat_page/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 TextStyle? textStyle; - const Html(this.html, {this.textStyle, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( - html, - textStyle: textStyle, - customWidgetBuilder: (element) { - if (element.attributes.keys.contains("data-mx-spoiler")) { - return InlineCustomWidget(child: SpoilerText(text: element.text)); - } - - final height = int.tryParse(element.attributes["height"] ?? "") ?? 300; - final width = int.tryParse(element.attributes["width"] ?? ""); - - return switch (element.localName) { - "code" => - element.parent?.localName == "pre" - ? element.outerHtml.contains("
") - ? Html( - """
${element.outerHtml.replaceAll("
", "\n")}
""", - ) - : CodeBlock( - element.text, - lang: element.className.replaceAll("language-", ""), - ) - : null, - - "blockquote" => Quoted(Html(element.innerHtml)), - - "a" => - element.attributes["href"]?.mention == null - ? null - : InlineCustomWidget(child: MentionChip(element.text)), - - "img" => - element.attributes["src"] == null - ? SizedBox.shrink() - : InlineCustomWidget( - alignment: PlaceholderAlignment.middle, - child: Image.network( - Uri.parse(element.attributes["src"]!) - .mxcToHttps( - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - "", - ) - .toString(), - headers: ref.headers, - errorBuilder: (_, error, _) => Text( - "Image Failed to Load", - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - height: height.toDouble(), - width: width?.toDouble(), - loadingBuilder: (_, child, loadingProgress) => - loadingProgress == null - ? child - : CircularProgressIndicator(), - ), - ), - ("del" || - "h1" || - "h2" || - "h3" || - "h4" || - "h5" || - "h6" || - "p" || - "ul" || - "ol" || - "sup" || - "sub" || - "li" || - "b" || - "i" || - "u" || - "strong" || - "em" || - "s" || - "code" || - "hr" || - "br" || - "div" || - "table" || - "thead" || - "tbody" || - "tr" || - "th" || - "td" || - "caption" || - "pre" || - "span" || - "details" || - "summary") => - null, - - _ => SizedBox.shrink(), - }; - }, - customStylesBuilder: (element) => { - "width": "auto", - ...Map.fromEntries( - element.attributes - .mapTo?>( - (key, value) => switch (key) { - "data-mx-color" => MapEntry("color", value), - "data-mx-bg-color" => MapEntry("background-color", value), - _ => null, - }, - ) - .nonNulls, - ), - }, - 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 deleted file mode 100644 index c2b832d..0000000 --- a/lib/widgets/chat_page/html/mention_chip.dart +++ /dev/null @@ -1,25 +0,0 @@ -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/chat_page/html/quoted.dart b/lib/widgets/chat_page/html/quoted.dart deleted file mode 100644 index 6640118..0000000 --- a/lib/widgets/chat_page/html/quoted.dart +++ /dev/null @@ -1,16 +0,0 @@ -import "package:flutter/material.dart"; - -class Quoted extends StatelessWidget { - final Widget child; - const Quoted(this.child, {super.key}); - - @override - Widget build(BuildContext context) => Container( - decoration: BoxDecoration( - border: Border( - left: BorderSide(width: 4, color: Theme.of(context).dividerColor), - ), - ), - child: Padding(padding: EdgeInsets.only(left: 8), child: child), - ); -} diff --git a/lib/widgets/chat_page/image_message.dart b/lib/widgets/chat_page/image_message.dart deleted file mode 100644 index 103fdd2..0000000 --- a/lib/widgets/chat_page/image_message.dart +++ /dev/null @@ -1,58 +0,0 @@ -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/member_list.dart b/lib/widgets/chat_page/member_list.dart deleted file mode 100644 index 24d22e4..0000000 --- a/lib/widgets/chat_page/member_list.dart +++ /dev/null @@ -1,57 +0,0 @@ -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/members_controller.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 members = ref.watch(MembersController.provider(room)); - return Drawer( - shape: Border(), - child: ListView( - children: [ - AppBar( - scrolledUnderElevation: 0, - leading: Icon(Icons.people), - title: Text("Members (${members.length})"), - 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", - ), - ], - ), - ...members.map( - (member) => ListTile( - onTap: () => showDialog( - context: context, - builder: (context) => - Dialog(child: Text("TODO: Open member popover")), - ), - leading: AvatarOrHash( - Uri.tryParse(member.content["avatar_url"] ?? ""), - member.content["displayname"].toString(), - ), - title: Text( - member.content["displayname"].toString(), - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - member.stateKey ?? "Unknown User", - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/widgets/chat_page/mention_overlay.dart b/lib/widgets/chat_page/mention_overlay.dart deleted file mode 100644 index 9858574..0000000 --- a/lib/widgets/chat_page/mention_overlay.dart +++ /dev/null @@ -1,124 +0,0 @@ -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/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) { - "@" => Consumer( - builder: (_, ref, _) { - final members = ref.watch(MembersController.provider(room)); - return ListView( - children: - (query.isEmpty - ? members - : members.where( - (member) => - member.stateKey?.toLowerCase().contains( - query.toLowerCase(), - ) == - true || - (member.content["displayname"] as String?) - ?.toLowerCase() - .contains(query.toLowerCase()) == - true, - )) - .map( - (member) => ListTile( - leading: AvatarOrHash( - Uri.tryParse( - member.content["avatar_url"] ?? "", - ), - member.content["displayname"] ?? "", - ), - title: Text( - member.content["displayname"] as String? ?? - member.stateKey ?? - "Unknown User", - ), - subtitle: member.stateKey != null - ? Text(member.stateKey!) - : null, - onTap: () => addTag( - id: "[@${member.content["displayname"]}](https://matrix.to/#/${member.stateKey})", - name: - member.stateKey - ?.substring(1) - .split(":") - .first ?? - "Unknown User", - ), - ), - ) - .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/message_wrapper.dart b/lib/widgets/chat_page/message_wrapper.dart deleted file mode 100644 index da53be0..0000000 --- a/lib/widgets/chat_page/message_wrapper.dart +++ /dev/null @@ -1,54 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; - -class MessageWrapper extends StatelessWidget { - final Message message; - final Widget child; - final MessageGroupStatus? groupStatus; - const MessageWrapper(this.message, this.child, this.groupStatus, {super.key}); - - @override - Widget build(BuildContext context) => 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 - ? AvatarOrHash( - Uri.parse(message.metadata?["avatarUrl"] ?? ""), - height: 40, - message.metadata?["displayName"] ?? "", - ) - : SizedBox(width: 40), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, - children: [ - if (groupStatus?.isFirst != false) - Text( - message.metadata?["displayName"] ?? message.authorId, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - child, - ], - ), - ), - ], - ), - ), - ); -} diff --git a/lib/widgets/chat_page/relation_preview.dart b/lib/widgets/chat_page/relation_preview.dart deleted file mode 100644 index 7aa3ae8..0000000 --- a/lib/widgets/chat_page/relation_preview.dart +++ /dev/null @@ -1,83 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; - -class RelationPreview extends ConsumerWidget { - final Message? relatedMessage; - final RelationType relationType; - final VoidCallback onDismiss; - final bool shouldMention; - final VoidCallback toggleShouldMention; - const RelationPreview({ - required this.relatedMessage, - required this.relationType, - required this.onDismiss, - required this.shouldMention, - required this.toggleShouldMention, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - if (relatedMessage == null) return SizedBox.shrink(); - final theme = Theme.of(context); - - return Container( - color: theme.colorScheme.surfaceContainerHigh, - padding: EdgeInsets.symmetric(horizontal: 8), - child: Row( - spacing: 8, - children: [ - SizedBox(width: 4), - if (relationType == RelationType.edit) - Text( - "Editing message:", - style: TextStyle(fontWeight: FontWeight.bold), - ), - AvatarOrHash( - Uri.tryParse(relatedMessage?.metadata?["avatarUrl"] ?? ""), - relatedMessage?.metadata?["displayName"]?.toString() ?? "", - height: 16, - ), - Text( - relatedMessage!.metadata?["displayName"] ?? - relatedMessage!.authorId, - 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/chat_page/reply_widget.dart b/lib/widgets/chat_page/reply_widget.dart deleted file mode 100644 index cd30acc..0000000 --- a/lib/widgets/chat_page/reply_widget.dart +++ /dev/null @@ -1,146 +0,0 @@ -import "dart:math"; -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/message_config.dart"; -import "package:nexus/models/requests/get_event_request.dart"; -import "package:nexus/models/room.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/chat_page/html/quoted.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(); - } - - final smallerText = - message is TextMessage && - replyMessage.metadata?["body"] != null - ? replyMessage.metadata!["body"].substring( - 0, - min( - max( - max( - (message as TextMessage) - .text - .length - - (replyMessage - .metadata?["displayName"] - as String) - .length - - 5, - message - .metadata?["displayName"] - .length, - ), - 5, - ), - replyMessage.metadata!["body"].length, - ), - ) - : null; - final replyText = - (smallerText == null || - smallerText.length == - replyMessage - .metadata!["body"] - .length) - ? replyMessage.metadata!["body"] - : "$smallerText..."; - - return InkWell( - onTap: () => onTapReply?.call(replyMessage), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - AvatarOrHash( - Uri.tryParse( - replyMessage.metadata?["avatarUrl"] ?? - "", - ), - replyMessage.metadata?["displayName"] ?? - "", - height: 16, - ), - Flexible( - child: Text( - replyMessage - .metadata?["displayName"] ?? - replyMessage.authorId, - style: Theme.of(context) - .textTheme - .labelMedium - ?.copyWith( - fontWeight: FontWeight.bold, - ), - overflow: TextOverflow.ellipsis, - ), - ), - Flexible( - child: Text( - replyText, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.labelMedium, - maxLines: 1, - ), - ), - ], - ), - ); - }, - ), - ), - ), - ); -} diff --git a/lib/widgets/chat_page/room_appbar.dart b/lib/widgets/chat_page/room_appbar.dart deleted file mode 100644 index 436bcb9..0000000 --- a/lib/widgets/chat_page/room_appbar.dart +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index 839109f..0000000 --- a/lib/widgets/chat_page/room_chat.dart +++ /dev/null @@ -1,368 +0,0 @@ -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/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/message_wrapper.dart"; -import "package:nexus/widgets/chat_page/room_appbar.dart"; -import "package:nexus/widgets/chat_page/text_message_wrapper.dart"; -import "package:nexus/widgets/chat_page/reply_widget.dart"; -import "package:nexus/widgets/form_text_input.dart"; -import "package:nexus/widgets/loading.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: (_) => Loading(), - - 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, - ), - - 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/chat_page/room_menu.dart b/lib/widgets/chat_page/room_menu.dart deleted file mode 100644 index 2687bc8..0000000 --- a/lib/widgets/chat_page/room_menu.dart +++ /dev/null @@ -1,121 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/models/room.dart"; - -class RoomMenu extends ConsumerWidget { - final Room room; - final IList children; - const RoomMenu(this.room, {this.children = const IList.empty(), super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final danger = Theme.of(context).colorScheme.error; - final client = ref.watch(ClientController.provider.notifier); - - return PopupMenuButton( - itemBuilder: (_) => [ - // PopupMenuItem( - // onTap: () async { - // final link = await room.matrixToInviteLink(); - // await Clipboard.setData(ClipboardData(text: link.toString())); - // }, - // child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), - // ), - PopupMenuItem( - onTap: () async { - await client.markRead(room); - await Future.wait(children.map((child) => client.markRead(child))); - }, - child: ListTile( - leading: Icon(Icons.check), - title: Text("Mark as Read"), - ), - ), - 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( - 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, - // builder: (context) => HookBuilder( - // builder: (_) { - // final reasonController = useTextEditingController(); - // return AlertDialog( - // title: Text("Report"), - // content: Column( - // mainAxisSize: MainAxisSize.min, - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // Text( - // "Report this room to your server administrators, who can take action like banning this 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: () { - // room.client.reportRoom(room.id, reasonController.text); - // Navigator.of(context).pop(); - // }, - // child: Text("Report"), - // ), - // ], - // ); - // }, - // ), - // ), - // child: ListTile( - // leading: Icon(Icons.report, color: danger), - // title: Text("Report", style: TextStyle(color: danger)), - // ), - // ), - ], - ); - } -} diff --git a/lib/widgets/chat_page/sidebar.dart b/lib/widgets/chat_page/sidebar.dart deleted file mode 100644 index 4642a58..0000000 --- a/lib/widgets/chat_page/sidebar.dart +++ /dev/null @@ -1,233 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/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/text_message_wrapper.dart b/lib/widgets/chat_page/text_message_wrapper.dart deleted file mode 100644 index 9734a34..0000000 --- a/lib/widgets/chat_page/text_message_wrapper.dart +++ /dev/null @@ -1,114 +0,0 @@ -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/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, - ); - } -} diff --git a/lib/widgets/common/account_switcher.dart b/lib/widgets/common/account_switcher.dart new file mode 100644 index 0000000..3d0f996 --- /dev/null +++ b/lib/widgets/common/account_switcher.dart @@ -0,0 +1,133 @@ +import "package:collection/collection.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/accounts.dart"; +import "package:nexus/controllers/active_account.dart"; +import "package:nexus/helpers/extensions/log_out.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/account.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; + +/// Switches between, adds and logs out of accounts. With [onlyWithOthers], it's +/// hidden when there's only one account. +class const AccountSwitcher({final bool onlyWithOthers = false, super.key}) + extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final danger = Theme.of(context).colorScheme.error; + final accounts = ref.watch(AccountsController.provider).value; + final activeId = ref.watch(ActiveAccountController.provider); + if (accounts == null || (onlyWithOthers && accounts.length < 2)) { + return const SizedBox.shrink(); + } + + String nameOf(Account account) => + account.displayName ?? account.userId ?? "New account"; + + Widget avatarOf(Account account, double dimension) => AvatarOrHash( + account.avatar, + nameOf(account), + dimension: dimension, + fallback: account.userId == null + ? Icon(Icons.account_circle, size: dimension) + : null, + ); + + final active = accounts.firstWhereOrNull( + (account) => account.id == activeId, + ); + + void switchTo(String accountId) => ref + .read(ActiveAccountController.provider.notifier) + .switchTo(accountId) + .onError(showError); + + final loggingOut = ref.watch(LogOut.mutation).isPending; + + return AdaptiveMenuButton( + tooltip: loggingOut ? "Logging out..." : "Switch account", + enabled: !loggingOut, + icon: loggingOut + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(), + ) + : active == null + ? const Icon(Icons.account_circle) + : avatarOf(active, 28), + itemBuilder: (_) => [ + for (final account in accounts) + PopupMenuItem( + onTap: () => switchTo(account.id), + child: ListTile( + selected: account.id == activeId, + leading: avatarOf(account, 32), + title: Text(nameOf(account)), + subtitle: switch (account) { + Account(displayName: _?, :final userId?) => Text(userId), + _ => null, + }, + trailing: account.userId == null + ? null + // Inside the menu, so it can close it + : Builder( + builder: (menuContext) => IconButton( + tooltip: "Log out", + icon: Icon(Icons.logout, color: danger), + onPressed: () async { + Navigator.of(menuContext).pop(); + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text("Log out of ${nameOf(account)}?"), + content: Text( + account.id == activeId + ? "You'll be switched to another account, or returned to the login page if this is your only one." + : "You'll stop receiving notifications for this account.", + ), + actions: [ + TextButton( + onPressed: () => + Navigator.of(dialogContext).pop(false), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => + Navigator.of(dialogContext).pop(true), + style: TextButton.styleFrom( + foregroundColor: danger, + ), + child: const Text("Log out"), + ), + ], + ), + ); + if (confirmed != true) return; + + await ref.logOut(account.id).onError(showError); + }, + ), + ), + ), + ), + const PopupMenuDivider(), + PopupMenuItem( + onTap: () { + final id = DateTime.now().millisecondsSinceEpoch.toString(); + ref + .read(AccountsController.provider.notifier) + .save(.new(id: id)) + .then((_) => switchTo(id)) + .onError(showError); + }, + child: const ListTile( + leading: Icon(Icons.person_add), + title: Text("Add account"), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/common/appbar.dart b/lib/widgets/common/appbar.dart new file mode 100644 index 0000000..586d578 --- /dev/null +++ b/lib/widgets/common/appbar.dart @@ -0,0 +1,96 @@ +import "dart:io"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:window_manager/window_manager.dart"; + +final class const Appbar({ + final Widget? leading, + final Widget? title, + final Color? backgroundColor, + final double? scrolledUnderElevation, + final IList actions = const .empty(), + final VoidCallback? onTap, + + /// Whether to show the minimize, maximize and close buttons on desktop. + final bool windowControls = true, + super.key, +}) extends ConsumerWidget implements PreferredSizeWidget { + @override + Size get preferredSize => const .fromHeight(kToolbarHeight); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDesktop = !(Platform.isAndroid || Platform.isIOS); + + void maximize() { + final window = WindowManager.instance.getCurrent(); + if (window == null) return; + + if (window.isMaximized) { + return window.unmaximize(); + } + + return window.maximize(); + } + + return GestureDetector( + onPanStart: ref + .watch(SettingsController.provider) + .whenOrNull( + data: (settings) => !isDesktop || settings.linuxMobileMode + ? null + : (_) => WindowManager.instance.getCurrent()?.startDragging(), + ), + child: AppBar( + leading: leading == null ? null : InkWell(onTap: onTap, child: leading), + backgroundColor: backgroundColor, + scrolledUnderElevation: scrolledUnderElevation, + actionsPadding: const .symmetric(horizontal: 8), + title: InkWell( + onTap: onTap, + child: IgnorePointer(child: title), + ), + flexibleSpace: GestureDetector( + onDoubleTap: isDesktop ? maximize : null, + ), + actions: [ + ...actions, + if (windowControls && isDesktop) ...[ + if (ref + .watch(SettingsController.provider) + .whenOrNull( + data: (settings) => settings.showMinimizeButton, + ) ?? + false) + IconButton( + tooltip: "Minimize window", + onPressed: () => + WindowManager.instance.getCurrent()?.minimize(), + icon: const Icon(Icons.minimize), + ), + if (!Platform.isLinux) + IconButton( + tooltip: "Maximize window", + onPressed: maximize, + icon: const Icon(Icons.fullscreen), + ), + if (ref + .watch(SettingsController.provider) + .whenOrNull( + data: (settings) => !settings.linuxMobileMode, + ) ?? + true) + IconButton( + tooltip: "Close window", + onPressed: () => exit(0), + icon: const Icon(Icons.close), + ), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/common/bouncing_dots.dart b/lib/widgets/common/bouncing_dots.dart new file mode 100644 index 0000000..37f655c --- /dev/null +++ b/lib/widgets/common/bouncing_dots.dart @@ -0,0 +1,41 @@ +import "dart:math"; + +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; + +/// Three dots that bounce one after another, in a loop. +class const BouncingDots({required final Color color, super.key}) + extends HookWidget { + @override + Widget build(BuildContext context) { + final controller = useAnimationController( + duration: const .new(milliseconds: 1200), + ); + useEffect(() { + controller.repeat(); + return null; + }, []); + final progress = useAnimation(controller); + + return Row( + mainAxisSize: .min, + spacing: 3, + children: [0, 1, 2].map((index) { + final phase = (progress - index * 0.15) % 1; + final bounce = phase < 0.4 ? sin(phase / 0.4 * pi) : 0.0; + + return Transform.translate( + offset: .new(0, -3 * bounce), + child: Opacity( + opacity: 0.4 + 0.6 * bounce, + child: Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: color, shape: .circle), + ), + ), + ); + }).toList(), + ); + } +} diff --git a/lib/widgets/common/count_badge.dart b/lib/widgets/common/count_badge.dart new file mode 100644 index 0000000..e0912f8 --- /dev/null +++ b/lib/widgets/common/count_badge.dart @@ -0,0 +1,16 @@ +import "package:material_ui/material_ui.dart"; + +/// Shows [count] in a badge on [child]: nothing if null, a dot if 0, and the +/// number otherwise. +class const CountBadge( + final int? count, { + required final Widget child, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) => switch (count) { + null => child, + 0 => Badge(child: child), + final count => Badge.count(count: count, child: child), + }; +} diff --git a/lib/widgets/common/divider_text.dart b/lib/widgets/common/divider_text.dart new file mode 100644 index 0000000..2c623ba --- /dev/null +++ b/lib/widgets/common/divider_text.dart @@ -0,0 +1,9 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/common/divider_widget.dart"; + +final class const DividerText(final String text, {super.key}) + extends StatelessWidget { + @override + Widget build(BuildContext context) => + DividerWidget(Text(text, style: Theme.of(context).textTheme.labelLarge)); +} diff --git a/lib/widgets/common/divider_widget.dart b/lib/widgets/common/divider_widget.dart new file mode 100644 index 0000000..5e4972c --- /dev/null +++ b/lib/widgets/common/divider_widget.dart @@ -0,0 +1,23 @@ +import "package:material_ui/material_ui.dart"; + +final class const DividerWidget(final Widget widget, {super.key}) + extends StatelessWidget { + @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/common/error_dialog.dart b/lib/widgets/common/error_dialog.dart new file mode 100644 index 0000000..a12a3b4 --- /dev/null +++ b/lib/widgets/common/error_dialog.dart @@ -0,0 +1,35 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_riverpod/misc.dart"; + +final class const ErrorDialog( + final Object error, + final StackTrace? stackTrace, { + final ProviderOrFamily? provider, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + return AlertDialog( + title: Text("An Error Occurred"), + content: SingleChildScrollView( + child: SelectableText( + "$error${stackTrace == null ? "" : "\n\n$stackTrace"}", + ), + ), + actions: [ + if (provider != null) + TextButton( + 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"), + ), + ], + ); + } +} diff --git a/lib/widgets/common/expandable_image.dart b/lib/widgets/common/expandable_image.dart new file mode 100644 index 0000000..34a1f76 --- /dev/null +++ b/lib/widgets/common/expandable_image.dart @@ -0,0 +1,28 @@ +import "package:material_ui/material_ui.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/helpers/extensions/show_image_viewer.dart"; +import "package:nexus/models/requests/download_media.dart"; + +final class const ExpandableImage( + final DownloadMediaRequest? request, { + required final Widget child, + + /// If set, a download button is shown, suggesting this filename + final String? downloadFilename, + final int? size, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => InkWell( + onTap: switch (request) { + final request? => () => context.showImageViewer( + ref, + request, + downloadFilename: downloadFilename, + size: size, + ), + null => null, + }, + child: child, + ); +} diff --git a/lib/widgets/common/highlight_wrapper.dart b/lib/widgets/common/highlight_wrapper.dart new file mode 100644 index 0000000..4612548 --- /dev/null +++ b/lib/widgets/common/highlight_wrapper.dart @@ -0,0 +1,20 @@ +import "package:material_ui/material_ui.dart"; + +final class const HighlightWrapper( + final Widget child, { + final bool isHighlighted = false, + super.key, +}) extends StatelessWidget { + @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: Durations.medium1, + child: Material(color: Colors.transparent, child: child), + ), + ); +} diff --git a/lib/widgets/common/linkified_text.dart b/lib/widgets/common/linkified_text.dart new file mode 100644 index 0000000..d0c558b --- /dev/null +++ b/lib/widgets/common/linkified_text.dart @@ -0,0 +1,49 @@ +import "package:flutter/gestures.dart"; +import "package:material_ui/material_ui.dart"; +import "package:linkify/linkify.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/helpers/extensions/get_link_info.dart"; +import "package:nexus/helpers/launch_helper.dart"; +import "package:nexus/helpers/matrix_linkifier.dart"; +import "package:nexus/widgets/html/mention_chip.dart"; + +final class const LinkifiedText( + final String text, { + final String? roomId, + final int? maxLines, + final TextStyle? style, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => Text.rich( + TextSpan( + style: style, + children: linkifyText(text) + .map( + (element) => switch (element) { + LinkableElement(:final url) when url.linkInfo != null => + WidgetSpan( + alignment: .baseline, + baseline: .alphabetic, + child: MentionChip(url, roomId), + ), + LinkableElement(:final text, :final url) => TextSpan( + text: text, + style: .new( + color: Theme.of(context).colorScheme.primary, + decoration: .underline, + ), + mouseCursor: SystemMouseCursors.click, + recognizer: TapGestureRecognizer() + ..onTap = () => + ref.read(LaunchHelper.provider).launchUrl(.parse(url)), + ), + _ => TextSpan(text: element.text), + }, + ) + .toList(), + ), + maxLines: maxLines, + overflow: maxLines == null ? null : .ellipsis, + ); +} diff --git a/lib/widgets/common/loading.dart b/lib/widgets/common/loading.dart new file mode 100644 index 0000000..e92860e --- /dev/null +++ b/lib/widgets/common/loading.dart @@ -0,0 +1,15 @@ +import "package:material_ui/material_ui.dart"; + +class const Loading({super.key, final double? dimension}) + extends StatelessWidget { + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: .all(16), + child: SizedBox.square( + dimension: dimension, + child: CircularProgressIndicator(), + ), + ), + ); +} diff --git a/lib/widgets/common/m3e_rail_theme.dart b/lib/widgets/common/m3e_rail_theme.dart new file mode 100644 index 0000000..3ca5ed2 --- /dev/null +++ b/lib/widgets/common/m3e_rail_theme.dart @@ -0,0 +1,36 @@ +import "package:material_ui/material_ui.dart"; +import "package:m3e_design/m3e_design.dart"; + +// Needed for navigation_rail_m3e (#65). +import "package:flutter/material.dart" as old_mat; + +/// Themes a navigation_rail_m3e rail, which still uses the old Material theme. +class const M3ERailTheme({ + required final WidgetBuilder builder, + final List extensions = const [], + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) => MaterialUiCompatibilityBridge( + child: Builder( + builder: (context) { + final theme = old_mat.Theme.of(context); + return old_mat.Theme( + data: theme.copyWith( + extensions: [ + ...extensions, + // Otherwise the FAB label falls back to Roboto + M3ETheme.defaults(theme.colorScheme).copyWith( + typography: .defaultFor( + theme.brightness, + baseOverride: theme.textTheme, + ), + ), + ], + ), + child: Builder(builder: builder), + ); + }, + ), + ); +} diff --git a/lib/widgets/common/sync_status_banner.dart b/lib/widgets/common/sync_status_banner.dart new file mode 100644 index 0000000..4199866 --- /dev/null +++ b/lib/widgets/common/sync_status_banner.dart @@ -0,0 +1,85 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/sync_status.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:timeago/timeago.dart"; + +class const SyncStatusBanner({super.key}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final status = ref.watch(SyncStatusController.provider); + final colorScheme = Theme.of(context).colorScheme; + + final banner = switch (status?.type) { + null || .ok => null, + .waiting => ( + title: "Waiting for sync...", + icon: Icons.sync, + background: colorScheme.primary, + foreground: colorScheme.onPrimary, + ), + .erroring => ( + title: "Sync is failing, retrying...", + icon: Icons.sync_problem, + background: colorScheme.error, + foreground: colorScheme.onError, + ), + .permanentlyFailed => ( + title: "Sync has permanently failed", + icon: Icons.cloud_off, + background: colorScheme.error, + foreground: colorScheme.onError, + ), + }; + + final details = status == null + ? [] + : [ + ?status.error, + if (status.errorCount > 0) "Failed attempts: ${status.errorCount}.", + if (status.lastSync case final lastSync?) + "Last synced ${format(lastSync)}.", + ]; + + return AnimatedSize( + duration: Durations.short4, + alignment: .topCenter, + child: banner == null + ? const SizedBox(width: double.infinity) + : Material( + color: banner.background, + child: InkWell( + onTap: details.isEmpty + ? null + : () => showDialog( + context: context, + builder: (context) => + ErrorDialog(details.join("\n\n"), null), + ), + child: Padding( + padding: const .symmetric(horizontal: 16, vertical: 8), + child: Row( + spacing: 12, + children: [ + Icon(banner.icon, size: 20, color: banner.foreground), + Expanded( + child: Text( + banner.title, + maxLines: 1, + overflow: .ellipsis, + style: .new( + color: banner.foreground, + fontWeight: .bold, + ), + ), + ), + if (details.isNotEmpty) + Icon(Icons.info_outline, color: banner.foreground), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/common/timestamp.dart b/lib/widgets/common/timestamp.dart new file mode 100644 index 0000000..bc9db74 --- /dev/null +++ b/lib/widgets/common/timestamp.dart @@ -0,0 +1,17 @@ +import "package:material_ui/material_ui.dart"; +import "package:timeago/timeago.dart"; + +class const Timestamp(final DateTime timestamp, {super.key}) + extends StatelessWidget { + @override + Widget build(BuildContext context) => Tooltip( + message: timestamp.toString(), + child: Text( + format(timestamp), + maxLines: 1, + overflow: .ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: Colors.grey), + ), + ); +} diff --git a/lib/widgets/composer/bot_command_form.dart b/lib/widgets/composer/bot_command_form.dart new file mode 100644 index 0000000..fdd0dc4 --- /dev/null +++ b/lib/widgets/composer/bot_command_form.dart @@ -0,0 +1,53 @@ +import "package:collection/collection.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/bot_command_draft.dart"; +import "package:nexus/models/bot_command_draft.dart"; +import "package:nexus/widgets/composer/bot_option_pill.dart"; + +/// A bot command with its parameters filled in inline, like Discord's slash +/// commands. +class const BotCommandForm( + final BotCommandDraft draft, { + required final VoidCallback onSubmit, + + /// Called on backspace in the first parameter while empty. + required final VoidCallback onRemove, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + return Wrap( + spacing: 6, + runSpacing: 6, + crossAxisAlignment: .center, + children: [ + Text( + draft.command.prefix, + style: .new( + color: Theme.of(context).colorScheme.primary, + fontWeight: .bold, + ), + ), + ...draft.shown.mapIndexed( + (index, parameter) => BotOptionPill( + parameter, + key: ValueKey(parameter.key), + composer: draft.composer, + onSubmit: onSubmit, + onRemove: parameter.optional + ? () => ref + .read( + BotCommandDraftController.provider(draft.composer) + .notifier, + ) + .removeOption(parameter.key) + : index == 0 + ? onRemove + : null, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/composer/bot_command_panel.dart b/lib/widgets/composer/bot_command_panel.dart new file mode 100644 index 0000000..bc6c05a --- /dev/null +++ b/lib/widgets/composer/bot_command_panel.dart @@ -0,0 +1,162 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/scheduler.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/composer_params.dart"; +import "package:nexus/controllers/bot_argument_suggestions.dart"; +import "package:nexus/controllers/bot_command_draft.dart"; +import "package:nexus/models/bot_argument_suggestion.dart"; +import "package:nexus/models/bot_command.dart"; +import "package:nexus/models/text_command.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; + +class const BotCommandPanel( + final ComposerParams composer, { + required final IList commands, + required final IList textCommands, + required final TextEditingController controller, + required final void Function(BotCommand command) onPick, + required final void Function(TextCommand command) onPickText, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final draftProvider = BotCommandDraftController.provider(composer); + final suggestionsProvider = BotArgumentSuggestionsController.provider( + composer, + ); + + final text = useState(controller.text); + useEffect(() { + void update() { + if (SchedulerBinding.instance.schedulerPhase == .persistentCallbacks) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) text.value = controller.text; + }); + } else { + text.value = controller.text; + } + } + + controller.addListener(update); + return () => controller.removeListener(update); + }, [controller]); + + ListTile commandTile(BotCommand command, {VoidCallback? onTap}) => .new( + leading: AvatarOrHash(command.botAvatar, command.botName, dimension: 32), + title: Text(command.prefix), + subtitle: Text(command.about, maxLines: 1, overflow: .ellipsis), + onTap: onTap, + ); + + final children = switch (ref.watch(draftProvider)) { + null => [ + ...textCommands + .where((command) => command.completes(text.value)) + .map( + (command) => ListTile( + leading: AvatarOrHash(null, "gomuks", dimension: 32), + title: Text(command.prefix), + subtitle: Text( + "gomuks · ${command.description}", + maxLines: 1, + overflow: .ellipsis, + ), + onTap: () => onPickText(command), + ), + ), + ...commands + .where((command) => command.completes(text.value)) + .map( + (command) => commandTile(command, onTap: () => onPick(command)), + ), + ], + final draft => [ + commandTile(draft.command), + if (draft.focusedParameter case final parameter?) + ListTile( + title: Text( + parameter.key, + style: .new(color: theme.colorScheme.primary), + ), + subtitle: switch (( + draft.errors[parameter.key], + parameter.description, + )) { + (final error?, _) => Text( + error, + style: .new(color: theme.colorScheme.error), + ), + (null, final description?) => Text(description), + (null, null) => null, + }, + trailing: Text( + parameter.schema.label, + style: theme.textTheme.labelSmall, + ), + ), + ...ref + .watch(suggestionsProvider) + .map( + (suggestion) => ListTile( + leading: switch (suggestion) { + BotArgumentSuggestion(room: _?) => AvatarOrHash( + suggestion.avatar, + suggestion.title, + fallback: Icon(Icons.numbers), + ), + BotArgumentSuggestion(subtitle: _?) => AvatarOrHash( + suggestion.avatar, + suggestion.title, + ), + _ => null, + }, + title: Text(suggestion.title), + subtitle: switch (suggestion.subtitle) { + final subtitle? => Text(subtitle), + null => null, + }, + onTap: () => + ref.read(suggestionsProvider.notifier).pick(suggestion), + ), + ), + if (draft.hiddenOptions.isNotEmpty) + Padding( + padding: EdgeInsets.symmetric(horizontal: 16).copyWith(bottom: 12), + child: Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: .center, + children: [ + Text("Optional", style: theme.textTheme.labelMedium), + ...draft.hiddenOptions.map( + (option) => ActionChip( + avatar: Icon(Icons.add), + label: Text(option.key), + tooltip: option.description, + onPressed: () => + ref.read(draftProvider.notifier).addOption(option.key), + ), + ), + ], + ), + ), + ], + }; + + return children.isEmpty + ? SizedBox(width: double.infinity) + : TextFieldTapRegion( + child: Card( + margin: .only(bottom: 8), + clipBehavior: .antiAlias, + child: ConstrainedBox( + constraints: .new(maxHeight: 320), + child: ListView(shrinkWrap: true, children: children), + ), + ), + ); + } +} diff --git a/lib/widgets/composer/bot_option_pill.dart b/lib/widgets/composer/bot_option_pill.dart new file mode 100644 index 0000000..0a86d8e --- /dev/null +++ b/lib/widgets/composer/bot_option_pill.dart @@ -0,0 +1,124 @@ +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/composer_params.dart"; +import "package:nexus/controllers/bot_command_draft.dart"; +import "package:nexus/models/content/bot_command_description.dart"; + +class const BotOptionPill( + final BotParameter parameter, { + required final ComposerParams composer, + + /// Called on backspace while empty, to remove the parameter or command. + final VoidCallback? onRemove, + required final VoidCallback onSubmit, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final provider = BotCommandDraftController.provider(composer); + final (:text, :isFocused, :error, :isLast) = ref.watch( + provider.select( + (draft) => ( + text: draft?.texts[parameter.key] ?? "", + isFocused: draft?.focused == parameter.key, + error: draft?.errors[parameter.key], + isLast: + draft?.missing.every((other) => other.key == parameter.key) ?? + true, + ), + ), + ); + final controller = useTextEditingController(text: text); + final focusNode = useFocusNode(); + + useEffect(() { + if (controller.text != text) { + controller.value = .new( + text: text, + selection: .collapsed(offset: text.length), + ); + } + return null; + }, [text]); + + useEffect(() { + if (isFocused && !focusNode.hasFocus) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => focusNode.requestFocus(), + ); + } + return null; + }, [isFocused]); + + return Focus( + canRequestFocus: false, + skipTraversal: true, + onFocusChange: (focused) { + if (focused) { + ref.read(provider.notifier).focus(parameter.key); + } + }, + onKeyEvent: (_, event) { + if (onRemove case final remove? + when event is KeyDownEvent && + event.logicalKey == .backspace && + controller.text.isEmpty) { + remove(); + return .handled; + } + return .ignored; + }, + child: Container( + padding: .symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isFocused ? colorScheme.primaryContainer : colorScheme.surface, + border: error == null ? null : .all(color: colorScheme.error), + borderRadius: .all(.circular(8)), + ), + child: Row( + mainAxisSize: .min, + spacing: 4, + children: [ + Text( + "${parameter.key}:", + style: .new( + fontWeight: .bold, + color: isFocused + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + Flexible( + child: ConstrainedBox( + constraints: .new(minWidth: 16, maxWidth: 320), + child: IntrinsicWidth( + child: TextField( + controller: controller, + focusNode: focusNode, + readOnly: parameter.schema is LiteralSchema, + onChanged: (text) => ref + .read(provider.notifier) + .setText(parameter.key, text), + textInputAction: isLast ? .send : .next, + onSubmitted: (_) => onSubmit(), + // Keeps the keyboard open, rather than moving focus itself + onEditingComplete: () {}, + keyboardType: + parameter.schema.allows(.integer) && + !parameter.schema.allows(.string) + ? .number + : null, + decoration: .collapsed(hintText: null), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart new file mode 100644 index 0000000..4c3caf6 --- /dev/null +++ b/lib/widgets/composer/composer.dart @@ -0,0 +1,945 @@ +import "dart:io"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:collection/collection.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/bot_argument_suggestions.dart"; +import "package:nexus/controllers/bot_command_draft.dart"; +import "package:nexus/controllers/bot_commands.dart"; +import "package:nexus/controllers/composer_draft.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/refocus_after_rebuild.dart"; +import "package:nexus/helpers/extensions/show_error_snack_bar.dart"; +import "package:nexus/helpers/extensions/with_latest_edit.dart"; +import "package:nexus/models/bot_command.dart"; +import "package:nexus/models/bot_command_draft.dart"; +import "package:nexus/models/composer_params.dart"; +import "package:nexus/models/text_command.dart"; +import "package:nexus/widgets/composer/bot_command_form.dart"; +import "package:nexus/widgets/composer/bot_command_panel.dart"; +import "package:nexus/widgets/emoji/emoji_picker.dart"; +import "package:material_ui/material_ui.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/attachment.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/controllers/image_picker.dart"; +import "package:nexus/helpers/file_dialog_helper.dart"; +import "package:image_picker/image_picker.dart"; +import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/image_packs.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/poll_start.dart"; +import "package:nexus/models/image_pack.dart"; +import "package:nexus/widgets/emoji/mxc_emoji.dart"; +import "package:nexus/widgets/emoji/sticker_picker.dart"; +import "package:nexus/widgets/composer/create_poll_sheet.dart"; +import "package:nexus/widgets/location/location_picker_sheet.dart"; +import "package:nexus/models/location.dart"; +import "package:nexus/widgets/composer/overlays/tagger_overlay.dart"; +import "package:nexus/widgets/composer/paste_files_action.dart"; +import "package:pasteboard/pasteboard.dart"; +import "package:path/path.dart"; +import "package:path_provider/path_provider.dart"; +import "package:nexus/widgets/composer/relation_preview.dart"; +import "package:nexus/widgets/composer/composer_replacement.dart"; +import "package:nexus/main.dart"; + +class const Composer( + final String roomId, { + required final Future Function( + String text, { + required bool shouldMention, + required IList tags, + }) + onSend, + required final Future Function( + String shortcode, + PackImage image, { + required bool shouldMention, + }) + onSendSticker, + required final Future Function( + PollStart poll, { + required bool shouldMention, + }) + onSendPoll, + required final Future Function( + LocationMessageContent location, { + required bool shouldMention, + }) + onSendLocation, + required final Future Function(BotCommandDraft draft) onSendBotCommand, + final String? threadRoot, + final FocusNode? node, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final ComposerParams composer = (roomId: roomId, threadRoot: threadRoot); + final draftProvider = ComposerDraftController.provider(composer); + final draftNotifier = ref.watch(draftProvider.notifier); + final ( + :relatedEventRowId, + :relationType, + :editingEvent, + :sticker, + :poll, + :location, + :textCommands, + ) = ref.watch( + draftProvider.select( + (draft) => ( + relatedEventRowId: draft.relatedEvent, + relationType: draft.relationType, + editingEvent: draft.isEditing ? draft.relatedEvent : null, + sticker: draft.sticker, + poll: draft.poll, + location: draft.location, + textCommands: draft.textCommands, + ), + ), + ); + final isEditing = editingEvent != null; + + final botCommandProvider = BotCommandDraftController.provider(composer); + final attachmentProvider = AttachmentController.provider(( + composer: composer, + editingRowId: editingEvent, + )); + final botCommand = ref.watch(botCommandProvider); + + final hasReplacement = + sticker != null || + poll != null || + location != null || + botCommand != null; + + final controller = useMemoized(() { + final draft = ref.read(draftProvider); + final ids = IMap.fromEntries( + draft.tags + .map((tag) => MapEntry("${tag.triggerCharacter}${tag.id}#", tag.id)) + .sortedBy((entry) => -entry.key.length), + ); + + return FlutterTaggerController(text: draft.text)..formatTags( + // Matches tags formatted by tagTextFormatter, like ":id#name#" + pattern: RegExp( + ids.isEmpty + ? "(?!)" + : "((?:${ids.keys.map(RegExp.escape).join("|")})[^#]*#)", + ), + parser: (formatted) { + final prefix = ids.keys.firstWhere(formatted.startsWith); + return [ + ids[prefix]!, + formatted.substring(prefix.length, formatted.length - 1), + ]; + }, + ); + }, [composer, editingEvent, hasReplacement]); + useEffect( + // After the frame, as FlutterTagger and TextField still use it until then + () => + () => WidgetsBinding.instance.addPostFrameCallback( + (_) => controller.dispose(), + ), + [controller], + ); + + final triggerCharacter = useState(""); + final shouldMention = useState(true); + final query = useState(""); + final canSendStickers = + ref.watch( + PowerLevelController.provider( + .new(eventType: .sticker, roomId: roomId), + ), + ) && + (ref + .watch(ImagePacksController.provider(roomId)) + .value + ?.any((pack) => pack.stickers.isNotEmpty) ?? + false); + final canSendPolls = ref.watch( + PowerLevelController.provider( + .new(eventType: .pollStart, roomId: roomId), + ), + ); + final relatedEvent = relatedEventRowId == null + ? null + : ref.watch( + RoomsController.provider.select( + (rooms) => rooms[roomId]?.withLatestEdit(relatedEventRowId), + ), + ); + + void dismiss() { + draftNotifier.dismiss(); + // Ending an edit replaces the text field + if (isEditing && (node?.hasFocus ?? false)) node?.refocusAfterRebuild(); + } + + final sinceTypingSent = useMemoized(Stopwatch.new); + final client = ref.watch(ClientController.provider.notifier); + + void stopTyping() { + if (!sinceTypingSent.isRunning) return; + sinceTypingSent + ..stop() + ..reset(); + client.setTyping(roomId, .zero).ignore(); + } + + // Stops typing in the previous room, or the message before an edit + useEffect(() => stopTyping, [controller]); + + // Called by FlutterTagger for changes once attached, unlike a listener on + // the controller, which is also told about the draft being restored + // during the build + void onFormattedTextChanged(String text) { + final draft = ref.read(draftProvider); + if (text == draft.text || + (draft.isEditing ? draft.relatedEvent : null) != editingEvent) { + return; + } + + draftNotifier.update( + (draft) => draft.copyWith(text: text, tags: controller.tags.toIList()), + ); + + final shouldSend = + ref.read(SettingsController.provider).value?.typingIndicators == + .showAndSend; + + if (!shouldSend || controller.text.isEmpty) return stopTyping(); + + if (sinceTypingSent.isRunning && + sinceTypingSent.elapsed < const .new(seconds: 5)) { + return; + } + + sinceTypingSent + ..reset() + ..start(); + client.setTyping(roomId, const .new(seconds: 10)).ignore(); + } + + final attachment = ref.watch(attachmentProvider); + + Future attach(Future pick) async { + if (await pick case final file?) { + await ref.read(attachmentProvider.notifier).add(file); + } + } + + /// Attaches [bytes] as a file called [filename], e.g. for pasted images. + Future attachBytes(Uint8List bytes, String filename) async { + // A new directory each time, so files with the same name don't clash + final directory = await (await getTemporaryDirectory()).createTemp( + "attachment", + ); + final file = await File(join(directory.path, filename)) + .writeAsBytes(bytes); + + await ref.read(attachmentProvider.notifier).add(.new(file.path)); + } + + /// Attaches the first file on the clipboard, or the image on it if there + /// are no files. Returns whether there was either, so that text can be + /// pasted otherwise. + Future pasteFiles() async { + // Only one attachment is supported at a time + if (attachment != null) return false; + + // Failing to read the clipboard counts as it being empty, so that text + // can still be pasted + final files = await Pasteboard.files().onError((_, _) => const []); + if (files.firstOrNull case final path?) { + ref + .read(attachmentProvider.notifier) + .add(XFile(path)) + .onError(showError); + return true; + } + + if (await Pasteboard.image.onError((_, _) => null) case final image?) { + attachBytes(image, "image.png").onError(showError); + return true; + } + + return false; + } + + // Bot commands can't follow text commands, which gomuks sends as text + final botCommands = + isEditing || + hasReplacement || + attachment != null || + textCommands.isNotEmpty + ? const IList.empty() + : ref.watch(BotCommandsController.provider(roomId)).value ?? + const .empty(); + + final availableTextCommands = hasReplacement || attachment != null + ? const IList.empty() + : TextCommand.values + .where((command) => !textCommands.contains(command)) + .toIList(); + + void setTextCommands(IList commands) => + draftNotifier.update((draft) => draft.copyWith(textCommands: commands)); + + void pickTextCommand(TextCommand command) { + setTextCommands(command.addTo(textCommands)); + controller.text = ""; + node?.requestFocus(); + } + + void removeTextCommand(TextCommand command) { + setTextCommands(textCommands.remove(command)); + node?.requestFocus(); + } + + /// Turns text commands typed at the start, like "/me ", into chips. + /// Returns whether there were any. + bool pickTypedTextCommands(String text) { + if (availableTextCommands.isEmpty) return false; + final parsed = TextCommand.parse(text, textCommands); + if (parsed.text == text) return false; + + setTextCommands(parsed.commands); + final removed = text.length - parsed.text.length; + controller.value = .new( + text: parsed.text, + selection: .collapsed( + offset: (controller.selection.baseOffset - removed).clamp( + 0, + parsed.text.length, + ), + ), + ); + return true; + } + + void pickBotCommand(BotCommand command) { + ref + .read(botCommandProvider.notifier) + .start(command, text: command.argumentsIn(controller.text) ?? ""); + controller.text = ""; + } + + BotCommand? typedBotCommand(String text) => switch (botCommands + .where((command) => command.argumentsIn(text) != null) + .sortedBy((command) => -command.prefix.length)) { + [final command, final other, ...] when command.prefix == other.prefix => + null, + [final command, ...] => command, + [] => null, + }; + + void pickTypedCommand(String text) { + if (pickTypedTextCommands(text)) return; + if (botCommands.any((command) => command.prefix.startsWith(text))) return; + if (typedBotCommand(text) case final command?) pickBotCommand(command); + } + + void removeBotCommand({required bool keepText}) { + // The text field gets a new controller from the draft once it's back + if (botCommand?.command.prefix case final prefix? when keepText) { + draftNotifier.update( + (draft) => draft.copyWith(text: prefix, tags: const .empty()), + ); + } + ref.read(botCommandProvider.notifier).clear(); + // The text field replaces the command form + node?.refocusAfterRebuild(); + } + + void send() { + if (hasReplacement) { + if (botCommand != null) { + if (ref + .read( + BotArgumentSuggestionsController.provider(composer).notifier, + ) + .pickTop()) { + return; + } + if (botCommand.missing.firstOrNull case final next? + when next.key != botCommand.focused && + !botCommand.errors.containsKey(botCommand.focused)) { + ref.read(botCommandProvider.notifier).focus(next.key); + return; + } + if (botCommand.problem case final problem?) { + ScaffoldMessenger.of(context).showErrorSnackBar(problem); + ref.read(botCommandProvider.notifier).focusNext(); + return; + } + } + + if (sticker case (final shortcode, final image)?) { + onSendSticker(shortcode, image, shouldMention: shouldMention.value); + } else if (poll case final draft?) { + onSendPoll(draft, shouldMention: shouldMention.value); + } else if (location case final draft?) { + onSendLocation(draft, shouldMention: shouldMention.value); + } else if (botCommand != null) { + onSendBotCommand(botCommand); + } + dismiss(); + // The draft is kept, so it can still be sent after the replacement + draftNotifier.update( + (draft) => draft.copyWith(sticker: null, poll: null, location: null), + ); + ref.read(botCommandProvider.notifier).clear(); + // Once the text field is back + WidgetsBinding.instance.addPostFrameCallback( + (_) => node?.requestFocus(), + ); + return; + } + + // In the order the command panel shows them + if (typedBotCommand(controller.text) case final command?) { + return pickBotCommand(command); + } + if (availableTextCommands.firstWhereOrNull( + (command) => command.completes(controller.text), + ) + case final command?) { + return pickTextCommand(command); + } + if (botCommands.firstWhereOrNull( + (command) => command.completes(controller.text), + ) + case final command?) { + return pickBotCommand(command); + } + + if (controller.value.text.isEmpty && attachment == null || + attachment != null && attachment.$2 == null) { + return; + } + onSend( + "${TextCommand.prefixOf(textCommands)}${controller.formattedText}", + shouldMention: shouldMention.value, + tags: .new(controller.tags), + ); + + // Before dismissing, which restores the draft from before an edit + controller.text = ""; + setTextCommands(const .empty()); + dismiss(); + } + + KeyEventResult sendOnEnter(FocusNode _, KeyEvent event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.enter && + !HardwareKeyboard.instance.isShiftPressed) { + send(); + return .handled; + } + + return .ignored; + } + + KeyEventResult onTextFieldKeyEvent(FocusNode node, KeyEvent event) { + if (textCommands.lastOrNull case final command? + when event is KeyDownEvent && + event.logicalKey == .backspace && + controller.selection.isCollapsed && + controller.selection.baseOffset == 0) { + setTextCommands(textCommands.removeLast()); + if (controller.text.isEmpty) { + controller.value = .new( + text: command.prefix, + selection: .collapsed(offset: command.prefix.length), + ); + } + return .handled; + } + + return sendOnEnter(node, event); + } + + final style = TextStyle( + color: theme.colorScheme.primary, + fontWeight: .bold, + ); + + return Padding( + padding: .all(12), + child: Column( + children: [ + if (attachment != null) + Card( + margin: .only(bottom: 8), + child: ListTile( + leading: attachment.$2 == null + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(), + ) + : Icon(Icons.file_copy), + title: Text(attachment.$1), + trailing: IconButton( + onPressed: () => ref.invalidate(attachmentProvider), + icon: Icon(Icons.close), + ), + ), + ), + AnimatedSize( + duration: Durations.short4, + alignment: .bottomCenter, + child: BotCommandPanel( + composer, + commands: botCommands, + textCommands: availableTextCommands, + controller: controller, + onPick: pickBotCommand, + onPickText: pickTextCommand, + ), + ), + ClipRRect( + borderRadius: .all(.circular(12)), + child: Column( + children: [ + RelationPreview( + relatedEvent, + shouldMention: shouldMention.value, + toggleShouldMention: () => + shouldMention.value = !shouldMention.value, + relationType: relationType, + onDismiss: dismiss, + ), + Container( + color: theme.colorScheme.surfaceContainerHighest, + child: Row( + mainAxisAlignment: .center, + children: + ref.watch( + PowerLevelController.provider( + .new(eventType: .message, roomId: roomId), + ), + ) + ? [ + IconButton( + onPressed: hasReplacement + ? null + : () => context.showAdaptiveSheet( + (context) => EmojiPicker( + roomId: roomId, + onSelection: + (value, {shortcode, description}) { + Navigator.of(context).pop(); + if (shortcode == null) { + controller.text += value; + } else { + final text = + "${controller.text}:"; + controller.value = .new( + text: text, + selection: .collapsed( + offset: text.length, + ), + ); + controller.addTag( + id: ImagePack.emojiMarkdown( + shortcode, + value, + description: description, + ), + name: shortcode, + ); + } + node?.requestFocus(); + }, + ), + ), + icon: Icon(Icons.emoji_emotions), + ), + AdaptiveMenuButton( + tooltip: "Add media", + enabled: attachment == null && !hasReplacement, + itemBuilder: (context) => [ + for (final (source, title, icon) in [ + if (Platform.isAndroid || Platform.isIOS) + ( + ImageSource.camera, + "Camera", + Icons.add_a_photo, + ), + ( + ImageSource.gallery, + "Gallery", + Icons.add_photo_alternate, + ), + ]) + PopupMenuItem( + child: ListTile( + title: Text(title), + leading: Icon(icon), + ), + onTap: () => attach( + source == .gallery + ? ref + .read(FileDialogHelper.provider) + .pickFile(images: true) + : ref + .read( + ImagePickerController + .provider, + ) + .pickImage(source: source), + ).onError(showError), + ), + PopupMenuItem( + onTap: () => attach( + ref + .read(FileDialogHelper.provider) + .pickFile(), + ).onError(showError), + child: ListTile( + title: Text("Files"), + leading: Icon(Icons.attachment), + ), + ), + if (canSendStickers) + PopupMenuItem( + // Stickers can't replace messages + enabled: !isEditing, + onTap: () => context.showAdaptiveSheet( + (context) => StickerPicker( + roomId: roomId, + onSelection: (shortcode, image) { + Navigator.of(context).pop(); + draftNotifier.update( + (draft) => draft.copyWith( + sticker: (shortcode, image), + ), + ); + }, + ), + ), + child: ListTile( + title: Text("Sticker"), + leading: Icon(Icons.sticky_note_2), + ), + ), + if (canSendPolls) + PopupMenuItem( + enabled: !isEditing, + onTap: () => context.showAdaptiveSheet( + (context) => CreatePollSheet( + onCreate: (created) { + Navigator.of(context).pop(); + draftNotifier.update( + (draft) => + draft.copyWith(poll: created), + ); + }, + ), + ), + child: ListTile( + title: Text("Poll"), + leading: Icon(Icons.poll), + ), + ), + PopupMenuItem( + enabled: !isEditing, + onTap: () => context.showAdaptiveSheet( + enableDrag: false, + (context) => LocationPickerSheet( + onPick: (picked) { + Navigator.of(context).pop(); + draftNotifier.update( + (draft) => + draft.copyWith(location: picked), + ); + }, + ), + ), + child: ListTile( + title: Text("Location"), + leading: Icon(Icons.location_on), + ), + ), + ], + icon: Icon(Icons.add), + ), + SizedBox(width: 8), + Expanded( + child: AnimatedSize( + duration: Durations.short4, + alignment: .bottomCenter, + child: switch (( + sticker, + poll, + location, + botCommand, + )) { + (_, final draft?, _, _) => + ComposerReplacement( + removeTooltip: "Remove poll", + onRemove: () { + draftNotifier.update( + (draft) => draft.copyWith(poll: null), + ); + node?.requestFocus(); + }, + onKeyEvent: sendOnEnter, + ListTile( + leading: Icon( + Icons.poll, + color: theme.colorScheme.primary, + ), + title: Text(draft.question), + subtitle: Text( + draft.answers + .map((answer) => answer.text) + .join(" · "), + maxLines: 1, + overflow: .ellipsis, + ), + ), + ), + ((final shortcode, final image)?, _, _, _) => + ComposerReplacement( + removeTooltip: "Remove sticker", + onRemove: () { + draftNotifier.update( + (draft) => + draft.copyWith(sticker: null), + ); + node?.requestFocus(); + }, + onKeyEvent: sendOnEnter, + Align( + alignment: .centerLeft, + child: ConstrainedBox( + constraints: .loose(.square(128)), + child: MxcEmoji( + image.url, + label: image.body ?? shortcode, + ), + ), + ), + ), + (_, _, final draft?, _) => + ComposerReplacement( + removeTooltip: "Remove location", + onRemove: () { + draftNotifier.update( + (draft) => + draft.copyWith(location: null), + ); + node?.requestFocus(); + }, + onKeyEvent: sendOnEnter, + ListTile( + leading: Icon( + draft.assetType == .pin + ? Icons.location_pin + : Icons.my_location, + color: theme.colorScheme.primary, + ), + title: Text( + draft.description ?? + (draft.assetType == .pin + ? "Location" + : "My location"), + ), + subtitle: Text( + draft.point?.formatted ?? + draft.geoUri.toString(), + maxLines: 1, + overflow: .ellipsis, + ), + ), + ), + (_, _, _, final draft?) => + ComposerReplacement( + removeTooltip: "Remove command", + onRemove: () => + removeBotCommand(keepText: false), + onKeyEvent: sendOnEnter, + BotCommandForm( + draft, + key: ValueKey(draft.command), + onSubmit: send, + onRemove: () => + removeBotCommand(keepText: true), + ), + ), + (null, null, null, null) => FlutterTagger( + // Attaches each new controller + key: ObjectKey(controller), + triggerStrategy: .eager, + onFormattedTextChanged: + onFormattedTextChanged, + // Keeps the trigger character, so tags + // can be restored from drafts + tagTextFormatter: ( + id, + tag, + triggerCharacter, + ) => "$triggerCharacter$id#$tag#", + // Characters that continue a search, for + // e.g. :+1, :custom_emoji or @user:server + searchRegex: .new(r"^[\w.:+-]*$"), + overlay: TaggerOverlay( + query.value, + roomId: roomId, + triggerCharacter: triggerCharacter.value, + addTag: ({required id, required name}) { + controller.addTag(id: id, name: name); + node?.requestFocus(); + }, + ), + controller: controller, + onSearch: (newQuery, newTriggerCharacter) { + triggerCharacter.value = + newTriggerCharacter; + query.value = newQuery; + }, + triggerCharacterAndStyles: { + "@": style, + "#": style, + ":": style, + }, + builder: (context, key) => Focus( + onKeyEvent: onTextFieldKeyEvent, + // Overrides the keyboard shortcut for pasting + child: Actions( + actions: { + PasteTextIntent: PasteFilesAction( + pasteFiles, + ), + }, + child: Row( + spacing: 4, + children: [ + ...textCommands.map( + (command) => InputChip( + label: Text(command.prefix), + tooltip: command.description, + visualDensity: .compact, + materialTapTargetSize: + .shrinkWrap, + onDeleted: () => + removeTextCommand(command), + ), + ), + Expanded( + child: TextField( + maxLines: 12, + minLines: 1, + textCapitalization: .sentences, + onChanged: pickTypedCommand, + autofocus: + (Platform.isLinux || + Platform.isMacOS || + Platform.isWindows), + decoration: .new( + hintText: + "Your message here...", + border: .none, + ), + controller: controller, + key: key, + focusNode: node, + // The toolbar's paste button doesn't use the shortcut's action, so replace it too + contextMenuBuilder: + ( + _, + editableText, + ) => AdaptiveTextSelectionToolbar.buttonItems( + anchors: editableText + .contextMenuAnchors, + buttonItems: editableText + .contextMenuButtonItems + .map( + (item) => + item.type == + .paste + ? item.copyWith( + onPressed: () async { + editableText + .hideToolbar(); + if (!await pasteFiles()) { + item.onPressed + ?.call(); + } + }, + ) + : item, + ) + .toList(), + ), + // Images pasted from Android keyboards' clipboards are inserted as content + contentInsertionConfiguration: .new( + onContentInserted: (content) { + if (attachment != null) { + return; + } + + if (content.data + case final data?) { + attachBytes( + data, + "image.${content.mimeType.split("/").last}", + ).onError(showError); + } + }, + ), + ), + ), + ], + ), + ), + ), + ), + }, + ), + ), + IconButton( + onPressed: + attachment != null && attachment.$2 == null + ? null + : 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/composer_replacement.dart b/lib/widgets/composer/composer_replacement.dart new file mode 100644 index 0000000..ef6ff9f --- /dev/null +++ b/lib/widgets/composer/composer_replacement.dart @@ -0,0 +1,30 @@ +import "package:material_ui/material_ui.dart"; + +/// Replaces the composer's text field with something to send, like a sticker. +class const ComposerReplacement( + final Widget child, { + required final String removeTooltip, + required final VoidCallback onRemove, + final FocusOnKeyEventCallback? onKeyEvent, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) => Focus( + autofocus: true, + onKeyEvent: onKeyEvent, + child: Padding( + padding: .symmetric(vertical: 8), + child: Row( + spacing: 12, + children: [ + Expanded(child: child), + IconButton( + tooltip: removeTooltip, + onPressed: onRemove, + icon: Icon(Icons.close), + ), + ], + ), + ), + ); +} diff --git a/lib/widgets/composer/create_poll_sheet.dart b/lib/widgets/composer/create_poll_sheet.dart new file mode 100644 index 0000000..48f849d --- /dev/null +++ b/lib/widgets/composer/create_poll_sheet.dart @@ -0,0 +1,109 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/models/content/poll_start.dart"; + +class const CreatePollSheet({ + required final void Function(PollStart poll) onCreate, + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final questionController = useTextEditingController(); + final question = useValueListenable(questionController).text.trim(); + + // Keyed by ID, so fields keep their text when one above is removed + final nextId = useRef(2); + final answers = useState>(.new({0: "", 1: ""})); + final filledAnswers = answers.value.entries + .map( + (answer) => + PollAnswer(id: "${answer.key}", text: answer.value.trim()), + ) + .where((answer) => answer.text.isNotEmpty) + .toIList(); + + final allowMultiple = useState(false); + + final canCreate = question.isNotEmpty && filledAnswers.length >= 2; + + void submit() => onCreate( + .new( + question: question, + answers: filledAnswers, + maxSelections: allowMultiple.value ? filledAnswers.length : 1, + ), + ); + + return SheetLayout( + title: const Text("Create a Poll"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: canCreate ? submit : null, + child: const Text("Create"), + ), + ], + children: [ + TextField( + controller: questionController, + autofocus: true, + textCapitalization: .sentences, + decoration: const .new(labelText: "Question"), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: Column( + spacing: 12, + children: answers.value.entries + .mapIndexed( + (index, answer) => TextFormField( + key: ValueKey(answer.key), + initialValue: answer.value, + textCapitalization: .sentences, + onChanged: (text) => + answers.value = answers.value.add(answer.key, text), + decoration: .new( + labelText: "Answer ${index + 1}", + suffixIcon: answers.value.length > 2 + ? IconButton( + tooltip: "Remove answer", + icon: const Icon(Icons.close), + onPressed: () => answers.value = answers.value + .remove(answer.key), + ) + : null, + ), + ), + ) + .toList(), + ), + ), + Align( + alignment: .centerLeft, + child: TextButton.icon( + onPressed: answers.value.length < PollStart.maxAnswers + ? () => answers.value = answers.value.add(nextId.value++, "") + : null, + icon: const Icon(Icons.add), + label: const Text("Add Answer"), + ), + ), + SwitchListTile( + secondary: const Icon(Icons.checklist), + title: const Text("Allow multiple answers"), + subtitle: const Text("Voters can choose more than one"), + value: allowMultiple.value, + onChanged: (value) => allowMultiple.value = value, + ), + ], + ); + } +} diff --git a/lib/widgets/composer/overlays/emoji_overlay.dart b/lib/widgets/composer/overlays/emoji_overlay.dart new file mode 100644 index 0000000..bb4b88f --- /dev/null +++ b/lib/widgets/composer/overlays/emoji_overlay.dart @@ -0,0 +1,59 @@ +import "package:collection/collection.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/all_emoji.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/weighted_search.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/image_pack.dart"; + +class const EmojiOverlay( + final String query, { + required final String roomId, + required final void Function({required String id, required String name}) + addTag, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => ref + .watch(AllEmojiController.provider(roomId)) + .betterWhen( + data: (emojis) => ListView( + children: emojis + .map((element) => element.emojis) + .flattened + .search( + query, + (emoji) => [ + ...emoji.aliases.map((alias) => (alias, 3)), + ...emoji.tags.map((tag) => (tag, 2)), + (emoji.description, 1), + ], + ) + .map( + (emoji) => ListTile( + leading: SizedBox.square( + dimension: 28, + child: FittedBox(child: emoji.widget), + ), + title: Text(emoji.aliases.first), + subtitle: Text(emoji.description), + onTap: () => addTag( + id: emoji.value.startsWith("mxc://") + ? ImagePack.emojiMarkdown( + emoji.aliases.first, + emoji.value, + description: switch (emoji.data) { + PackImage(:final body) => body, + _ => null, + }, + ) + : emoji.value, + name: emoji.aliases.first, + ), + ), + ) + .toList(), + ), + ); +} diff --git a/lib/widgets/composer/overlays/room_overlay.dart b/lib/widgets/composer/overlays/room_overlay.dart new file mode 100644 index 0000000..c95db16 --- /dev/null +++ b/lib/widgets/composer/overlays/room_overlay.dart @@ -0,0 +1,60 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/via.dart"; +import "package:nexus/helpers/extensions/weighted_search.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; + +class const RoomOverlay( + final String query, { + required final void Function({required String id, required String name}) + addTag, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final rooms = ref.watch(RoomsController.provider); + return ListView( + children: rooms.values + .search( + query, + (room) => [ + (room.metadata?.name, 3), + (room.metadata?.canonicalAlias, 2), + (room.metadata?.id, 1), + ], + ) + .map((room) { + final name = + room.metadata?.name ?? + room.metadata?.canonicalAlias ?? + room.metadata?.id ?? + "Unknown Room"; + 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)).viaQuery; + addTag( + id: "[#$name](matrix:roomid/${room.metadata?.id.substring(1)}$vias)", + name: + (room.metadata?.canonicalAlias ?? room.metadata?.id) + ?.substring(1) + .split(":") + .first ?? + "", + ); + }, + ); + }) + .toList(), + ); + } +} diff --git a/lib/widgets/composer/overlays/tagger_overlay.dart b/lib/widgets/composer/overlays/tagger_overlay.dart new file mode 100644 index 0000000..f760e01 --- /dev/null +++ b/lib/widgets/composer/overlays/tagger_overlay.dart @@ -0,0 +1,39 @@ +import "package:material_ui/material_ui.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/widgets/composer/overlays/room_overlay.dart"; +import "package:nexus/widgets/composer/overlays/user_overlay.dart"; +import "package:nexus/widgets/composer/overlays/emoji_overlay.dart"; +import "package:nexus/widgets/common/loading.dart"; + +class const TaggerOverlay( + final String query, { + required final String roomId, + required final void Function({required String id, required String name}) + addTag, + required final String? triggerCharacter, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + return Padding( + padding: .all(8), + child: ClipRRect( + borderRadius: .all(.circular(12)), + child: Container( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + padding: .all(8), + child: Material( + color: Colors.transparent, + child: switch (triggerCharacter) { + "@" => UserOverlay(query, roomId: roomId, addTag: addTag), + "#" => RoomOverlay(query, addTag: addTag), + ":" => EmojiOverlay(query, roomId: roomId, addTag: addTag), + + _ => Loading(), + }, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/composer/overlays/user_overlay.dart b/lib/widgets/composer/overlays/user_overlay.dart new file mode 100644 index 0000000..eec15f5 --- /dev/null +++ b/lib/widgets/composer/overlays/user_overlay.dart @@ -0,0 +1,54 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/members_by_status.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/weighted_search.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; + +class const UserOverlay( + final String query, { + required final String roomId, + required final void Function({required String id, required String name}) + addTag, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => ref + .watch( + MembersByStatusController.provider(.new(roomId: roomId, status: .join)), + ) + .betterWhen( + data: (members) => ListView( + children: members + .search( + query, + (member) => [ + if (member.content case MembershipContent(:final displayName)) + (displayName, 2), + (member.stateKey, 1), + ], + ) + .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(), + ), + ); +} diff --git a/lib/widgets/composer/paste_files_action.dart b/lib/widgets/composer/paste_files_action.dart new file mode 100644 index 0000000..79bf4ea --- /dev/null +++ b/lib/widgets/composer/paste_files_action.dart @@ -0,0 +1,12 @@ +import "package:material_ui/material_ui.dart"; + +/// Overrides a text field's paste, to paste files and images with +/// [pasteFiles], falling back to pasting text if it returns false. +class PasteFilesAction(final Future Function() pasteFiles) + extends Action { + @override + Future invoke(PasteTextIntent intent) async { + final pasteText = callingAction; + if (!await pasteFiles()) pasteText?.invoke(intent); + } +} diff --git a/lib/widgets/composer/relation_preview.dart b/lib/widgets/composer/relation_preview.dart new file mode 100644 index 0000000..f319d74 --- /dev/null +++ b/lib/widgets/composer/relation_preview.dart @@ -0,0 +1,61 @@ +import "package:material_ui/material_ui.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/renderers/event_preview.dart"; + +class const RelationPreview( + final Event? relatedEvent, { + required final RelationType relationType, + required final VoidCallback onDismiss, + required final bool shouldMention, + required final VoidCallback toggleShouldMention, + super.key, +}) extends ConsumerWidget { + @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!.rowId, + roomId: relatedEvent!.roomId, + ), + ), + ), + + if (relationType == .reply) + TextButton( + onPressed: toggleShouldMention, + child: Text( + shouldMention ? "@On" : "@Off", + style: .new( + 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/composer/typing_indicator.dart b/lib/widgets/composer/typing_indicator.dart new file mode 100644 index 0000000..9cacb9c --- /dev/null +++ b/lib/widgets/composer/typing_indicator.dart @@ -0,0 +1,112 @@ +import "package:collection/collection.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/room_typing.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/hooks/tap_recognizer.dart"; +import "package:nexus/widgets/common/bouncing_dots.dart"; +import "package:nexus/widgets/users/user_span.dart"; + +class const TypingIndicator(final String roomId, {super.key}) + extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final typing = ref.watch(RoomTypingController.provider(roomId)); + + // Past three people, only the first two are named + final named = typing.length > 3 ? 2 : typing.length; + // One for each person who can be named + final recognizers = [ + useTapRecognizer(), + useTapRecognizer(), + useTapRecognizer(), + ]; + final subjects = [ + ...typing + .take(named) + .mapIndexed( + (index, typingUserId) => UserSpan( + context, + ref + .watch( + UserController.provider( + .new(roomId: roomId, userId: typingUserId), + ), + ) + .value + ?.displayName ?? + typingUserId.localpart, + userId: typingUserId, + roomId: roomId, + recognizer: recognizers[index], + style: TextStyle(fontWeight: .bold), + ), + ), + if (typing.length > named) + TextSpan(text: "${typing.length - named} others"), + ]; + + final spans = [ + ...subjects.expandIndexed( + (index, subject) => [ + if (index > 0) + TextSpan( + text: index < subjects.length - 1 + ? ", " + : subjects.length > 2 + ? ", and " + : " and ", + ), + subject, + ], + ), + TextSpan(text: subjects.length == 1 ? " is typing..." : " are typing..."), + ]; + + return AnimatedSwitcher( + duration: Durations.medium2, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: animation, + alignment: .bottomLeft, + child: child, + ), + ), + child: typing.isEmpty + ? const SizedBox.shrink() + : Align( + alignment: .bottomLeft, + child: Material( + color: theme.colorScheme.surfaceContainerHigh, + elevation: 1, + shape: const StadiumBorder(), + child: Padding( + padding: .symmetric(horizontal: 12, vertical: 4), + child: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + BouncingDots(color: theme.colorScheme.onSurfaceVariant), + Flexible( + child: Text.rich( + TextSpan(children: spans), + maxLines: 1, + overflow: .ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/divider_text.dart b/lib/widgets/divider_text.dart deleted file mode 100644 index ca78844..0000000 --- a/lib/widgets/divider_text.dart +++ /dev/null @@ -1,29 +0,0 @@ -import "package:flutter/material.dart"; - -class DividerText extends StatelessWidget { - final String text; - - const DividerText(this.text, {super.key}); - - @override - Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) => Row( - children: [ - SizedBox( - width: 16, - child: Divider(color: Theme.of(context).colorScheme.onSurface), - ), - ConstrainedBox( - constraints: BoxConstraints(maxWidth: constraints.maxWidth - 32), - child: Padding( - padding: const EdgeInsets.all(8), - child: Text(text, style: Theme.of(context).textTheme.labelLarge), - ), - ), - Expanded( - child: Divider(color: Theme.of(context).colorScheme.onSurface), - ), - ], - ), - ); -} diff --git a/lib/widgets/emoji/emoji_picker.dart b/lib/widgets/emoji/emoji_picker.dart new file mode 100644 index 0000000..fb834c9 --- /dev/null +++ b/lib/widgets/emoji/emoji_picker.dart @@ -0,0 +1,84 @@ +import "dart:async"; + +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_emoji_picker/material_emoji_picker.dart" as upstream; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/all_emoji.dart"; +import "package:nexus/controllers/image_packs.dart"; +import "package:nexus/controllers/recent_emoji.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/image_pack_emoji.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/image_pack.dart"; + +class const EmojiPicker({ + required final String roomId, + + /// Has the [shortcode] and [description] of images from packs. + required final FutureOr Function( + String value, { + String? shortcode, + String? description, + }) + onSelection, + final bool allowFreeText = false, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final recentEmoji = ref.watch(RecentEmojiController.provider); + + return ref + .watch(AllEmojiController.provider(roomId)) + .betterWhen( + data: (categories) { + final allEmoji = categories.expand((category) => category.emojis); + final customCategories = + ref + .watch(ImagePacksController.provider(roomId)) + .value + ?.map((pack) => pack.emojiCategory) + .nonNulls + .toIList() ?? + const .empty(); + + return upstream.EmojiPicker( + onSelection: (emoji) async { + final image = switch (emoji.data) { + final PackImage image => image, + _ => null, + }; + await onSelection( + emoji.value, + shortcode: image == null ? null : emoji.aliases.first, + description: image?.body, + ); + + await ref + .read(RecentEmojiController.provider.notifier) + .add(emoji.value) + .onError(showError); + }, + onFreeText: allowFreeText ? (text) => onSelection(text) : null, + prependCategories: IList([ + .new( + icon: Icon(Icons.history), + name: "Recent", + emojis: .new( + recentEmoji + .map( + (recent) => allEmoji.firstWhereOrNull( + (element) => element.value == recent.emoji, + ), + ) + .nonNulls, + ), + ), + ]).addAll(customCategories), + ); + }, + ); + } +} diff --git a/lib/widgets/emoji/mxc_emoji.dart b/lib/widgets/emoji/mxc_emoji.dart new file mode 100644 index 0000000..e8c4916 --- /dev/null +++ b/lib/widgets/emoji/mxc_emoji.dart @@ -0,0 +1,16 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/helpers/mxc_image.dart"; + +/// A custom emoji or sticker from an image pack, scaled to fit its parent. +class const MxcEmoji(final Uri url, {final String? label, super.key}) + extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => Image( + image: MxcImage(ref, .new(mxc: url)), + semanticLabel: label, + fit: .contain, + errorBuilder: (context, _, _) => + Icon(Icons.broken_image, color: Theme.of(context).colorScheme.error), + ); +} diff --git a/lib/widgets/emoji/sticker_picker.dart b/lib/widgets/emoji/sticker_picker.dart new file mode 100644 index 0000000..3222399 --- /dev/null +++ b/lib/widgets/emoji/sticker_picker.dart @@ -0,0 +1,47 @@ +import "dart:async"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_emoji_picker/material_emoji_picker.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/image_packs.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/image_pack_emoji.dart"; +import "package:nexus/models/content/image_pack.dart"; + +/// Stickers from the image packs available in [roomId], grouped by pack. +class const StickerPicker({ + required final String roomId, + required final FutureOr Function(String shortcode, PackImage image) + onSelection, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => ref + .watch(ImagePacksController.provider(roomId)) + .betterWhen( + data: (packs) { + final categories = packs + .map((pack) => pack.stickerCategory) + .nonNulls + .toIList(); + + return categories.isEmpty + ? Padding( + padding: .all(16), + child: Center(child: Text("No sticker packs available")), + ) + : EmojiPicker( + prependCategories: categories, + includeDefaultEmoji: false, + itemSize: 96, + searchHint: "Search stickers", + onSelection: (sticker) { + if (sticker.data case final PackImage image) { + return onSelection(sticker.aliases.first, image); + } + }, + ); + }, + ); +} diff --git a/lib/widgets/encryption/recovery_key_sheet.dart b/lib/widgets/encryption/recovery_key_sheet.dart new file mode 100644 index 0000000..36e4e09 --- /dev/null +++ b/lib/widgets/encryption/recovery_key_sheet.dart @@ -0,0 +1,145 @@ +import "dart:async"; + +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/helpers/launch_helper.dart"; +import "package:nexus/models/gomuks_exception.dart"; +import "package:nexus/models/recovery_key.dart"; + +/// Shows a newly generated recovery key, and once the user confirms they've +/// saved it, resets encryption with it. Pops with true if that succeeded. +class const RecoveryKeySheet( + final RecoveryKey recoveryKey, { + required final bool isReset, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final saved = useState(false); + final isLoading = useState(false); + final error = useState(null); + final needsApproval = useState(false); + final approvalUrl = useState(null); + final colorScheme = Theme.of(context).colorScheme; + + Future reset() async { + if (isLoading.value) return; + isLoading.value = true; + error.value = null; + try { + await ref + .read(ClientController.provider.notifier) + .resetEncryption(recoveryKey); + if (context.mounted) Navigator.of(context).pop(true); + } catch (resetError) { + if (resetError case GomuksException(statusCode: 401)) { + // Open the approval page straight away the first time. + if (!needsApproval.value) { + needsApproval.value = true; + final homeserver = ref + .read(ClientStateController.provider) + ?.homeserverUrl; + final url = homeserver == null + ? null + : await ref + .read(ClientController.provider.notifier) + .getCrossSigningResetUrl(.parse(homeserver)) + .onError((_, _) => null); + if (!context.mounted || url == null) return; + + approvalUrl.value = url; + await ref.read(LaunchHelper.provider).launchUrl(url); + } + } else { + error.value = resetError.toString(); + } + } finally { + if (context.mounted) isLoading.value = false; + } + } + + // Approval happens on a web page that can't redirect back, so keep retrying + // in the background until it goes through, or the sheet is closed. + useEffect(() { + if (!needsApproval.value) return null; + return Timer.periodic(const .new(seconds: 3), (_) => reset()).cancel; + }, [needsApproval.value]); + + return SheetLayout( + title: const Text("Save Your Recovery Key"), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text("Cancel"), + ), + TextButton( + onPressed: !saved.value || isLoading.value ? null : reset, + style: isReset + ? TextButton.styleFrom(foregroundColor: colorScheme.error) + : null, + child: Text( + needsApproval.value + ? "Try Again" + : isReset + ? "Reset" + : "Set Up", + ), + ), + ], + children: [ + const Text( + "This is your new recovery key. Store it somewhere safe, like a password manager. You'll need it to verify new sessions.", + ), + Card.filled( + child: ListTile( + title: SelectableText( + recoveryKey.recoveryKey, + style: const .new(fontFamily: "bundled-mono"), + ), + trailing: IconButton( + tooltip: "Copy recovery key", + onPressed: () => + Clipboard.setData(.new(text: recoveryKey.recoveryKey)), + icon: const Icon(Icons.copy), + ), + ), + ), + CheckboxListTile( + value: saved.value, + onChanged: (value) => saved.value = value ?? false, + title: const Text("I've saved my recovery key"), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: needsApproval.value + ? Column( + crossAxisAlignment: .stretch, + spacing: 8, + children: [ + const Text( + "Your homeserver needs you to approve resetting your identity in your account settings. Once you've approved it, come back here and the reset will finish automatically.", + ), + if (approvalUrl.value case final url?) + OutlinedButton.icon( + onPressed: () => + ref.read(LaunchHelper.provider).launchUrl(url), + icon: const Icon(Icons.open_in_new), + label: const Text("Approve in account settings"), + ), + ], + ) + : const SizedBox(width: .infinity), + ), + if (error.value case final message?) + Text(message, style: .new(color: colorScheme.error)), + ], + ); + } +} diff --git a/lib/widgets/encryption/reset_encryption_sheet.dart b/lib/widgets/encryption/reset_encryption_sheet.dart new file mode 100644 index 0000000..40b0074 --- /dev/null +++ b/lib/widgets/encryption/reset_encryption_sheet.dart @@ -0,0 +1,161 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/client_state.dart"; +import "package:nexus/widgets/encryption/recovery_key_sheet.dart"; + +class const ResetEncryptionSheet({ + /// Whether this is shown as a page rather than in a sheet, in which case it + /// can't be cancelled, and is replaced once encryption is set up. + final bool isPage = false, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final verificationState = ref.watch( + ClientStateController.provider.select( + (state) => state?.verificationState, + ), + ); + final isReset = verificationState?.hasCrossSigning ?? false; + + final usePassphrase = useState(false); + final passphraseController = useTextEditingController(); + final confirmationController = useTextEditingController(); + final passphrase = useValueListenable(passphraseController).text; + final confirmation = useValueListenable(confirmationController).text; + final mismatch = confirmation.isNotEmpty && confirmation != passphrase; + final isLoading = useState(false); + final error = useState(null); + final colorScheme = Theme.of(context).colorScheme; + + return SheetLayout( + title: Text(isReset ? "Reset Encryption Identity" : "Set Up Encryption"), + actions: [ + if (!isPage) + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: + isLoading.value || + (usePassphrase.value && + (passphrase.isEmpty || passphrase != confirmation)) + ? null + : () async { + isLoading.value = true; + error.value = null; + try { + final recoveryKey = await ref + .read(ClientController.provider.notifier) + .generateRecoveryKey( + usePassphrase.value ? passphrase : "", + ); + if (!context.mounted) return; + + final done = await context.showAdaptiveSheet( + (_) => RecoveryKeySheet(recoveryKey, isReset: isReset), + ); + if (done != true) return; + if (context.mounted && !isPage) Navigator.of(context).pop(); + + // Shown from the root navigator, because on the verify page, + // this is replaced by the chat once the session is verified. + navigatorKey.currentContext?.showAdaptiveSheet( + (context) => SheetLayout( + title: Text( + isReset ? "Identity Reset" : "Encryption Set Up", + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Done"), + ), + ], + children: [ + Text( + isReset + ? "Your encryption identity has been reset, and this session is verified. You'll need to verify your other sessions again." + : "Encryption is set up, and this session is verified. Use your recovery key to verify your other sessions.", + ), + ], + ), + ); + } catch (generateError) { + error.value = generateError.toString(); + } finally { + if (context.mounted) isLoading.value = false; + } + }, + child: Text(isReset ? "Continue" : "Set Up"), + ), + ], + children: [ + if (verificationState case VerificationState( + hasCrossSigning: true, + hasSecretStorage: false, + )) + const Text( + "Your account has an encryption identity, but no recovery key. If you're still signed in on another client, you can set up a recovery key there instead, then restart Nexus and use it to verify. Otherwise, continue with the reset:", + ), + Text( + isReset + ? "This replaces your encryption identity with a new one. Your other sessions will need to be verified again, and any messages you can't currently decrypt may become unreadable." + : "Set up encryption so you can verify your other sessions, and get back your encrypted messages if you lose access to this one.", + ), + Column( + children: [ + SwitchListTile( + secondary: const Icon(Icons.password), + title: const Text("Use a passphrase"), + subtitle: const Text("Optional, as well as your recovery key"), + value: usePassphrase.value, + onChanged: (value) => usePassphrase.value = value, + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: usePassphrase.value + ? Padding( + padding: const .only(top: 12), + child: Column( + crossAxisAlignment: .stretch, + spacing: 12, + children: [ + const Text( + "Your passphrase can be used to gain access to your E2EE messages, so make it long and unique. Your passphrase should ideally not be the same as your password.", + ), + TextField( + controller: passphraseController, + obscureText: true, + decoration: const .new(labelText: "Passphrase"), + ), + TextField( + controller: confirmationController, + obscureText: true, + decoration: .new( + labelText: "Confirm passphrase", + errorText: mismatch + ? "Passphrases don't match" + : null, + ), + ), + ], + ), + ) + : const SizedBox(width: .infinity), + ), + ], + ), + if (error.value case final message?) + Text(message, style: .new(color: colorScheme.error)), + ], + ); + } +} diff --git a/lib/widgets/error_dialog.dart b/lib/widgets/error_dialog.dart deleted file mode 100644 index b016a8b..0000000 --- a/lib/widgets/error_dialog.dart +++ /dev/null @@ -1,32 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flutter_riverpod/misc.dart"; - -class ErrorDialog extends ConsumerWidget { - final Object error; - final StackTrace? stackTrace; - final ProviderOrFamily? provider; - const ErrorDialog(this.error, this.stackTrace, {this.provider, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return AlertDialog( - title: Text("An Error Occurred"), - content: SingleChildScrollView( - child: SelectableText("$error\n\n$stackTrace"), - ), - actions: [ - if (provider != null) - TextButton( - onPressed: () => ref.invalidate(provider!), - child: const Text("Try Again"), - ), - TextButton( - onPressed: () => - Navigator.of(context).popUntil((route) => route.isFirst), - child: const Text("Go Back"), - ), - ], - ); - } -} diff --git a/lib/widgets/form_text_input.dart b/lib/widgets/form_text_input.dart deleted file mode 100644 index 21b2e5c..0000000 --- a/lib/widgets/form_text_input.dart +++ /dev/null @@ -1,83 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter/services.dart"; - -class FormTextInput extends StatelessWidget { - final List extraValidators; - final TextEditingController? controller; - final TextInputType keyboardType; - final String? initialValue; - final bool readOnly; - final bool obscure; - final String? title; - final int? minLines; - final int? maxLength; - final bool outlined; - final int? maxLines; - final bool capitalize; - final bool required; - final bool autocorrect; - final void Function()? onTap; - final Widget? trailing; - final InputBorder? border; - final List? formatters; - final bool autofocus; - - const FormTextInput({ - super.key, - this.border, - this.controller, - this.autofocus = false, - this.title, - this.obscure = false, - this.readOnly = false, - this.extraValidators = const [], - this.keyboardType = TextInputType.text, - this.initialValue, - this.minLines, - this.capitalize = false, - this.maxLength, - this.formatters, - this.maxLines = 1, - this.outlined = true, - this.trailing, - this.onTap, - this.autocorrect = true, - this.required = true, - }); - - @override - Widget build(BuildContext context) => TextFormField( - autofocus: autofocus, - controller: controller, - keyboardType: keyboardType, - readOnly: readOnly, - minLines: minLines, - maxLines: maxLines, - maxLength: maxLength, - inputFormatters: formatters, - textCapitalization: capitalize - ? TextCapitalization.sentences - : TextCapitalization.none, - initialValue: initialValue, - autocorrect: autocorrect, - obscureText: obscure, - onTap: onTap, - decoration: InputDecoration( - labelText: title, - border: border ?? (outlined ? null : const UnderlineInputBorder()), - suffixIcon: trailing, - ), - validator: (value) { - if ((value?.isEmpty ?? true) && required) { - return "This field is required"; - } - - for (final validator in extraValidators) { - final reason = validator(value!); - if (reason != null) return reason; - } - - return null; - }, - ); -} diff --git a/lib/widgets/chat_page/html/code_block.dart b/lib/widgets/html/code_block.dart similarity index 61% rename from lib/widgets/chat_page/html/code_block.dart rename to lib/widgets/html/code_block.dart index 80950ce..1881bc6 100644 --- a/lib/widgets/chat_page/html/code_block.dart +++ b/lib/widgets/html/code_block.dart @@ -1,34 +1,35 @@ import "dart:math"; -import "package:flutter/material.dart"; - -class CodeBlock extends StatelessWidget { - final String code; - final String lang; - const CodeBlock(this.code, {required this.lang, super.key}); +import "package:flutter/services.dart"; +import "package:material_ui/material_ui.dart"; +class const CodeBlock( + final String code, { + required final String lang, + super.key, +}) extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); return ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(16)), + borderRadius: .all(.circular(16)), child: ColoredBox( color: theme.colorScheme.surfaceContainerHighest, child: IntrinsicWidth( child: Column( children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: .spaceBetween, children: [ Padding( - padding: EdgeInsets.symmetric(horizontal: 8), + padding: .symmetric(horizontal: 8), child: Text( lang.substring(0, min(lang.length, 15)), - style: TextStyle(fontFamily: "monospace"), + style: .new(fontFamily: "bundled-mono"), ), ), TextButton.icon( - onPressed: () {}, + onPressed: () => Clipboard.setData(.new(text: code)), icon: Icon(Icons.copy), label: Text("Copy"), ), @@ -37,13 +38,13 @@ class CodeBlock extends StatelessWidget { ColoredBox( color: theme.colorScheme.surfaceContainerHigh, child: Container( - constraints: BoxConstraints(minWidth: 250), - padding: EdgeInsets.all(8), + constraints: .new(minWidth: 250), + padding: .all(8), child: SelectableText( code, minLines: 1, maxLines: 99, - style: TextStyle(fontFamily: "monospace"), + style: .new(fontFamily: "bundled-mono"), ), ), ), diff --git a/lib/widgets/html/html.dart b/lib/widgets/html/html.dart new file mode 100644 index 0000000..9236e9a --- /dev/null +++ b/lib/widgets/html/html.dart @@ -0,0 +1,170 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart"; +import "package:html/dom.dart" as dom; +import "package:nexus/helpers/extensions/get_link_info.dart"; +import "package:nexus/helpers/launch_helper.dart"; +import "package:nexus/helpers/mxc_image.dart"; +import "package:nexus/widgets/common/expandable_image.dart"; +import "package:nexus/widgets/html/mention_chip.dart"; +import "package:nexus/widgets/html/spoiler_text.dart"; +import "package:nexus/widgets/html/code_block.dart"; +import "package:nexus/widgets/html/quoted.dart"; + +class const Html( + final String html, { + final String? roomId, + final TextStyle? textStyle, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( + html, + buildAsync: false, + textStyle: textStyle, + customWidgetBuilder: (element) { + if (element.attributes.keys.contains("data-mx-profile-fallback")) { + return SizedBox.shrink(); + } + + if (element.attributes.keys.contains("data-mx-spoiler")) { + return InlineCustomWidget(child: SpoilerText(element.text)); + } + + final height = + int.tryParse(element.attributes["height"] ?? "") ?? + (element.attributes.keys.contains("data-mx-emoticon") ? 32 : null) ?? + 300; + final width = int.tryParse(element.attributes["width"] ?? ""); + final src = Uri.tryParse(element.attributes["src"] ?? ""); + + return switch (element.localName) { + "code" => + element.parent?.localName == "pre" + ? CodeBlock( + element.text, + lang: element.className.replaceAll("language-", ""), + ) + : InlineCustomWidget( + child: ClipRRect( + borderRadius: .all(.circular(4)), + child: Container( + padding: .all(8), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + child: SelectableText( + element.text, + style: .new(fontFamily: "bundled-mono"), + ), + ), + ), + ), + + "blockquote" => Quoted( + Html(element.innerHtml, textStyle: textStyle, roomId: roomId), + ), + + "font" => InlineCustomWidget( + child: Html( + (dom.Element.tag("span") + ..attributes.addAll({ + "data-mx-color": ?element.attributes["color"], + ...element.attributes, + }) + ..innerHtml = element.innerHtml) + .outerHtml, + textStyle: textStyle, + roomId: roomId, + ), + ), + + "a" => + element.attributes["href"]?.linkInfo == null + ? null + : InlineCustomWidget( + child: MentionChip(element.attributes["href"]!, roomId), + ), + + "img" => + src == null + ? SizedBox.shrink() + : InlineCustomWidget( + alignment: .middle, + child: ExpandableImage( + .new(mxc: src), + downloadFilename: src.pathSegments.last, + child: Image( + image: MxcImage(ref, .new(mxc: src)), + 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(), + ), + ), + ), + + // Allowed elements list + ("del" || + "h1" || + "h2" || + "h3" || + "h4" || + "h5" || + "h6" || + "p" || + "ul" || + "ol" || + "sup" || + "sub" || + "li" || + "b" || + "i" || + "u" || + "strong" || + "em" || + "s" || + "code" || + "hr" || + "br" || + "div" || + "table" || + "thead" || + "tbody" || + "tr" || + "th" || + "td" || + "caption" || + "pre" || + "span" || + "details" || + "summary") => + null, + + _ => SizedBox.shrink(), + }; + }, + customStylesBuilder: (element) => { + "width": "auto", + ...Map.fromEntries( + element.attributes + .mapTo?>( + (key, value) => switch (key) { + "data-mx-color" => .new("color", value), + "data-mx-bg-color" => .new("background-color", value), + _ => null, + }, + ) + .nonNulls, + ), + }, + onTapUrl: (url) => ref.read(LaunchHelper.provider).launchUrl(.parse(url)), + ); +} diff --git a/lib/widgets/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart new file mode 100644 index 0000000..d6c7c0d --- /dev/null +++ b/lib/widgets/html/mention_chip.dart @@ -0,0 +1,58 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/room_summary.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/get_link_info.dart"; +import "package:nexus/helpers/extensions/open_link_info.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/room_summary.dart"; + +class const MentionChip(final String content, final String? roomId, {super.key}) + extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final linkInfo = content.linkInfo; + final mention = linkInfo?.identifier; + final data = switch (mention?.characters.firstOrNull) { + "@" => + ref + .watch( + UserController.provider(.new(roomId: roomId, userId: mention!)), + ) + .whenOrNull(data: (data) => data), + + "#" || "!" => + ref + .watch( + RoomSummaryController.provider( + .new(roomIdOrAlias: mention!, via: linkInfo!.via), + ), + ) + .whenOrNull(data: (data) => data) ?? + RoomSummary(roomId: mention), + + _ => null, + }; + + return mention == null + ? SizedBox.shrink() + : InkWell( + onTap: () => ref.openLinkInfo(context, linkInfo!, roomId: roomId), + child: Text( + switch (data) { + RoomSummary summary => + "${(summary.name == null ? null : "#${summary.name}") ?? summary.canonicalAlias ?? summary.roomId}${linkInfo?.eventId == null ? "" : " > ${linkInfo?.eventId}"}", + MembershipContent membership => + membership.displayName == null + ? mention + : "@${membership.displayName}", + _ => mention, + }, + style: .new( + fontWeight: .bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} diff --git a/lib/widgets/html/quoted.dart b/lib/widgets/html/quoted.dart new file mode 100644 index 0000000..3157263 --- /dev/null +++ b/lib/widgets/html/quoted.dart @@ -0,0 +1,13 @@ +import "package:material_ui/material_ui.dart"; + +class const Quoted(final Widget child, {super.key}) extends StatelessWidget { + @override + Widget build(BuildContext context) => Container( + decoration: BoxDecoration( + border: Border( + left: .new(width: 4, color: Theme.of(context).dividerColor), + ), + ), + child: Padding(padding: .only(left: 8), child: child), + ); +} diff --git a/lib/widgets/chat_page/html/spoiler_text.dart b/lib/widgets/html/spoiler_text.dart similarity index 51% rename from lib/widgets/chat_page/html/spoiler_text.dart rename to lib/widgets/html/spoiler_text.dart index 9a42bff..e4a5977 100644 --- a/lib/widgets/chat_page/html/spoiler_text.dart +++ b/lib/widgets/html/spoiler_text.dart @@ -1,11 +1,7 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; -class SpoilerText extends HookWidget { - final String text; - - const SpoilerText({super.key, required this.text}); - +class const SpoilerText(final String text, {super.key}) extends HookWidget { @override Widget build(BuildContext context) { final revealed = useState(false); @@ -13,15 +9,15 @@ class SpoilerText extends HookWidget { return InkWell( onTap: () => revealed.value = !revealed.value, child: AnimatedContainer( - duration: const Duration(milliseconds: 100), - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + duration: Durations.short2, + padding: const .symmetric(horizontal: 4, vertical: 2), decoration: BoxDecoration( color: revealed.value ? Colors.transparent : Colors.blueGrey, - borderRadius: BorderRadius.circular(4), + borderRadius: .circular(4), ), child: Text( text, - style: TextStyle(color: revealed.value ? null : Colors.transparent), + style: .new(color: revealed.value ? null : Colors.transparent), ), ), ); diff --git a/lib/widgets/lazy_loading/message_avatar.dart b/lib/widgets/lazy_loading/message_avatar.dart new file mode 100644 index 0000000..95f987f --- /dev/null +++ b/lib/widgets/lazy_loading/message_avatar.dart @@ -0,0 +1,28 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/author.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/avatars/avatar_or_hash.dart"; + +class const MessageAvatar( + final Event event, { + final double height = 24, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => + switch (ref.watch(AuthorController.provider(event))) { + AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( + onTap: () => + context.showUserPopover(event.sender, roomId: event.roomId), + child: AvatarOrHash( + value.avatarUrl, + value.displayName ?? event.sender.localpart, + dimension: height, + ), + ), + _ => AvatarOrHash(null, event.sender.localpart, dimension: height), + }; +} diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart new file mode 100644 index 0000000..d7c91bd --- /dev/null +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -0,0 +1,58 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/author.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/helpers/extensions/displayname_color.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/content/membership.dart"; + +class const MessageDisplayname( + final Event event, { + final TextStyle? style, + final bool clickable = true, + final bool shouldWrap = true, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final color = ref.displaynameColor(event.roomId, event.sender); + + return switch (ref.watch(AuthorController.provider(event))) { + AsyncData(:final MembershipContent? value) || + AsyncLoading(:final value) || + AsyncError(:final value) => InkWell( + onTap: clickable + ? () => context.showUserPopover(event.sender, roomId: event.roomId) + : null, + child: switch ([ + Text( + value?.displayName ?? event.sender.localpart, + style: style ?? .new(color: color, fontWeight: .bold), + maxLines: 1, + overflow: .ellipsis, + ), + if (event.pmp != null) + Text( + "(via ${event.sender})", + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: color, fontWeight: .bold), + maxLines: 1, + overflow: .ellipsis, + ), + ]) { + final children when shouldWrap => Wrap( + spacing: 4, + crossAxisAlignment: .center, + children: children, + ), + final children => Row( + spacing: 4, + mainAxisSize: .min, + children: children.map((child) => Flexible(child: child)).toList(), + ), + }, + ), + }; + } +} diff --git a/lib/widgets/loading.dart b/lib/widgets/loading.dart deleted file mode 100644 index 9bb2858..0000000 --- a/lib/widgets/loading.dart +++ /dev/null @@ -1,14 +0,0 @@ -import "package:flutter/material.dart"; - -class Loading extends StatelessWidget { - final double? height; - const Loading({this.height, super.key}); - - @override - Widget build(BuildContext context) => Center( - child: Padding( - padding: EdgeInsets.all(16), - child: SizedBox(height: height, child: CircularProgressIndicator()), - ), - ); -} diff --git a/lib/widgets/location/location_map.dart b/lib/widgets/location/location_map.dart new file mode 100644 index 0000000..c9fb4b3 --- /dev/null +++ b/lib/widgets/location/location_map.dart @@ -0,0 +1,83 @@ +import "package:flutter_map/flutter_map.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:latlong2/latlong.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/launch_helper.dart"; + +class const LocationMap( + final LatLng center, { + final bool showMarker = true, + final bool interactive = false, + final double zoom = 15, + final MapController? controller, + final PositionCallback? onPositionChanged, + super.key, +}) extends ConsumerWidget { + /// The marker's icon, which points at the location from its bottom. + static Widget markerOf(BuildContext context) => Icon( + Icons.location_pin, + size: 40, + color: Theme.of(context).colorScheme.primary, + ); + + @override + Widget build(BuildContext context, WidgetRef ref) => FlutterMap( + mapController: controller, + options: MapOptions( + initialCenter: center, + initialZoom: zoom, + minZoom: 2, + maxZoom: 19, + onPositionChanged: onPositionChanged, + interactionOptions: InteractionOptions( + flags: interactive + ? InteractiveFlag.all & ~InteractiveFlag.rotate + : InteractiveFlag.none, + ), + ), + children: [ + TileLayer( + urlTemplate: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + // Required by OpenStreetMap's tile usage policy + userAgentPackageName: "nexus.federated.nexus", + ), + if (showMarker) + MarkerLayer( + markers: [ + Marker( + point: center, + width: 40, + height: 40, + alignment: .topCenter, + child: markerOf(context), + ), + ], + ), + // Required by OpenStreetMap + Align( + alignment: .bottomRight, + child: Material( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.8), + child: InkWell( + onTap: interactive + ? () => ref + .read(LaunchHelper.provider) + .launchUrl( + Uri.https("www.openstreetmap.org", "/copyright"), + ) + : null, + child: Padding( + padding: const .symmetric(horizontal: 4, vertical: 2), + child: Text( + "© OpenStreetMap", + style: Theme.of(context).textTheme.labelSmall, + maxLines: 1, + overflow: .ellipsis, + ), + ), + ), + ), + ), + ], + ); +} diff --git a/lib/widgets/location/location_message.dart b/lib/widgets/location/location_message.dart new file mode 100644 index 0000000..661b7dd --- /dev/null +++ b/lib/widgets/location/location_message.dart @@ -0,0 +1,68 @@ +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/location.dart"; +import "package:nexus/widgets/location/location_map.dart"; +import "package:nexus/widgets/location/location_viewer_sheet.dart"; + +class const LocationMessage(final LocationMessageContent content, {super.key}) + extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final point = content.point; + final description = content.description; + + if (point == null) { + return Wrap( + spacing: 8, + children: [ + Text( + "Invalid location:", + style: TextStyle(color: theme.colorScheme.error), + ), + Text(content.body), + ], + ); + } + + return Column( + crossAxisAlignment: .start, + spacing: 8, + children: [ + if (description != null) + Text(description, style: theme.textTheme.titleSmall), + ConstrainedBox( + constraints: const .new(maxWidth: 400), + child: AspectRatio( + aspectRatio: 3 / 2, + child: ClipRRect( + borderRadius: .circular(12), + child: Stack( + children: [ + LocationMap(point), + Positioned.fill( + child: Material( + type: .transparency, + child: InkWell( + onTap: () => context.showAdaptiveSheet( + enableDrag: false, + (_) => LocationViewerSheet( + point, + type: content.assetType, + description: description, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + Text(point.formatted, style: theme.textTheme.labelSmall), + ], + ); + } +} diff --git a/lib/widgets/location/location_picker_sheet.dart b/lib/widgets/location/location_picker_sheet.dart new file mode 100644 index 0000000..4fb61bf --- /dev/null +++ b/lib/widgets/location/location_picker_sheet.dart @@ -0,0 +1,157 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:flutter_map/flutter_map.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:latlong2/latlong.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/helpers/current_location_helper.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/location.dart"; +import "package:nexus/widgets/location/location_map.dart"; + +/// Chooses a location to share, either the user's own or a pin dropped on the +/// map. +class const LocationPickerSheet({ + required final void Function(LocationMessageContent location) onPick, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final mapController = useMemoized(MapController.new); + useEffect(() => mapController.dispose, [mapController]); + + // Not state, as it changes every frame while the map moves + final center = useRef(const LatLng(0, 0)); + + // Until the map is moved or the user is located, it's an arbitrary spot + final placed = useState(false); + // Whether the map is on the user's location, and its accuracy in meters + final own = useState<({double? accuracy})?>(null); + final locating = useState(false); + final error = useState(null); + final descriptionController = useTextEditingController(); + + Future locate({bool askPermission = true}) async { + locating.value = true; + error.value = null; + try { + final found = await ref + .read(CurrentLocationHelper.provider) + .get(askPermission: askPermission); + // Doesn't move away from a spot the user chose while it was finding them + if (found == null || + !context.mounted || + (!askPermission && placed.value)) { + return; + } + + final (point, accuracy) = found; + center.value = point; + mapController.move(point, 16); + own.value = (accuracy: accuracy); + placed.value = true; + } catch (caught) { + if (context.mounted && askPermission) error.value = caught.toString(); + } finally { + if (context.mounted) locating.value = false; + } + } + + // Starts at the user's location if they've already allowed it, except on + // Linux, where checking would ask + useEffect(() { + locate(askPermission: false); + return null; + }, []); + + void share() { + final description = descriptionController.text.trim(); + final self = own.value != null; + final geoUri = GeoUri.fromLatLng( + center.value, + uncertainty: own.value?.accuracy, + ); + + onPick( + .new( + body: + "${description.isEmpty ? (self ? "My location" : "Location") : description}: $geoUri", + geoUri: geoUri, + location: .new( + uri: geoUri, + description: description.isEmpty ? null : description, + ), + asset: .new(type: self ? .self : .pin), + ), + ); + } + + return SheetLayout( + title: const Text("Share Location"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: placed.value ? share : null, + child: const Text("Share"), + ), + ], + children: [ + SizedBox( + height: 300, + child: ClipRRect( + borderRadius: .circular(12), + child: Stack( + children: [ + LocationMap( + center.value, + zoom: 2, + interactive: true, + showMarker: false, + controller: mapController, + onPositionChanged: (camera, hasGesture) { + center.value = camera.center; + if (hasGesture) { + own.value = null; + placed.value = true; + } + }, + ), + IgnorePointer( + child: Center( + child: Transform.translate( + offset: const .new(0, -20), + child: LocationMap.markerOf(context), + ), + ), + ), + ], + ), + ), + ), + OutlinedButton.icon( + onPressed: locating.value ? null : locate, + icon: locating.value + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.my_location), + label: const Text("Use current location"), + ), + if (error.value case final error?) + Text( + error, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + TextField( + controller: descriptionController, + textCapitalization: .sentences, + decoration: const .new(labelText: "Description (optional)"), + ), + ], + ); + } +} diff --git a/lib/widgets/location/location_viewer_sheet.dart b/lib/widgets/location/location_viewer_sheet.dart new file mode 100644 index 0000000..ca74ab0 --- /dev/null +++ b/lib/widgets/location/location_viewer_sheet.dart @@ -0,0 +1,43 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:latlong2/latlong.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/helpers/launch_helper.dart"; +import "package:nexus/models/location.dart"; +import "package:nexus/widgets/location/location_map.dart"; + +class const LocationViewerSheet( + final LatLng point, { + final LocationAssetType type = .self, + final String? description, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => SheetLayout( + title: Text( + description ?? (type == .self ? "Shared Location" : "Location"), + ), + actions: [ + TextButton( + onPressed: () => ref + .read(LaunchHelper.provider) + .launchUrl(GeoUri.openStreetMap(point)), + child: const Text("Open in OpenStreetMap"), + ), + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Close"), + ), + ], + children: [ + SizedBox( + height: 400, + child: ClipRRect( + borderRadius: .circular(12), + child: LocationMap(point, interactive: true), + ), + ), + SelectableText(point.formatted), + ], + ); +} diff --git a/lib/widgets/pages/chat.dart b/lib/widgets/pages/chat.dart new file mode 100644 index 0000000..c5d704f --- /dev/null +++ b/lib/widgets/pages/chat.dart @@ -0,0 +1,105 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/init_complete.dart"; +import "package:nexus/controllers/link_request.dart"; +import "package:nexus/helpers/extensions/open_link_info.dart"; +import "package:nexus/main.dart"; +import "package:nexus/controllers/selected_room.dart"; +import "package:nexus/controllers/notification_request.dart"; +import "package:nexus/widgets/common/appbar.dart"; +import "package:nexus/widgets/sidebar/sidebar.dart"; +import "package:nexus/widgets/common/sync_status_banner.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:nexus/widgets/common/loading.dart"; + +class const ChatPage({super.key}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final scaffoldKey = useMemoized(GlobalKey.new); + final initComplete = ref.watch(InitCompleteController.provider); + final notificationRequest = ref.watch( + NotificationRequestController.provider, + ); + + // On mobile, open the drawer so the sidebar can show the tapped notification + useEffect(() { + if (notificationRequest == null || !initComplete) return null; + + WidgetsBinding.instance.addPostFrameCallback((_) { + final scaffold = scaffoldKey.currentState; + if (scaffold != null && scaffold.hasDrawer && !scaffold.isDrawerOpen) { + scaffold.openDrawer(); + } + }); + return null; + }, [notificationRequest, initComplete]); + + final linkRequest = ref.watch(LinkRequestController.provider); + + // Open Matrix links the OS launched Nexus with, once sync has completed + useEffect(() { + if (linkRequest == null || !initComplete) return null; + + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(LinkRequestController.provider.notifier).consume(); + navigatorKey.currentState?.popUntil((route) => route.isFirst); + final scaffoldContext = scaffoldKey.currentContext; + if (scaffoldContext == null) return; + + ref.openLinkInfo(scaffoldContext, linkRequest).onError(showError); + }); + return null; + }, [linkRequest, initComplete]); + + return LayoutBuilder( + builder: (context, constraints) { + final isDesktop = context.isDesktop; + final showMembersByDefault = constraints.maxWidth > 1000; + final roomId = ref.watch(SelectedRoomController.provider); + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) scaffoldKey.currentState?.openDrawer(); + }, + child: SafeArea( + child: Scaffold( + key: scaffoldKey, + appBar: initComplete ? null : Appbar(), + body: initComplete + ? Row( + children: [ + if (isDesktop) Sidebar(), + Expanded( + child: RoomChat( + key: ValueKey(roomId), + roomId: roomId, + isDesktop: isDesktop, + showMembersByDefault: showMembersByDefault, + ), + ), + ], + ) + : Column( + children: [ + const SyncStatusBanner(), + Expanded( + child: Center( + child: Column( + mainAxisSize: .min, + children: [Loading(), Text("Syncing...")], + ), + ), + ), + ], + ), + drawer: isDesktop || !initComplete ? null : Sidebar(), + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/pages/explore.dart b/lib/widgets/pages/explore.dart new file mode 100644 index 0000000..3cd77ea --- /dev/null +++ b/lib/widgets/pages/explore.dart @@ -0,0 +1,338 @@ +import "dart:math"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/room_directory.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/main.dart"; +import "package:nexus/helpers/extensions/room_summary_display.dart"; +import "package:nexus/widgets/rooms/add_directory_server_sheet.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; +import "package:nexus/widgets/common/divider_text.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; +import "package:nexus/widgets/common/m3e_rail_theme.dart"; +import "package:nexus/widgets/rooms/room_directory_card.dart"; + +class const ExplorePage({super.key}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final ownServer = ref.watch( + ClientStateController.provider.select( + (state) => state?.userId?.serverName, + ), + ); + final savedServers = ref + .watch( + SettingsController.provider.select( + (settings) => settings.value?.directoryServers, + ), + ) + ?.where((server) => server != ownServer) + .toIList(); + + // Null is the user's own server + final selected = useState(null); + final servers = IList([null, ...?savedServers]); + String labelOf(String? server) => server ?? ownServer ?? "Home"; + IconData iconOf(String? server) => server == null ? Icons.home : Icons.dns; + + final current = servers[max(0, servers.indexOf(selected.value))]; + final provider = RoomDirectoryController.provider(current); + final loadMore = RoomDirectoryController.loadMoreMutation(current); + final loadingMore = ref.watch(loadMore).isPending; + final directory = ref.watch(provider); + final theme = Theme.of(context); + + final via = IList([?current ?? ownServer]); + + Future addServer() async { + final server = await context.showAdaptiveSheet( + (_) => AddDirectoryServerSheet( + existing: .new([?ownServer, ...servers.nonNulls]), + ), + ); + if (server == null) return; + + final settings = await ref.read(SettingsController.provider.future); + await ref + .read(SettingsController.provider.notifier) + .set( + settings.copyWith( + directoryServers: settings.directoryServers.add(server), + ), + ); + if (context.mounted) selected.value = server; + } + + Future removeServer(String server) async { + selected.value = null; + final settings = await ref.read(SettingsController.provider.future); + await ref + .read(SettingsController.provider.notifier) + .set( + settings.copyWith( + directoryServers: settings.directoryServers.remove(server), + ), + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 700; + + final searchBar = SearchAnchor.bar( + barHintText: "Search...", + isFullScreen: !isWide, + suggestionsBuilder: (suggestionsContext, controller) async { + final text = controller.text.trim(); + if (text.isEmpty) return const []; + + // Each search hits the server, so wait for the user to stop typing + await Future.delayed(Durations.long2); + if (controller.text.trim() != text) return const []; + + try { + final directory = await ref + .read(ClientController.provider.notifier) + .getRoomDirectory( + .new( + server: current, + limit: 20, + filter: .new(genericSearchTerm: text), + ), + ); + return directory.rooms.map( + (summary) => Consumer( + builder: (_, ref, _) { + final joined = ref.watch( + RoomsController.provider.select( + (rooms) => rooms.containsKey(summary.roomId), + ), + ); + + return ListTile( + onTap: () { + Navigator.of(suggestionsContext).pop(); + controller.text = ""; + + context.showAdaptiveSheet( + (_) => RoomSummarySheet( + summary, + via: joined ? null : via, + ), + ); + }, + leading: AvatarOrHash( + summary.avatarUrl, + summary.displayName, + fallback: Icon(summary.icon), + ), + title: Text(summary.displayName), + subtitle: Text( + [ + summary.details, + if (joined) "Joined", + ].where((part) => part.isNotEmpty).join(" · "), + ), + ); + }, + ), + ); + } catch (error) { + return [ + ListTile( + leading: const Icon(Icons.error_outline), + title: const Text("Couldn't search this server"), + subtitle: Text(error.toString()), + ), + ]; + } + }, + ); + + final results = CustomScrollView( + slivers: [ + SliverPadding( + padding: const .all(16), + sliver: SliverList.list( + children: [ + if (!isWide) + Padding( + padding: const .only(bottom: 12), + child: SingleChildScrollView( + scrollDirection: .horizontal, + child: Row( + spacing: 8, + children: [ + ...servers.map( + (server) => ChoiceChip( + avatar: Icon(iconOf(server)), + label: Text(labelOf(server)), + selected: current == server, + onSelected: (_) => selected.value = server, + ), + ), + ActionChip( + avatar: const Icon(Icons.add), + label: const Text("Add server"), + onPressed: () => addServer().onError(showError), + ), + ], + ), + ), + ), + searchBar, + const SizedBox(height: 16), + Text(switch (directory.value?.totalRoomCountEstimate) { + final count? when count > 0 => "Popular Rooms ($count)", + _ => "Popular Rooms", + }, style: theme.textTheme.titleLarge), + ], + ), + ), + ...switch (directory) { + AsyncData(:final value) when value.rooms.isEmpty => [ + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisSize: .min, + children: [ + Icon( + Icons.search_off, + size: 48, + color: theme.colorScheme.onSurface, + ), + const SizedBox(height: 12), + Text( + "No rooms found", + style: theme.textTheme.headlineSmall, + ), + ], + ), + ), + ), + ], + AsyncData(:final value) || AsyncLoading(:final value?) => [ + SliverPadding( + padding: const .symmetric(horizontal: 16), + sliver: SliverLayoutBuilder( + builder: (_, constraints) => SliverGrid.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: max( + 1, + (constraints.crossAxisExtent + 12) ~/ (280 + 12), + ), + mainAxisExtent: 210, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: value.rooms.length, + itemBuilder: (_, index) => + RoomDirectoryCard(value.rooms[index], via: via), + ), + ), + ), + SliverPadding( + padding: const .all(16), + sliver: SliverToBoxAdapter( + child: value.nextBatch == null + ? null + : Center( + child: TextButton( + onPressed: loadingMore + ? null + : () => loadMore + .run( + ref, + (transaction) => transaction + .get(provider.notifier) + .loadMore(), + ) + .onError(showError), + child: const Text("Load more"), + ), + ), + ), + ), + ], + AsyncError(:final error, :final stackTrace) => [ + SliverFillRemaining( + hasScrollBody: false, + child: ErrorDialog(error, stackTrace, provider: provider), + ), + ], + AsyncLoading() => [ + const SliverFillRemaining( + hasScrollBody: false, + child: Loading(), + ), + ], + }, + ], + ); + + return Scaffold( + appBar: AppBar( + title: const Text("Explore Rooms"), + actionsPadding: const .symmetric(horizontal: 12), + actions: [ + if (current case final server?) + IconButton( + tooltip: "Remove server", + onPressed: () => removeServer(server).onError(showError), + icon: const Icon(Icons.delete_outline), + ), + ], + ), + body: isWide + ? Row( + children: [ + M3ERailTheme( + builder: (_) => NavigationRailM3E( + type: .alwaysExpand, + scrollable: true, + fab: .new( + icon: const Icon(Icons.add), + label: "Add server", + tooltip: "Add a server to browse", + onPressed: () => addServer().onError(showError), + ), + sections: [ + .new( + header: const DividerText("Servers"), + destinations: servers + .map( + (server) => NavigationRailM3EDestination( + icon: Icon(iconOf(server)), + label: labelOf(server), + ), + ) + .toList(), + ), + ], + selectedIndex: servers.indexOf(current), + onDestinationSelected: (index) => + selected.value = servers[index], + ), + ), + const VerticalDivider(), + Expanded(child: results), + ], + ) + : results, + ); + }, + ); + } +} diff --git a/lib/widgets/pages/select_server.dart b/lib/widgets/pages/select_server.dart new file mode 100644 index 0000000..a44e4b8 --- /dev/null +++ b/lib/widgets/pages/select_server.dart @@ -0,0 +1,243 @@ +import "package:app_links/app_links.dart"; +import "package:material_ui/material_ui.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/auth_url.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_id.dart"; +import "package:nexus/helpers/extensions/show_error_snack_bar.dart"; +import "package:nexus/helpers/launch_helper.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/gomuks_exception.dart"; +import "package:nexus/models/homeserver.dart"; +import "package:nexus/models/oauth_auth_code_response.dart"; +import "package:nexus/widgets/pages/settings.dart"; +import "package:nexus/widgets/common/account_switcher.dart"; +import "package:nexus/widgets/common/appbar.dart"; +import "package:nexus/widgets/common/divider_text.dart"; + +class const SelectServerPage({super.key}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + final launch = ref.read(LaunchHelper.provider).launchUrl; + + final isLoading = useState(false); + final homeserverUrl = useTextEditingController(); + + Future setHomeserver(Uri? newHomeserver) async { + if (newHomeserver == null) { + ScaffoldMessenger.of(context).showErrorSnackBar( + "Failed to parse homeserver URL. Are you sure you typed it correctly?", + ); + } + isLoading.value = true; + + try { + if (newHomeserver?.hasScheme == false) { + newHomeserver = Uri.https(newHomeserver!.path); + } + + final newUrl = await ref + .read(ClientController.provider.notifier) + .discoverHomeserver(newHomeserver!); + + if (context.mounted) { + Future tryLogin(Uri url) async { + final OAuthAuthCodeResponse codeResponse; + try { + codeResponse = await ref.watch( + AuthUrlController.provider(url).future, + ); + } on GomuksException catch (error) { + if (error.errorCode != "M_UNRECOGNIZED") rethrow; + if (context.mounted) { + ScaffoldMessenger.of(context).showErrorSnackBar( + "${url.host} doesn't support OAuth login, which Nexus requires. Try a different homeserver, or ask your homeserver's admin to enable OAuth.", + ); + } + return; + } + + if (!context.mounted) return; + + await ref.read(LaunchHelper.provider).launchUrl(codeResponse.url); + + AppLinks().uriLinkStream.listen((encodedUri) async { + final state = encodedUri.queryParameters["state"]; + if (state != codeResponse.state) return; + isLoading.value = true; + + try { + final code = encodedUri.queryParameters["code"]; + + if (code != null && context.mounted) { + await ref + .read(ClientController.provider.notifier) + .exchangeToken( + .new( + homeserverUrl: url, + codeVerifier: codeResponse.codeVerifier, + redirectUri: .new( + scheme: "nexus.federated.nexus", + path: "/", + ), + code: code, + clientId: await ref.read( + ClientIdController.provider(url).future, + ), + ), + ); + } + } catch (error, stackTrace) { + showError(error, stackTrace); + rethrow; + } finally { + isLoading.value = false; + } + }); + } + + if (newUrl == null) { + ScaffoldMessenger.of(context).showErrorSnackBar( + "Homeserver verification failed.", + action: ( + label: "Attempt log in anyways", + onPressed: () => tryLogin(newHomeserver!).onError(showError), + ), + ); + } else { + await tryLogin(newUrl); + } + } + } catch (error, stackTrace) { + showError(error, stackTrace); + rethrow; + } finally { + isLoading.value = false; + } + } + + return Scaffold( + appBar: Appbar( + actions: .new([ + const AccountSwitcher(onlyWithOthers: true), + IconButton( + onPressed: () => + showDialog(context: context, builder: (_) => SettingsPage()), + icon: Icon(Icons.settings), + ), + ]), + ), + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: .new(maxWidth: 600), + child: ListView( + padding: .symmetric(vertical: 8, horizontal: 12), + children: [ + Row( + spacing: 12, + children: [ + SvgPicture.asset("assets/bundled/icon.svg", width: 128), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + Text("Nexus", style: theme.textTheme.displayMedium), + Text( + "A Simple Matrix Client", + style: theme.textTheme.titleLarge, + 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, + keyboardType: .url, + 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", + ), + // TODO: Add back unredacted when they have MAS, find where new favicon is + // .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/widgets/pages/settings.dart b/lib/widgets/pages/settings.dart new file mode 100644 index 0000000..c205d3e --- /dev/null +++ b/lib/widgets/pages/settings.dart @@ -0,0 +1,19 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/settings_sections.dart"; +import "package:nexus/helpers/extensions/show_about_sheet.dart"; +import "package:nexus/widgets/settings/settings_layout.dart"; + +class const SettingsPage({super.key}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => SettingsLayout( + title: "Settings", + sectionsProvider: SettingsSectionsController.provider, + actions: [ + IconButton( + onPressed: () => context.showAboutSheet(ref), + icon: Icon(Icons.info_outline), + ), + ], + ); +} diff --git a/lib/widgets/pages/settings_category.dart b/lib/widgets/pages/settings_category.dart new file mode 100644 index 0000000..0efa68e --- /dev/null +++ b/lib/widgets/pages/settings_category.dart @@ -0,0 +1,86 @@ +import "dart:async"; + +import "package:collection/collection.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:hooks_riverpod/misc.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:nexus/models/settings_category.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/widgets/common/highlight_wrapper.dart"; +import "package:super_sliver_list/super_sliver_list.dart"; + +class const SettingsCategoryPage( + final int index, { + required final ProviderListenable< + AsyncValue>> + > + sectionsProvider, + final int? initialHighlight, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final highlight = useState(initialHighlight); + final listController = useRef(ListController()); + final scrollController = useScrollController(); + + useEffect(() { + if (initialHighlight == null) return null; + Timer? timer; + + void listener() => WidgetsBinding.instance.addPostFrameCallback((_) { + if (!listController.value.isAttached) return; + + listController.value.animateToItem( + index: initialHighlight!, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => Durations.extralong1, + curve: (_) => Curves.easeInOut, + ); + timer = Timer(Durations.extralong4, () { + highlight.value = null; + }); + listController.value.removeListener(listener); + }); + + listController.value.addListener(listener); + return timer?.cancel; + }, []); + + return ref + .watch(sectionsProvider) + .betterWhen( + skipLoadingOnReload: true, + data: (sections) => Scaffold( + appBar: AppBar( + title: Text(sections.values.flattenedToList[index].title), + ), + body: SafeArea( + child: SuperListView( + controller: scrollController, + listController: listController.value, + padding: .symmetric(vertical: 12, horizontal: 8), + children: sections.values.flattenedToList[index].settings + .mapIndexed( + (index, setting) => Padding( + padding: .only(bottom: 4), + child: HighlightWrapper( + setting.builder( + setting.title, + setting.description, + setting.icon, + ), + isHighlighted: highlight.value == index, + ), + ), + ) + .toList(), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/pages/verify.dart b/lib/widgets/pages/verify.dart new file mode 100644 index 0000000..aa342cd --- /dev/null +++ b/lib/widgets/pages/verify.dart @@ -0,0 +1,131 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/models/client_state.dart"; +import "package:nexus/widgets/common/loading.dart"; +import "package:nexus/widgets/pages/settings.dart"; +import "package:nexus/widgets/common/account_switcher.dart"; +import "package:nexus/widgets/common/appbar.dart"; +import "package:nexus/helpers/required_validator_helper.dart"; +import "package:nexus/widgets/encryption/reset_encryption_sheet.dart"; + +class const VerifyPage({super.key}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final passphraseController = useTextEditingController(); + final isLoading = useState(false); + final inputError = useState(null); + final formKey = useRef(GlobalKey()); + + final verificationState = ref.watch( + ClientStateController.provider.select( + (state) => state?.verificationState, + ), + ); + + Future verify() async { + if (isLoading.value) return; + + if (formKey.value.currentState?.validate() != true) { + return; + } + + isLoading.value = true; + + try { + await ref + .read(ClientController.provider.notifier) + .verify(passphraseController.text); + } catch (error) { + inputError.value = error.toString(); + } finally { + if (context.mounted) { + isLoading.value = false; + } + } + } + + return Scaffold( + appBar: Appbar( + actions: .new([ + const AccountSwitcher(onlyWithOthers: true), + IconButton( + onPressed: () => + showDialog(context: context, builder: (_) => SettingsPage()), + icon: Icon(Icons.settings), + ), + ]), + ), + body: switch (verificationState) { + null || VerificationState(stateChecked: false) => const Loading(), + + // Nothing to verify yet, so go straight to setup. + VerificationState(hasSecretStorage: false) => Dialog( + insetPadding: const .symmetric(vertical: 12, horizontal: 32), + child: ConstrainedBox( + constraints: const .new(maxWidth: 450), + child: const ResetEncryptionSheet(isPage: true), + ), + ), + + _ => AlertDialog( + insetPadding: .symmetric(vertical: 12, horizontal: 32), + scrollable: true, + 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: () {}, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: isLoading.value + ? null + : () { + context.showAdaptiveSheet( + (_) => const ResetEncryptionSheet(), + ); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text("Reset Identity"), + ), + TextButton( + onPressed: isLoading.value ? null : verify, + child: Text("Verify"), + ), + ], + ), + }, + ); + } +} diff --git a/lib/widgets/players/audio.dart b/lib/widgets/players/audio.dart new file mode 100644 index 0000000..898e89b --- /dev/null +++ b/lib/widgets/players/audio.dart @@ -0,0 +1,64 @@ +import "dart:async"; + +import "package:material_ui/material_ui.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/controllers/client.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; +import "package:nexus/models/info/audio.dart"; +import "package:nexus/widgets/players/player_controls.dart"; + +class const AudioPlayer( + final Uri uri, + final AudioInfo? info, { + final bool encrypted = false, + final String? filename, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = useMemoized( + () => Player(configuration: .new(bufferSize: 128 * 1024 * 1024)), + ); + final loaded = useState(false); + + useEffect(() { + scheduleMicrotask(() async { + final audio = await ref + .read(ClientController.provider.notifier) + .downloadMedia(.new(mxc: uri, encrypted: encrypted)); + if (!context.mounted) return; + await player.open(Media(audio.path), play: false); + if (context.mounted) loaded.value = true; + }); + + return player.dispose; + }, []); + + return Card( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: Padding( + padding: .symmetric(horizontal: 8), + child: PlayerControls( + player, + loading: !loaded.value, + size: info?.size, + actions: .new([ + SizedBox(width: 4), + IconButton( + onPressed: () => context.saveAttachment( + ref, + uri, + encrypted: encrypted, + filename: filename, + ), + tooltip: "Download", + icon: Icon(Icons.download), + ), + ]), + ), + ), + ); + } +} diff --git a/lib/widgets/players/player_controls.dart b/lib/widgets/players/player_controls.dart new file mode 100644 index 0000000..6f54afa --- /dev/null +++ b/lib/widgets/players/player_controls.dart @@ -0,0 +1,116 @@ +import "dart:math"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:media_kit/media_kit.dart"; +import "package:nexus/helpers/extensions/size_to_string.dart"; + +/// Play/pause, position and seeking controls for a media_kit [Player]. +class const PlayerControls( + final Player player, { + final bool loading = false, + final Color? color, + final int? size, + final IList actions = const .empty(), + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final playing = useStream( + player.stream.playing, + initialData: player.state.playing, + ).requireData; + final buffering = useStream( + player.stream.buffering, + initialData: player.state.buffering, + ).requireData; + final position = useStream( + player.stream.position, + initialData: player.state.position, + ).requireData; + final duration = useStream( + player.stream.duration, + initialData: player.state.duration, + ).requireData; + + 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"; + } + + final sizeSuffix = size == null ? "" : " (${size!.sizeAsString})"; + + final textStyle = Theme.of(context).textTheme.bodySmall + ?.copyWith(color: color); + final max = duration.inMilliseconds <= 0 + ? 1.0 + : duration.inMilliseconds.toDouble(); + + final playButton = loading || buffering + ? SizedBox.square( + dimension: 48, + child: CircularProgressIndicator(padding: .all(12), color: color), + ) + : IconButton( + onPressed: player.playOrPause, + color: color, + tooltip: playing ? "Pause" : "Play", + icon: Icon(playing ? Icons.pause_circle : Icons.play_circle), + ); + + Slider slider({EdgeInsetsGeometry? padding}) => .new( + padding: padding, + max: max, + value: min(position.inMilliseconds.toDouble(), max), + onChanged: loading + ? null + : (value) => player.seek(.new(milliseconds: value.toInt())), + ); + + return LayoutBuilder( + builder: (context, constraints) => constraints.maxWidth < 300 + ? Column( + mainAxisSize: .min, + children: [ + SizedBox(height: 12), + slider(padding: const .symmetric(horizontal: 16)), + Row( + children: [ + playButton, + Expanded( + child: Text( + "${format(position)} / ${format(duration)}$sizeSuffix", + style: textStyle, + maxLines: 1, + overflow: .ellipsis, + ), + ), + ...actions, + ], + ), + ], + ) + : IntrinsicHeight( + child: Row( + children: [ + playButton, + SizedBox(width: 8), + Text(format(position), style: textStyle), + Expanded(child: slider()), + Text("${format(duration)}$sizeSuffix", style: textStyle), + ...actions, + ], + ), + ), + ); + } +} diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart new file mode 100644 index 0000000..9a4bd26 --- /dev/null +++ b/lib/widgets/players/video.dart @@ -0,0 +1,141 @@ +import "dart:async"; + +import "package:material_ui/material_ui.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/save_attachment.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/widgets/players/player_controls.dart"; + +class const VideoPlayer( + final Uri uri, + final VideoInfo? info, { + final bool encrypted = false, + final String? filename, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = useMemoized( + () => Player(configuration: .new(bufferSize: 128 * 1024 * 1024)), + ); + final controller = useMemoized(() => VideoController(player)); + final loaded = useState(false); + + useEffect(() { + scheduleMicrotask(() async { + final video = await ref + .read(ClientController.provider.notifier) + .downloadMedia(.new(mxc: uri, encrypted: encrypted)); + if (!context.mounted) return; + await player.open(Media(video.path), play: false); + if (context.mounted) loaded.value = true; + }); + + return player.dispose; + }, []); + + final controls = useCallback( + (VideoState _) => + // Until media_kit moves to material_ui, this Material is needed. + Material( + type: .transparency, + child: ValueListenableBuilder( + valueListenable: loaded, + builder: (_, loaded, _) => Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: .opaque, + onTap: loaded ? player.playOrPause : null, + ), + ), + Positioned( + top: 4, + right: 4, + child: IconButton( + onPressed: () => context.saveAttachment( + ref, + uri, + encrypted: encrypted, + filename: filename, + ), + color: Colors.white, + style: IconButton.styleFrom( + backgroundColor: Colors.black38, + ), + tooltip: "Download", + icon: Icon(Icons.download), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: .bottomCenter, + end: .topCenter, + colors: [Colors.black54, Colors.transparent], + ), + ), + child: Padding( + padding: const .only(left: 4, right: 4, top: 16), + child: PlayerControls( + player, + loading: !loaded, + size: info?.size, + color: Colors.white, + actions: .new([ + Builder( + builder: (context) { + final fullscreen = isFullscreen(context); + + return IconButton( + onPressed: () => toggleFullscreen(context), + color: Colors.white, + tooltip: fullscreen + ? "Exit fullscreen" + : "Fullscreen", + icon: Icon( + fullscreen + ? Icons.fullscreen_exit + : Icons.fullscreen, + ), + ); + }, + ), + ]), + ), + ), + ), + ), + ], + ), + ), + ), + [uri, encrypted, filename, info], + ); + + final aspectRatio = switch (info) { + VideoInfo(:final width?, :final height?) when width > 0 && height > 0 => + width / height, + _ => 16 / 9, + }; + + return ConstrainedBox( + constraints: const .new(maxHeight: 300), + child: AspectRatio( + aspectRatio: aspectRatio, + child: ClipRRect( + borderRadius: .circular(12), + child: Video(controller: controller, controls: controls), + ), + ), + ); + } +} diff --git a/lib/widgets/reactions/reaction_key.dart b/lib/widgets/reactions/reaction_key.dart new file mode 100644 index 0000000..ca00602 --- /dev/null +++ b/lib/widgets/reactions/reaction_key.dart @@ -0,0 +1,25 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/mxc_image.dart"; + +/// A reaction's key, either an emoji or a custom (mxc) image. +class const ReactionKey( + final String reaction, { + required final double height, + final TextStyle? style, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => + reaction.startsWith("mxc://") + ? Image( + height: height, + image: MxcImage(ref, .new(mxc: Uri.parse(reaction))), + errorBuilder: (context, _, _) => Icon( + Icons.broken_image, + size: height, + color: Theme.of(context).colorScheme.error, + ), + ) + : Text(reaction, style: style, overflow: .ellipsis); +} diff --git a/lib/widgets/reactions/reaction_row.dart b/lib/widgets/reactions/reaction_row.dart new file mode 100644 index 0000000..c8bae95 --- /dev/null +++ b/lib/widgets/reactions/reaction_row.dart @@ -0,0 +1,120 @@ +import "package:flutter/gestures.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/reactions.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/reaction_group.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/reactions/reaction_key.dart"; +import "package:nexus/widgets/reactions/reactions_popover.dart"; +import "package:nexus/main.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final clientState = ref.watch(ClientStateController.provider); + + return Padding( + padding: .only(top: 4), + child: 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]?.senders.contains( + clientState!.userId, + ) ?? + false; + void showReactions(PositionedGestureDetails details) => + context.showAdaptivePopover( + (_) => ReactionsPopover( + event, + initialReaction: reaction, + ), + anchor: context.globalRectAt(details.globalPosition), + ); + + return GestureDetector( + onSecondaryTapUp: showReactions, + onLongPressStart: showReactions, + child: Tooltip( + // Long press opens the reactions popover instead + triggerMode: .manual, + message: reactors?[reaction]?.shortcode ?? reaction, + child: ChoiceChip( + showCheckmark: false, + selected: selected, + label: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + Flexible( + child: ReactionKey(reaction, height: 20), + ), + Text(count.toString(), overflow: .ellipsis), + ], + ), + onSelected: enabled.value + ? (value) async { + enabled.value = false; + try { + final controller = ref.watch( + RoomChatController.provider(( + roomId: event.roomId, + contextualEvent: null, + threadRoot: null, + )).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/reactions/reactions_popover.dart b/lib/widgets/reactions/reactions_popover.dart new file mode 100644 index 0000000..44c455b --- /dev/null +++ b/lib/widgets/reactions/reactions_popover.dart @@ -0,0 +1,144 @@ +import "dart:math"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/reactions.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/helpers/extensions/show_image_viewer.dart"; +import "package:nexus/widgets/reactions/reaction_key.dart"; + +/// Every reaction to an event in tabs, each listing who reacted with it. +class const ReactionsPopover( + final Event event, { + final String? initialReaction, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final reactions = ref.watch( + ReactionsController.provider( + .new(roomId: event.roomId, eventRowId: event.rowId), + ), + ); + // Ordered like the reaction row on the timeline + final order = event.reactions.keys.toIList(); + final keys = + reactions.value?.keys.toIList().sortOrdered( + (a, b) => order.indexOf(a).compareTo(order.indexOf(b)), + ) ?? + const .empty(); + + final vsync = useSingleTickerProvider(keys: [keys.length]); + final tabController = useMemoized( + () => TabController( + length: keys.length, + initialIndex: keys + .indexOf(initialReaction ?? "") + .clamp(0, max(0, keys.length - 1)), + vsync: vsync, + ), + [keys.length], + ); + useEffect(() => tabController.dispose, [tabController]); + useListenable(tabController); + + return reactions.betterWhen( + skipLoadingOnReload: true, + data: (reactions) { + if (keys.isEmpty) return SizedBox.shrink(); + + final reaction = keys[tabController.index]; + + return Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + TabBar( + controller: tabController, + isScrollable: true, + tabAlignment: .start, + tabs: keys.map((reaction) { + void expand() => context.showImageViewer( + ref, + .new(mxc: Uri.parse(reaction)), + downloadFilename: reaction.split("/").last, + ); + final isImage = reaction.startsWith("mxc://"); + + return GestureDetector( + onSecondaryTap: isImage ? expand : null, + onLongPress: isImage ? expand : null, + child: Tab( + child: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + ReactionKey(reaction, height: 20), + Text(reactions[reaction]!.senders.length.toString()), + ], + ), + ), + ); + }).toList(), + ), + Flexible( + child: AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOutCubicEmphasized, + alignment: .topCenter, + child: SingleChildScrollView( + key: ValueKey(reaction), + padding: .symmetric(vertical: 8), + child: Column( + crossAxisAlignment: .stretch, + children: reactions[reaction]!.senders + .map( + (userId) => Consumer( + builder: (context, ref, _) { + final member = ref + .watch( + UserController.provider( + .new( + roomId: event.roomId, + userId: userId, + ), + ), + ) + .value; + final name = + member?.displayName ?? userId.localpart; + + return ListTile( + leading: AvatarOrHash( + member?.avatarUrl, + name, + dimension: 40, + ), + title: Text(name, overflow: .ellipsis), + subtitle: Text(userId, overflow: .ellipsis), + onTap: () => context.showUserPopover( + userId, + roomId: event.roomId, + ), + ); + }, + ), + ) + .toList(), + ), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/read_receipts/read_receipts_row.dart b/lib/widgets/read_receipts/read_receipts_row.dart new file mode 100644 index 0000000..156d3f8 --- /dev/null +++ b/lib/widgets/read_receipts/read_receipts_row.dart @@ -0,0 +1,114 @@ +import "dart:math"; + +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/read_receipts.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/avatar_ring.dart"; +import "package:nexus/widgets/read_receipts/read_receipts_sheet.dart"; + +class const ReadReceiptsRow( + final String eventId, { + required final ChatParams chat, + super.key, +}) extends ConsumerWidget { + static const avatarSize = 24.0; + static const overlap = 6.0; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final receipts = ref.watch( + ReadReceiptsController.provider(chat) + .select((receipts) => receipts[eventId] ?? const .empty()), + ); + + return AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOutCubicEmphasized, + alignment: .topRight, + child: receipts.isEmpty + ? const SizedBox(width: .infinity) + : Padding( + // Clear of the scrollbar + padding: .only(right: 16, bottom: 2), + child: LayoutBuilder( + builder: (context, constraints) { + const width = avatarSize + AvatarRing.ringWidth * 2; + final fits = + (constraints.maxWidth - width) ~/ (width - overlap) + 1; + + // If they don't all fit, the last spot shows how many more there are + final shown = receipts.length <= fits + ? receipts.length + : max(0, fits - 1); + + return Align( + alignment: .centerRight, + child: Wrap( + spacing: -overlap, + children: [ + ...receipts + .take(shown) + .map( + (receipt) => Consumer( + builder: (context, ref, _) { + final member = ref + .watch( + UserController.provider( + .new( + roomId: chat.roomId, + userId: receipt.userId, + ), + ), + ) + .value; + final name = + member?.displayName ?? + receipt.userId.localpart; + + return AvatarRing( + tooltip: name, + onTap: () => context.showUserPopover( + receipt.userId, + roomId: chat.roomId, + ), + child: AvatarOrHash( + member?.avatarUrl, + name, + dimension: avatarSize, + ), + ); + }, + ), + ), + if (receipts.length > shown) + AvatarRing( + tooltip: "Show everyone who has read up to here", + onTap: () => context.showAdaptiveSheet( + (_) => ReadReceiptsSheet(eventId, chat: chat), + ), + child: ColoredBox( + color: theme.colorScheme.surfaceContainerHighest, + child: Center( + child: Text( + "+${receipts.length - shown}", + style: theme.textTheme.labelSmall, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/widgets/read_receipts/read_receipts_sheet.dart b/lib/widgets/read_receipts/read_receipts_sheet.dart new file mode 100644 index 0000000..3021102 --- /dev/null +++ b/lib/widgets/read_receipts/read_receipts_sheet.dart @@ -0,0 +1,54 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/read_receipts.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; + +/// Everyone who has read up to an event, kept up to date while open. +class const ReadReceiptsSheet( + final String eventId, { + required final ChatParams chat, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => SheetLayout( + title: Text("Read by"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Close")), + ], + children: ref + .watch( + ReadReceiptsController.provider(chat) + .select((receipts) => receipts[eventId] ?? const .empty()), + ) + .map( + (receipt) => Consumer( + builder: (context, ref, _) { + final member = ref + .watch( + UserController.provider( + .new(roomId: chat.roomId, userId: receipt.userId), + ), + ) + .value; + final name = member?.displayName ?? receipt.userId.localpart; + + return ListTile( + leading: AvatarOrHash(member?.avatarUrl, name, dimension: 40), + title: Text(name, overflow: .ellipsis), + subtitle: Text(receipt.userId, overflow: .ellipsis), + onTap: () => context.showUserPopover( + receipt.userId, + roomId: chat.roomId, + ), + ); + }, + ), + ) + .toList(), + ); +} diff --git a/lib/widgets/renderers/diffs/change_line.dart b/lib/widgets/renderers/diffs/change_line.dart new file mode 100644 index 0000000..7195882 --- /dev/null +++ b/lib/widgets/renderers/diffs/change_line.dart @@ -0,0 +1,72 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; + +/// A line of what a state event changed, like a room's old name, struck +/// through, then its new one. +class const ChangeLine({ + /// What changed, e.g. `Denied`, in bold before the items. + final String? label, + final IList removed = const .empty(), + final IList added = const .empty(), + required final Color removedColor, + required final Color addedColor, + + /// Whether to quote the items, like server names, but not whole sentences. + final bool quoted = true, + + /// Shown after the items, like a change that isn't one of them. + final List trailing = const [], + + /// How many removed and how many added items to show, as some events, like + /// a room's first server ACL, can change hundreds. + final int maxShown = 5, + + /// The most lines each item can take, as some, like topics, can be long. + final int maxLines = 2, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final style = theme.textTheme.labelMedium; + + Iterable items( + IList items, { + required Color color, + required bool struck, + }) => [ + ...items + .take(maxShown) + .map( + (item) => Text( + quoted ? "\"$item\"" : item, + maxLines: maxLines, + overflow: .ellipsis, + style: style?.copyWith( + color: color, + decoration: struck ? .lineThrough : null, + decorationColor: color, + ), + ), + ), + if (items.length > maxShown) + Text( + "+${items.length - maxShown} more", + style: style?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ]; + + return Wrap( + spacing: 8, + runSpacing: 2, + crossAxisAlignment: .center, + children: [ + if (label case final label?) + Text(label, style: style?.copyWith(fontWeight: .bold)), + ...items(removed, color: removedColor, struck: true), + ...items(added, color: addedColor, struck: false), + ...trailing, + ], + ); + } +} diff --git a/lib/widgets/renderers/diffs/join_rules_diff.dart b/lib/widgets/renderers/diffs/join_rules_diff.dart new file mode 100644 index 0000000..1698aa0 --- /dev/null +++ b/lib/widgets/renderers/diffs/join_rules_diff.dart @@ -0,0 +1,72 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/join_rule_name.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; +import "package:nexus/models/content/join_rules.dart"; +import "package:nexus/widgets/renderers/diffs/change_line.dart"; + +/// The join rule a join rules event changed to, and for restricted rooms, the +/// rooms whose members it let join, or stopped letting join. +class const JoinRulesDiff( + final JoinRulesContent content, { + final JoinRulesContent? previous, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final removedColor = theme.colorScheme.error; + final addedColor = theme.successScheme.primary; + + ISet roomIdsOf(JoinRulesContent? content) => + (content?.allow ?? const ISet.empty()) + .map((condition) => condition.roomId) + .nonNulls + .toISet(); + final before = roomIdsOf(previous); + final after = roomIdsOf(content); + final removed = before.difference(after); + final added = after.difference(before); + + // Rooms the user isn't in are shown by ID + final names = ref.watch( + RoomsController.provider.select( + (rooms) => IMap.fromEntries( + removed + .addAll(added) + .map( + (roomId) => + MapEntry(roomId, rooms[roomId]?.metadata?.name ?? roomId), + ), + ), + ), + ); + IList named(ISet roomIds) => + roomIds.map((roomId) => names[roomId] ?? roomId).toIList(); + + return Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + if (previous?.joinRule != content.joinRule) + ChangeLine( + removed: [?previous?.joinRule.displayName].lock, + added: [content.joinRule.displayName].lock, + removedColor: removedColor, + addedColor: addedColor, + quoted: false, + ), + if (removed.isNotEmpty || added.isNotEmpty) + ChangeLine( + label: "Members of", + removed: named(removed), + added: named(added), + removedColor: removedColor, + addedColor: addedColor, + ), + ], + ); + } +} diff --git a/lib/widgets/renderers/diffs/pinned_events_diff.dart b/lib/widgets/renderers/diffs/pinned_events_diff.dart new file mode 100644 index 0000000..41bd3a1 --- /dev/null +++ b/lib/widgets/renderers/diffs/pinned_events_diff.dart @@ -0,0 +1,116 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/event.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/renderers/event.dart"; + +/// A line for each message a pinned events event pinned, in green, and each one +/// it unpinned, in red and struck through. +class const PinnedEventsDiff( + final String roomId, { + required final IList pinned, + required final IList unpinned, + + /// If set, tapping a message jumps to it. + final JumpToEvent? jumpToEvent, + super.key, +}) extends ConsumerWidget { + static const maxShown = 5; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final style = theme.textTheme.labelMedium; + final changes = [ + ...pinned.map((eventId) => (eventId, unpinned: false)), + ...unpinned.map((eventId) => (eventId, unpinned: true)), + ]; + + return Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + ...changes.take(maxShown).map((change) { + final color = change.unpinned + ? theme.colorScheme.error + : theme.successScheme.primary; + final decoration = change.unpinned + ? TextDecoration.lineThrough + : null; + + return DefaultTextStyle.merge( + style: style?.copyWith( + color: color, + decoration: decoration, + decorationColor: color, + ), + child: switch (ref.watch( + EventController.provider( + .new(roomId: roomId, eventId: change.$1), + ), + )) { + // Keeps showing the event while it reloads + AsyncValue(value: final rowId?) => InkWell( + borderRadius: .circular(4), + hoverColor: color.withValues(alpha: 0.12), + onTap: jumpToEvent == null ? null : () => jumpToEvent!(rowId), + child: Row( + mainAxisSize: .min, + children: [ + // Other events already say who sent them + if (ref.watch( + RoomsController.provider.select( + (rooms) => rooms[roomId]?.events[rowId], + ), + ) + case final event? + when event.content is MessageContent) ...[ + MessageDisplayname( + event, + clickable: false, + shouldWrap: false, + style: .new( + color: color, + fontWeight: .bold, + decoration: decoration, + decorationColor: color, + ), + ), + Text(": "), + ], + Flexible( + child: IgnorePointer( + child: EventRenderer( + rowId, + roomId: roomId, + textOnly: true, + maxLines: 1, + ), + ), + ), + ], + ), + ), + AsyncLoading() => SizedBox.square( + dimension: 12, + child: CircularProgressIndicator(strokeWidth: 2, color: color), + ), + // Like if it was deleted, or the server won't share it + _ => Text("Message not found"), + }, + ); + }), + if (changes.length > maxShown) + Text( + "+${changes.length - maxShown} more", + style: style?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ); + } +} diff --git a/lib/widgets/renderers/diffs/power_levels_diff.dart b/lib/widgets/renderers/diffs/power_levels_diff.dart new file mode 100644 index 0000000..f90c917 --- /dev/null +++ b/lib/widgets/renderers/diffs/power_levels_diff.dart @@ -0,0 +1,141 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/displayname_color.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/power_level_permission.dart"; + +/// Whose power levels, and which permissions' levels, a power levels event +/// changed, in green when raised, and red when lowered. +class const PowerLevelsDiff( + final String roomId, + final PowerLevelsContent content, { + final PowerLevelsContent? previous, + super.key, +}) extends ConsumerWidget { + static const maxShown = 5; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final style = theme.textTheme.labelMedium; + final before = previous ?? PowerLevelsContent(); + + Text levels(int? from, int? to, {int? fromLevel, int? toLevel}) => Text( + "${from ?? "Default"} → ${to ?? "Default"}", + style: style?.copyWith( + color: switch ((fromLevel ?? from, toLevel ?? to)) { + (final from?, final to?) when to > from => + theme.successScheme.primary, + (final from?, final to?) when to < from => theme.colorScheme.error, + _ => theme.colorScheme.onSurfaceVariant, + }, + ), + ); + + Iterable capped(Iterable lines) { + final list = lines.toList(); + return [ + ...list.take(maxShown), + if (list.length > maxShown) + Text( + "+${list.length - maxShown} more", + style: style?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ]; + } + + final users = before.users.keys + .toISet() + .addAll(content.users.keys) + .map( + (userId) => ( + userId, + from: before.users[userId], + to: content.users[userId], + fromLevel: before.users[userId] ?? before.usersDefault, + toLevel: content.users[userId] ?? content.usersDefault, + ), + ) + .where((change) => change.from != change.to) + // The most powerful first + .toIList() + .sort((a, b) => b.toLevel.compareTo(a.toLevel)); + + final permissions = <(String, int?, int?)>[ + ...PowerLevelPermission.values.map( + (permission) => ( + permission.title, + permission.levelIn(before), + permission.levelIn(content), + ), + ), + ...before.events.keys + .toISet() + .addAll(content.events.keys) + .map( + (type) => ( + PowerLevelEvent.of(type)?.title ?? type, + before.events[type], + content.events[type], + ), + ), + ].where((permission) => permission.$2 != permission.$3); + + return Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + ...capped( + users.map( + (change) => Wrap( + spacing: 8, + crossAxisAlignment: .center, + children: [ + Text( + ref + .watch( + UserController.provider( + .new(roomId: roomId, userId: change.$1), + ), + ) + .value + ?.displayName ?? + change.$1.localpart, + style: style?.copyWith( + fontWeight: .bold, + color: ref.displaynameColorAt( + change.$1, + change.toLevel.toDouble(), + ), + ), + ), + levels( + change.from, + change.to, + fromLevel: change.fromLevel, + toLevel: change.toLevel, + ), + ], + ), + ), + ), + ...capped( + permissions.map( + (permission) => Wrap( + spacing: 8, + crossAxisAlignment: .center, + children: [ + Text(permission.$1, style: style?.copyWith(fontWeight: .bold)), + levels(permission.$2, permission.$3), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/renderers/diffs/server_acl_diff.dart b/lib/widgets/renderers/diffs/server_acl_diff.dart new file mode 100644 index 0000000..9364c04 --- /dev/null +++ b/lib/widgets/renderers/diffs/server_acl_diff.dart @@ -0,0 +1,76 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; +import "package:nexus/models/content/server_acl.dart"; +import "package:nexus/widgets/renderers/diffs/change_line.dart"; + +/// The servers newly denied (in red) and allowed (in green) by a server ACL +/// event, and those no longer denied or allowed, struck through. +class const ServerAclDiff( + final ServerACLContent content, { + final ServerACLContent? previous, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final ipLiteralsAllowed = switch (previous) { + final previous? + when previous.allowIpLiterals != content.allowIpLiterals => + content.allowIpLiterals == true, + _ => null, + }; + + Widget? listDiff( + String label, + Color color, + IList before, + IList after, { + required bool ipLiterals, + }) { + // Sets, as an ACL can list hundreds of servers + final beforeSet = before.toISet(); + final afterSet = after.toISet(); + final removed = before.where((server) => !afterSet.contains(server)); + final added = after.where((server) => !beforeSet.contains(server)); + if (removed.isEmpty && added.isEmpty && !ipLiterals) return null; + + return ChangeLine( + label: label, + removed: .new(removed), + added: .new(added), + removedColor: color, + addedColor: color, + trailing: [ + if (ipLiterals) + Text( + "servers using IP addresses", + style: theme.textTheme.labelMedium?.copyWith(color: color), + ), + ], + ); + } + + return Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + ?listDiff( + "Denied", + theme.colorScheme.error, + previous?.deny ?? const .empty(), + content.deny, + ipLiterals: ipLiteralsAllowed == false, + ), + ?listDiff( + "Allowed", + theme.successScheme.primary, + previous?.allow ?? const .empty(), + content.allow, + ipLiterals: ipLiteralsAllowed == true, + ), + ], + ); + } +} diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart new file mode 100644 index 0000000..4e14fad --- /dev/null +++ b/lib/widgets/renderers/event.dart @@ -0,0 +1,575 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/gestures.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/event.dart"; +import "package:nexus/controllers/poll_votes.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/helpers/extensions/is_shown_as_message.dart"; +import "package:nexus/helpers/extensions/send_failed.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/helpers/hooks/tap_recognizer.dart"; +import "package:nexus/helpers/extensions/open_link_info.dart"; +import "package:nexus/main.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/encryption.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/name.dart"; +import "package:nexus/models/content/pinned_events.dart"; +import "package:nexus/models/content/poll_end.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/content/server_acl.dart"; +import "package:nexus/models/content/tombstone.dart"; +import "package:nexus/models/content/topic.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/renderers/message.dart"; +import "package:nexus/widgets/reactions/reaction_row.dart"; +import "package:nexus/widgets/read_receipts/read_receipts_row.dart"; +import "package:nexus/widgets/renderers/membership.dart"; +import "package:nexus/widgets/renderers/diffs/change_line.dart"; +import "package:nexus/widgets/renderers/generic_event.dart"; +import "package:nexus/widgets/renderers/diffs/join_rules_diff.dart"; +import "package:nexus/widgets/renderers/diffs/power_levels_diff.dart"; +import "package:nexus/widgets/renderers/diffs/pinned_events_diff.dart"; +import "package:nexus/widgets/renderers/diffs/server_acl_diff.dart"; +import "package:nexus/widgets/renderers/swipe_to_reply.dart"; +import "package:nexus/widgets/threads/thread_summary.dart"; +import "package:nexus/widgets/common/timestamp.dart"; +import "package:nexus/widgets/users/user_span.dart"; + +class const EventRenderer( + final int eventRowId, { + required final String roomId, + final int? previousEventRowId, + final bool textOnly = false, + final int? maxLines, + final JumpToEvent? jumpToEvent, + final void Function(String threadRoot)? onOpenThread, + final IList Function(Event event)? getEventOptions, + + /// If set, events can be swiped, calling this + final VoidCallback? onSwipe, + + /// If set, shows who in this chat has read up to this event, e.g. in the + /// timeline. + final ChatParams? readReceiptsChat, + + /// The root of the thread this is shown in, if any, to set it apart from the + /// replies. + final String? threadRoot, + super.key, +}) extends HookConsumerWidget { + @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 senderRecognizer = useTapRecognizer(); + + final (event, previousEvent) = ref.watch( + RoomsController.provider.select((rooms) { + final event = rooms[roomId]?.events[eventRowId]; + if (event == null) return (null, null); + + final edited = event.lastEditRowId == 0 + ? null + : rooms[roomId]?.events[event.lastEditRowId]; + + return ( + edited == null + ? event + : event.copyWith( + content: edited.content, + localContent: edited.localContent, + ), + previousEventRowId == null + ? null + : rooms[roomId]?.events[previousEventRowId!], + ); + }), + ); + + if (event == null) return SizedBox.shrink(); + + final (hideMembershipEvents, hideServerAclEvents) = ref + .watch(SettingsController.provider) + .maybeWhen( + orElse: () => (false, true), + data: (settings) => + (settings.hideMembershipEvents, settings.hideServerAclEvents), + ); + + final isHidden = + // Knocks and their outcomes stay visible, as moderators need to act + // on them + (hideMembershipEvents && + event.content is MembershipContent && + ![event.content, event.previousContent].any( + (content) => + content is MembershipContent && content.status == .knock, + )) || + (hideServerAclEvents && event.content is ServerACLContent); + + if (isHidden && !textOnly) return SizedBox.shrink(); + + final isThreadRoot = threadRoot != null && event.eventId == threadRoot; + final isGrouped = + (threadRoot == null || previousEvent?.eventId != threadRoot) && + (previousEvent?.isShownAsMessage ?? false) && + event.sender == previousEvent?.sender && + event.pmp?.id == previousEvent?.pmp?.id; + + // For what a state event changed, from and to + final removedColor = colorScheme.error; + final addedColor = theme.successScheme.primary; + + GenericEventRenderer generic( + IconData icon, + String text, { + List details = const [], + }) => .new( + icon, + maxLines: maxLines, + details: details, + trailing: Timestamp(event.timestamp), + [ + UserSpan.sender(context, ref, event, recognizer: senderRecognizer), + TextSpan(text: text), + ], + ); + + // For rooms an upgrade links, i.e. the old and new rooms + Widget roomLink(String label, String linkedRoomId) => InkWell( + onTap: () => ref + .openLinkInfo(context, ( + identifier: linkedRoomId, + eventId: null, + // The sender is in both rooms, so their server can be joined + // through + via: [event.sender.substring(event.sender.indexOf(":") + 1)].lock, + )) + .onError(showError), + child: Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: colorScheme.primary, + decoration: .underline, + decorationColor: colorScheme.primary, + ), + ), + ); + + final child = + event.isShownAsMessage || (isThreadRoot && event.redactedBy != null) + ? MessageRenderer( + event, + onTapReply: jumpToEvent == null + ? null + : () async { + final replyRowId = await ref.read( + EventController.provider( + .new(roomId: roomId, eventId: event.replyTo!), + ).future, + ); + if (replyRowId != null) { + await jumpToEvent!(replyRowId); + } + }, + onTapThread: onOpenThread == null + ? null + : () => onOpenThread!(event.eventId), + isGrouped: isGrouped, + maxLines: maxLines, + textOnly: textOnly, + ) + : event.redactedBy != null || event.relationType == "m.replace" + ? null + : switch (event.content) { + Content(:final parseError?, :final stackTrace) => Row( + children: [ + Flexible( + child: ErrorDialog( + "An error occurred while parsing event ${event.eventId}:\n$parseError", + stackTrace, + ), + ), + ], + ), + + MembershipContent content => switch (event.previousContent) { + MembershipContent(:final status) => + status == content.status + ? null + : MembershipRenderer(event, maxLines: maxLines), + _ => MembershipRenderer(event, maxLines: maxLines), + }, + + AvatarContent() => generic( + Icons.interests, + "changed the room avatar", + ), + CreateContent(:final predecessor) => generic( + Icons.add, + predecessor == null + ? "created the room" + : "created the room, replacing an older one", + details: [ + if (predecessor case PreviousRoom(:final roomId)) + roomLink("Go to old room", roomId), + ], + ), + TombstoneContent(:final body, :final replacementRoom) => generic( + Icons.upgrade, + "replaced the room", + details: [ + if (body.isNotEmpty) + Text(body, style: theme.textTheme.labelMedium), + roomLink("Go to new room", replacementRoom), + ], + ), + PollEndContent() => switch (event.relatesTo) { + final pollId? => switch (ref + .watch( + EventController.provider( + .new(roomId: roomId, eventId: pollId), + ), + ) + .value) { + final pollRowId? + when ref + .watch( + PollVotesController.provider( + .new(roomId: roomId, eventRowId: pollRowId), + ), + ) + .value + ?.endEventId == + event.eventId => + generic(Icons.poll, "ended a poll"), + _ => null, + }, + null => null, + }, + final PowerLevelsContent content => generic( + Icons.power, + "changed the room's power levels", + details: [ + PowerLevelsDiff( + roomId, + content, + previous: switch (event.previousContent) { + final PowerLevelsContent previous => previous, + _ => null, + }, + ), + ], + ), + final JoinRulesContent content => generic( + Icons.rule, + "changed the room's join rules", + details: [ + JoinRulesDiff( + content, + previous: switch (event.previousContent) { + final JoinRulesContent previous => previous, + _ => null, + }, + ), + ], + ), + TopicContent(:final topic) => () { + final previous = switch (event.previousContent) { + TopicContent(:final topic) when topic.isNotEmpty => topic, + _ => null, + }; + + return generic( + Icons.description, + topic.isEmpty + ? "removed the room topic" + : previous == null + ? "set the room topic" + : "changed the room topic", + details: [ + ChangeLine( + removed: [?previous].lock, + added: [if (topic.isNotEmpty) topic].lock, + removedColor: removedColor, + addedColor: addedColor, + quoted: false, + ), + ], + ); + }(), + HistoryVisibilityContent(:final historyVisibility) => generic( + Icons.history, + "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)", + .unknown => "something Nexus doesn't know about", + }}", + ), + PinnedEventsContent(:final pinnedEvents) => () { + final before = switch (event.previousContent) { + PinnedEventsContent(:final pinnedEvents) => pinnedEvents, + _ => const IList.empty(), + }; + final pinned = pinnedEvents + .where((id) => !before.contains(id)) + .toIList(); + final unpinned = before + .where((id) => !pinnedEvents.contains(id)) + .toIList(); + + return generic( + Icons.push_pin, + switch ((pinned.length, unpinned.length)) { + (1, 0) => "pinned a message", + (0, 1) => "unpinned a message", + _ => "changed the pinned messages", + }, + details: [ + PinnedEventsDiff( + roomId, + pinned: pinned, + unpinned: unpinned, + jumpToEvent: jumpToEvent, + ), + ], + ); + }(), + final ServerACLContent content => generic( + Icons.list, + "updated the server ban list", + details: [ + ServerAclDiff( + content, + previous: switch (event.previousContent) { + final ServerACLContent previous => previous, + _ => null, + }, + ), + ], + ), + CanonicalAliasContent(:final alias, :final altAliases) => () { + final previous = switch (event.previousContent) { + final CanonicalAliasContent previous => previous, + _ => null, + }; + final previousAltAliases = + previous?.altAliases ?? const ISet.empty(); + + return generic( + Icons.numbers, + previous == null + ? "set the room's aliases" + : "changed the room's aliases", + details: [ + if (previous?.alias != alias) + ChangeLine( + label: "Canonical alias", + removed: [?previous?.alias].lock, + added: [?alias].lock, + removedColor: removedColor, + addedColor: addedColor, + ), + if (previousAltAliases != altAliases) + ChangeLine( + label: "Aliases", + removed: previousAltAliases + .difference(altAliases) + .toIList(), + added: altAliases + .difference(previousAltAliases) + .toIList(), + removedColor: removedColor, + addedColor: addedColor, + ), + ], + ); + }(), + NameContent(:final name) => () { + final previous = switch (event.previousContent) { + NameContent(:final name) when name.isNotEmpty => name, + _ => null, + }; + + return generic( + Icons.title, + name.isEmpty + ? "removed the room name" + : previous == null + ? "set the room name" + : "changed the room name", + details: [ + ChangeLine( + removed: [?previous].lock, + added: [if (name.isNotEmpty) name].lock, + removedColor: removedColor, + addedColor: addedColor, + ), + ], + ); + }(), + EncryptionContent() => generic( + Icons.lock, + event.previousContent is EncryptionContent + ? "changed the room's encryption settings" + : "enabled encryption", + ), + _ => null, + }; + + final contextMenuCallback = getEventOptions == null + ? null + : (PositionedGestureDetails details) => context.showAdaptiveMenu( + getEventOptions!(event).toList(), + globalPosition: details.globalPosition, + ); + + final threadSummary = switch (onOpenThread) { + final onOpenThread? when !textOnly && child is! MessageRenderer => + ThreadSummary(event, onTap: () => onOpenThread(event.eventId)), + _ => null, + }; + final belowIndent = child is MessageRenderer + ? 4.0 + : 8 + GenericEventRenderer.textIndent(context); + + 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.showAdaptiveMenu( + getEventOptions!(event).toList(), + globalPosition: topLeft, + ); + return null; + }, + ), + }, + child: Container( + decoration: BoxDecoration( + color: focusNode.hasPrimaryFocus + ? theme.colorScheme.surfaceContainerHighest + : null, + ), + child: GestureDetector( + onSecondaryTapUp: contextMenuCallback, + onLongPressStart: contextMenuCallback, + child: SwipeToReply( + onSwipe: onSwipe, + Padding( + padding: EdgeInsets.symmetric(horizontal: 8) + .copyWith(top: isGrouped ? 0 : 8), + child: Wrap( + verticalDirection: .up, + spacing: 6, + crossAxisAlignment: .center, + children: [ + child, + if (child is! MessageRenderer && + child is! GenericEventRenderer) + Timestamp(event.timestamp), + ], + ), + ), + ), + ), + ), + ), + ), + + ...[ + if (child is! MessageRenderer) ReactionRow(event), + ?threadSummary, + + // Outside the message itself, so it doesn't move when swiped + if (readReceiptsChat case final chat? when event.isShownAsMessage) + ReadReceiptsRow(event.eventId, chat: chat), + + if (event.sendFailed) + Padding( + padding: .only(bottom: 4), + child: Row( + spacing: 4, + children: [ + Text( + event.sendError!, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + InkWell( + child: Text( + "Hide", + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + decoration: .underline, + decorationColor: theme.colorScheme.primary, + ), + ), + onTap: () => ref + .read( + RoomChatController.provider(( + roomId: roomId, + contextualEvent: null, + threadRoot: null, + )).notifier, + ) + .hideClientSticky(event.rowId), + ), + ], + ), + ), + ].map( + (child) => Padding( + padding: .only(left: belowIndent), + child: child, + ), + ), + ] + else if (textOnly) + Text( + event.redactedBy == null ? "Unknown event type" : "Redacted event", + style: errorStyle, + ) + else if (threadSummary != null) + Padding(padding: .only(left: 4), child: threadSummary), + + if (isThreadRoot && !textOnly) Divider(height: 16), + ], + ); + } +} diff --git a/lib/widgets/renderers/event_preview.dart b/lib/widgets/renderers/event_preview.dart new file mode 100644 index 0000000..53e10ac --- /dev/null +++ b/lib/widgets/renderers/event_preview.dart @@ -0,0 +1,52 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/is_shown_as_message.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 const EventPreview( + final int rowId, { + required final String roomId, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final event = ref.watch( + RoomsController.provider.select((rooms) => rooms[roomId]?.events[rowId]), + ); + if (event == null) return SizedBox.shrink(); + + final showSender = event.hasMessageContent; + + return IgnorePointer( + child: Padding( + padding: .symmetric(vertical: 4), + child: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + if (showSender) MessageAvatar(event, height: 36), + + Flexible( + child: Column( + mainAxisAlignment: .center, + crossAxisAlignment: .start, + children: [ + if (showSender) MessageDisplayname(event, shouldWrap: false), + EventRenderer( + rowId, + roomId: roomId, + textOnly: true, + maxLines: 1, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/renderers/file_card.dart b/lib/widgets/renderers/file_card.dart new file mode 100644 index 0000000..4c3ce43 --- /dev/null +++ b/lib/widgets/renderers/file_card.dart @@ -0,0 +1,35 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; +import "package:nexus/helpers/extensions/size_to_string.dart"; +import "package:nexus/models/info/file.dart"; + +final class const FileCard( + final Uri uri, + final FileInfo? info, { + final String? filename, + final bool encrypted = false, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => 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), + trailing: IconButton( + onPressed: () => context.saveAttachment( + ref, + uri, + encrypted: encrypted, + filename: filename, + ), + icon: Icon(Icons.download), + ), + ), + ), + ); +} diff --git a/lib/widgets/renderers/generic_event.dart b/lib/widgets/renderers/generic_event.dart new file mode 100644 index 0000000..100643b --- /dev/null +++ b/lib/widgets/renderers/generic_event.dart @@ -0,0 +1,83 @@ +import "package:collection/collection.dart"; +import "package:material_ui/material_ui.dart"; + +class const GenericEventRenderer( + final IconData icon, + final List children, { + final int? maxLines, + + /// Shown at the end of the text, like the event's timestamp. + final Widget? trailing, + + /// Shown under the text, like what changed in a state event. + final List details = const [], + super.key, +}) extends StatelessWidget { + static const _iconPadding = 4.0; + static const _spacing = 8.0; + + /// How far the text is from the start, past the icon, so what's shown under + /// the event can line up with it. + static double textIndent(BuildContext context) => + (IconTheme.of(context).size ?? 24) + _iconPadding * 2 + _spacing; + + @override + Widget build(BuildContext context) { + // Only previewing the event, so it should stay short + final previewing = maxLines != null; + final iconSize = IconTheme.of(context).size ?? 24; + + return Padding( + padding: .symmetric(vertical: 4), + child: Row( + spacing: _spacing, + mainAxisSize: .min, + // At the top, by the text, rather than the middle of any details + crossAxisAlignment: .start, + children: [ + Padding( + padding: .symmetric(horizontal: _iconPadding), + child: Icon(icon), + ), + Flexible( + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + spacing: 4, + children: [ + // At least as tall as the icon, so they're centered together + ConstrainedBox( + constraints: .new(minHeight: iconSize), + child: Row( + spacing: 8, + mainAxisSize: .min, + children: [ + Flexible( + child: Text.rich( + TextSpan( + children: children + .expandIndexed( + (index, child) => [ + if (index > 0) TextSpan(text: " "), + child, + ], + ) + .toList(), + ), + maxLines: maxLines ?? 999, + overflow: .ellipsis, + ), + ), + if (!previewing) ?trailing, + ], + ), + ), + if (!previewing) ...details, + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart new file mode 100644 index 0000000..125ea64 --- /dev/null +++ b/lib/widgets/renderers/membership.dart @@ -0,0 +1,67 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/displayname_color.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/hooks/tap_recognizer.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/renderers/generic_event.dart"; +import "package:nexus/widgets/users/user_span.dart"; + +class const MembershipRenderer( + final Event event, { + final int? maxLines, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + assert( + event.content is MembershipContent, + "Make sure to only pass membership events to MembershipRenderer", + ); + + final targetRecognizer = useTapRecognizer(); + final senderRecognizer = useTapRecognizer(); + final previous = switch (event.previousContent) { + MembershipContent(:final status) => status, + _ => null, + }; + + return switch (event.content) { + MembershipContent content => GenericEventRenderer( + Icons.people, + maxLines: maxLines, + [ + UserSpan( + context, + content.displayName ?? event.stateKey!.localpart, + userId: event.stateKey!, + roomId: event.roomId, + recognizer: targetRecognizer, + style: .new( + color: ref.displaynameColor(event.roomId, event.stateKey!), + fontWeight: .bold, + ), + ), + TextSpan( + text: + "${switch (content.status) { + .invite => previous == .knock ? "was accepted into" : "was invited to", + .join => "joined", + .leave when previous == .knock => event.sender == event.stateKey ? "retracted their knock on" : "had their knock denied in", + .leave => event.sender == event.stateKey ? "left" : (previous == .ban ? "was unbanned from" : "was kicked from"), + .ban => "was banned from", + .knock => "asked to join", + .unknown => "changed their membership in", + }} the room${event.sender == event.stateKey ? "" : " by"}", + ), + if (event.sender != event.stateKey) + UserSpan.sender(context, ref, event, recognizer: senderRecognizer), + if (content.reason != null) + TextSpan(text: "for \"${content.reason}\""), + ], + ), + _ => SizedBox.shrink(), + }; + } +} diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart new file mode 100644 index 0000000..40ee5e7 --- /dev/null +++ b/lib/widgets/renderers/message.dart @@ -0,0 +1,305 @@ +import "package:collection/collection.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:linkify/linkify.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/event.dart"; +import "package:nexus/helpers/extensions/get_filename.dart"; +import "package:nexus/helpers/extensions/linkify_html.dart"; +import "package:nexus/models/content/encrypted.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/location.dart"; +import "package:nexus/models/content/poll_start.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/renderers/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/common/linkified_text.dart"; +import "package:nexus/widgets/location/location_message.dart"; +import "package:nexus/widgets/common/loading.dart"; +import "package:nexus/widgets/renderers/message_image.dart"; +import "package:nexus/widgets/reactions/reaction_row.dart"; +import "package:nexus/widgets/common/timestamp.dart"; +import "package:nexus/widgets/renderers/url_preview.dart"; +import "package:nexus/widgets/renderers/event_preview.dart"; +import "package:nexus/widgets/players/video.dart"; +import "package:nexus/widgets/players/audio.dart"; +import "package:nexus/widgets/renderers/poll.dart"; +import "package:nexus/widgets/threads/thread_summary.dart"; + +class const MessageRenderer( + final Event event, { + final VoidCallback? onTapReply, + + /// If set, thread roots show a summary of their thread, calling this when + /// tapped. + final VoidCallback? onTapThread, + final bool textOnly = false, + final bool isGrouped = false, + final int? maxLines, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final errorStyle = TextStyle(color: colorScheme.error); + + final textStyle = TextStyle( + fontSize: event.localContent?.bigEmoji == true && maxLines != 1 + ? 32 + : null, + fontStyle: event.content is EmoteMessageContent ? .italic : null, + ); + + final rendered = switch (event.content) { + _ when event.redactedBy != null => Text( + "Redacted event", + style: errorStyle, + ), + 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, info: info, encrypted: false), + ), + PollStartContent(:final poll) => + textOnly + ? Text( + "Poll: ${poll.question}", + maxLines: maxLines, + overflow: .ellipsis, + ) + : PollRenderer(event, poll), + MessageContent content => switch (content) { + final LocationMessageContent location => + textOnly + ? Text( + switch (location.description) { + final description? => "Location: $description", + null => "Location", + }, + maxLines: maxLines, + overflow: .ellipsis, + ) + : LocationMessage(location), + 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: [ + textOnly + ? Text( + body.isEmpty ? content.filename ?? "" : body, + style: textStyle, + maxLines: maxLines, + overflow: maxLines == null ? null : .ellipsis, + ) + : format == .html && formattedBody != null + ? Html( + roomId: event.roomId, + textStyle: textStyle, + formattedBody.linkifiedHtml, + ) + : LinkifiedText( + body.isEmpty ? content.filename ?? "" : body, + roomId: event.roomId, + style: textStyle, + maxLines: maxLines, + ), + + if (!textOnly) ...[ + if (event.content + case ImageMessageContent(:final url) || + FileMessageContent(:final url) || + VideoMessageContent(:final url) || + AudioMessageContent(:final url)) + ConstrainedBox( + constraints: .loose(.square(500)), + child: switch (event.content) { + VideoMessageContent( + :final info, + :final file, + :final filename, + :final body, + ) => + VideoPlayer( + url, + info, + encrypted: file != null, + filename: filename ?? body, + ), + AudioMessageContent( + :final info, + :final file, + :final filename, + :final body, + ) => + AudioPlayer( + url, + info, + encrypted: file != null, + filename: filename ?? body, + ), + FileMessageContent( + :final info, + :final filename, + :final file, + ) => + FileCard( + url, + info, + filename: filename, + encrypted: file != null, + ), + ImageMessageContent( + :final info, + :final file, + :final filename, + :final body, + ) => + MessageImage( + url, + info: info, + encrypted: file != null, + filename: filename ?? body, + ), + _ => SizedBox.shrink(), + }, + ), + + if (event.lastEditRowId != 0) + Text("(edited)", style: theme.textTheme.labelSmall), + + if (linkify(body) + .firstWhereOrNull((element) => element is UrlElement) + case final UrlElement link?) + if (Uri.tryParse(link.url) case final Uri url?) UrlPreview(url), + ], + ], + ), + UnknownMessageContent(:final body) => + body == null + ? Text("This message is redacted", style: errorStyle) + : Wrap( + spacing: 8, + children: [ + Text("Unknown message type:", style: errorStyle), + Text(body), + ], + ), + }, + _ => throw Exception("This is impossible"), + }; + + return Row( + crossAxisAlignment: .start, + mainAxisSize: .min, + spacing: 6, + children: [ + if (!textOnly) + if (isGrouped) + SizedBox(width: 40) + else + MessageAvatar(event, height: 40), + Flexible( + child: Column( + spacing: 2, + crossAxisAlignment: .start, + children: [ + if (!isGrouped && !textOnly) + Row( + spacing: 4, + children: [ + Flexible(child: MessageDisplayname(event)), + Flexible(flex: 0, child: Timestamp(event.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: SizedBox( + // Two lines of text, plus EventPreview's padding + height: + MediaQuery.textScalerOf(context).scale(40) + + 8, + child: switch (ref.watch( + EventController.provider( + .new( + roomId: event.roomId, + eventId: event.replyTo!, + ), + ), + )) { + AsyncData(:final value?) || + AsyncLoading(:final value?) => EventPreview( + value, + roomId: event.roomId, + ), + AsyncError _ => Text( + "An error occurred while fetching the reply", + style: errorStyle, + ), + _ => Loading(dimension: 12), + }, + ), + ), + ), + ), + rendered, + if (!textOnly) ReactionRow(event), + if (onTapThread case final onTapThread? when !textOnly) + ThreadSummary(event, onTap: onTapThread), + ], + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/widgets/renderers/message_image.dart b/lib/widgets/renderers/message_image.dart new file mode 100644 index 0000000..a8c44e3 --- /dev/null +++ b/lib/widgets/renderers/message_image.dart @@ -0,0 +1,60 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_blurhash/flutter_blurhash.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/helpers/mxc_image.dart"; +import "package:nexus/models/info/image.dart" as i; +import "package:nexus/models/requests/download_media.dart"; +import "package:nexus/widgets/common/expandable_image.dart"; +import "package:nexus/widgets/common/loading.dart"; + +final class const MessageImage( + final Uri url, { + final i.ImageInfo? info, + required final bool encrypted, + final String? filename, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final request = DownloadMediaRequest(mxc: url, encrypted: encrypted); + return ExpandableImage( + request, + downloadFilename: filename, + size: info?.size, + child: ClipRRect( + borderRadius: .all(.circular(8)), + child: AspectRatio( + aspectRatio: (info?.width ?? 1) / (info?.height ?? 1), + child: Image( + image: MxcImage(ref, request), + width: info?.width?.toDouble(), + fit: .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!.toDouble(), + 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/renderers/poll.dart b/lib/widgets/renderers/poll.dart new file mode 100644 index 0000000..1f738b5 --- /dev/null +++ b/lib/widgets/renderers/poll.dart @@ -0,0 +1,221 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.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:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/poll_votes.dart"; +import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/room_power_levels.dart"; +import "package:nexus/helpers/extensions/event_id_is_fake.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/poll_start.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/renderers/poll_option.dart"; + +class const PollRenderer(final Event event, final PollStart poll, {super.key}) + extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + /// The answer being voted for, or no answer while ending the poll + final pending = useState<({String? answerId})?>(null); + final isEnding = switch (pending.value) { + (answerId: null) => true, + _ => false, + }; + + final votesProvider = PollVotesController.provider( + .new(roomId: event.roomId, eventRowId: event.rowId), + ); + final votesState = ref.watch(votesProvider); + final votes = votesState.value ?? const .new(); + final userId = ref.watch( + ClientStateController.provider.select((value) => value?.userId), + ); + final powerLevels = ref.watch( + RoomPowerLevelsController.provider(event.roomId), + ); + + final isOpen = !event.eventId.isFake && !votes.hasEnded; + final canVote = + isOpen && + votesState.hasValue && + ref.watch( + PowerLevelController.provider( + .new(eventType: .pollResponse, roomId: event.roomId), + ), + ); + final canEnd = + isOpen && + userId != null && + powerLevels.canEndPoll(userId, pollSender: event.sender); + final ownVote = + (userId == null ? null : votes.votes[userId]) ?? const .empty(); + final votersByAnswer = votes.votersByAnswer; + final mostVotes = + votersByAnswer.values.map((voters) => voters.length).maxOrNull ?? 0; + final isSingleChoice = poll.selectionLimit == 1; + + final notifier = RoomChatController.provider(( + roomId: event.roomId, + contextualEvent: null, + threadRoot: null, + )).notifier; + + Future run( + ({String? answerId}) action, + Future Function() send, + ) async { + pending.value = action; + await send().onError(showError); + if (!context.mounted) return; + // Otherwise the old votes show until they're refetched + await ref.read(votesProvider.future).onError((_, _) => const .new()); + if (context.mounted) pending.value = null; + } + + Future toggle(String answerId) { + final picked = ownVote.contains(answerId) + ? ownVote.remove(answerId) + : isSingleChoice + ? IList([answerId]) + : ownVote.add(answerId); + + return run( + (answerId: answerId), + () => ref + .read(notifier) + .votePoll( + event, + // Past the limit, the oldest pick is replaced + picked.length > poll.selectionLimit ? picked.removeAt(0) : picked, + ), + ); + } + + void confirmEnd() => context.showAdaptiveSheet( + (sheetContext) => SheetLayout( + title: const Text("End Poll"), + actions: [ + TextButton( + onPressed: Navigator.of(sheetContext).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: () { + Navigator.of(sheetContext).pop(); + run((answerId: null), () => ref.read(notifier).endPoll(event)); + }, + child: const Text("End"), + ), + ], + children: const [ + Text( + "Are you sure you want to end this poll? " + "No one will be able to vote after it ends.", + ), + ], + ), + ); + + final voterCount = votes.voterCount; + + return ConstrainedBox( + constraints: const .new(maxWidth: 400), + child: Column( + crossAxisAlignment: .stretch, + mainAxisSize: .min, + spacing: 12, + children: [ + Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + Text( + poll.question.isEmpty ? "Untitled poll" : poll.question, + style: theme.textTheme.titleMedium, + ), + Text( + [ + votes.hasEnded + ? "Ended" + : isSingleChoice + ? "Choose one" + : "Choose up to ${poll.selectionLimit}", + "$voterCount ${voterCount == 1 ? "vote" : "votes"}", + ].join(" · "), + style: theme.textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + if (votesState case AsyncError(:final error)) + Text( + "Failed to load votes: $error", + style: .new(color: colorScheme.error), + ), + if (votes.undecryptable case final count when count > 0) + Row( + spacing: 8, + children: [ + Icon(Icons.lock, size: 16, color: colorScheme.error), + Flexible( + child: Text( + "$count ${count == 1 ? "response" : "responses"} couldn't " + "be decrypted, so the results may be incomplete", + style: theme.textTheme.labelMedium?.copyWith( + color: colorScheme.error, + ), + ), + ), + ], + ), + M3ECardColumn( + color: colorScheme.surface, + outerRadius: 16, + padding: const .symmetric(horizontal: 16, vertical: 12), + onTap: canVote && pending.value == null + ? (index) => toggle(poll.answers[index].id) + : null, + children: poll.answers.map((answer) { + final count = votersByAnswer[answer.id]?.length ?? 0; + + return PollOption( + answer.text, + picked: ownVote.contains(answer.id), + isSingleChoice: isSingleChoice, + share: voterCount == 0 ? 0 : count / voterCount, + isWinner: votes.hasEnded && count > 0 && count == mostVotes, + loading: pending.value?.answerId == answer.id, + ); + }).toList(), + ), + if (canEnd) + Align( + alignment: .centerRight, + child: M3EButton.icon( + style: .tonal, + size: .xs, + onPressed: pending.value == null ? confirmEnd : null, + icon: isEnding + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.stop_circle), + label: const Text("End Poll"), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/renderers/poll_option.dart b/lib/widgets/renderers/poll_option.dart new file mode 100644 index 0000000..ff01d4e --- /dev/null +++ b/lib/widgets/renderers/poll_option.dart @@ -0,0 +1,67 @@ +import "package:material_ui/material_ui.dart"; + +class const PollOption( + final String text, { + required final bool picked, + required final bool isSingleChoice, + + /// From 0 to 1 + required final double share, + final bool isWinner = false, + final bool loading = false, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final emphasis = isWinner ? const TextStyle(fontWeight: .bold) : null; + + return Column( + crossAxisAlignment: .stretch, + spacing: 10, + children: [ + Row( + spacing: 12, + children: [ + SizedBox.square( + dimension: 20, + child: loading + ? const CircularProgressIndicator(strokeWidth: 2.5) + : Icon( + switch ((isSingleChoice, picked)) { + (true, true) => Icons.radio_button_checked, + (true, false) => Icons.radio_button_unchecked, + (false, true) => Icons.check_box, + (false, false) => Icons.check_box_outline_blank, + }, + size: 20, + color: picked + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + Expanded( + child: Text( + text, + style: theme.textTheme.bodyLarge?.merge(emphasis), + ), + ), + Text( + "${(share * 100).round()}%", + style: theme.textTheme.labelLarge + ?.copyWith(color: colorScheme.onSurfaceVariant) + .merge(emphasis), + ), + ], + ), + TweenAnimationBuilder( + tween: Tween(end: share), + duration: Durations.medium2, + curve: Curves.easeInOut, + builder: (context, value, _) => LinearProgressIndicator(value: value), + ), + ], + ); + } +} diff --git a/lib/widgets/renderers/swipe_to_reply.dart b/lib/widgets/renderers/swipe_to_reply.dart new file mode 100644 index 0000000..c02b310 --- /dev/null +++ b/lib/widgets/renderers/swipe_to_reply.dart @@ -0,0 +1,58 @@ +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; + +class const SwipeToReply( + final Widget child, { + required final VoidCallback? onSwipe, + super.key, +}) extends HookWidget { + static const maxDrag = 64.0; + static const threshold = 0.75; + + @override + Widget build(BuildContext context) { + final controller = useAnimationController(duration: Durations.short4); + useListenable(controller); + + if (onSwipe == null) return child; + + final progress = controller.value; + + return GestureDetector( + onHorizontalDragUpdate: (details) { + final previous = controller.value; + controller.value = previous + details.primaryDelta! / maxDrag; + + if (previous < threshold && controller.value >= threshold) { + HapticFeedback.lightImpact(); + } + }, + onHorizontalDragEnd: (_) { + if (controller.value >= threshold) onSwipe!(); + controller.animateBack(0, curve: Curves.easeOut); + }, + onHorizontalDragCancel: () => + controller.animateBack(0, curve: Curves.easeOut), + child: Stack( + alignment: .centerLeft, + children: [ + Positioned( + left: 16, + child: Opacity( + opacity: (progress / threshold).clamp(0, 1), + child: Transform.scale( + scale: progress >= threshold ? 1 : 0.75, + child: const Icon(Icons.reply), + ), + ), + ), + Transform.translate( + offset: .new(progress * maxDrag, 0), + child: child, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/renderers/url_preview.dart b/lib/widgets/renderers/url_preview.dart new file mode 100644 index 0000000..6403427 --- /dev/null +++ b/lib/widgets/renderers/url_preview.dart @@ -0,0 +1,65 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/url_preview.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/launch_helper.dart"; +import "package:nexus/helpers/mxc_image.dart"; + +class const UrlPreview(final Uri link, {super.key}) extends ConsumerWidget { + @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.read(LaunchHelper.provider).launchUrl(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, + maxLines: 3, + overflow: .ellipsis, + ), + if (preview.description != null) ...[ + Text( + preview.description!, + maxLines: 20, + overflow: .ellipsis, + ), + SizedBox(height: 4), + ], + if (preview.imageUrl != null) + ClipRRect( + borderRadius: .all(.circular(8)), + child: Image( + errorBuilder: (_, _, _) => SizedBox.shrink(), + width: preview.width, + image: MxcImage( + ref, + .new(mxc: preview.imageUrl!), + ), + fit: .fitWidth, + ), + ), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/lib/widgets/room_chat/chat_body.dart b/lib/widgets/room_chat/chat_body.dart new file mode 100644 index 0000000..3b5f752 --- /dev/null +++ b/lib/widgets/room_chat/chat_body.dart @@ -0,0 +1,163 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:measure_size/measure_size.dart"; +import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/controllers/tombstone.dart"; +import "package:nexus/helpers/hooks/chat_composer.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/composer/composer.dart"; +import "package:nexus/widgets/composer/typing_indicator.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; +import "package:nexus/widgets/room_chat/chat_timeline.dart"; +import "package:nexus/widgets/room_chat/tombstone_banner.dart"; + +final class const ChatBody({ + required final ChatParams chat, + required final ChatScroll scroll, + required final JumpToEvent jumpToEvent, + required final IList Function(Event event) getEventOptions, + required final ChatComposer composer, + final void Function(String threadRoot)? onOpenThread, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final roomId = chat.roomId; + final repliedEventRowId = composer.repliedEventRowId; + + final composerSize = useState(64); + final typingIndicatorSize = useState(0); + + final controllerProvider = RoomChatController.provider(chat); + final notifier = ref.watch(controllerProvider.notifier); + final controllerData = ref.watch(controllerProvider); + + final swipeToReply = ref.watch( + SettingsController.provider.select( + (settings) => settings.value?.swipeToReply ?? false, + ), + ); + final tombstone = ref.watch(TombstoneController.provider(roomId)); + final canReply = + tombstone == null && + ref.watch( + PowerLevelController.provider( + .new(eventType: .message, roomId: roomId), + ), + ); + + return Stack( + children: [ + Positioned.fill( + child: Padding( + padding: .symmetric(horizontal: 4), + child: switch (controllerData) { + AsyncLoading(value: _?) || AsyncData(value: _) => ChatTimeline( + scroll: scroll, + jumpToEvent: jumpToEvent, + chat: chat, + getEventOptions: getEventOptions, + onSwipe: swipeToReply && canReply + ? (rowId) => composer.relate(rowId, .reply) + : null, + onOpenThread: onOpenThread, + composerHeight: composerSize.value, + typingIndicatorHeight: typingIndicatorSize.value, + ), + AsyncLoading _ => Loading(), + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + }, + ), + ), + AnimatedPositioned( + duration: Durations.short4, + bottom: composerSize.value, + left: 12, + // Leaves room for the jump to bottom button when it's shown + right: scroll.atBottom ? 12 : 72, + child: MeasureSize( + onChange: (size) => typingIndicatorSize.value = size.height, + child: TypingIndicator(roomId), + ), + ), + Positioned( + right: 16, + bottom: composerSize.value, + child: IgnorePointer( + ignoring: scroll.atBottom, + child: AnimatedOpacity( + opacity: scroll.atBottom ? 0 : 1, + duration: Durations.short4, + child: FloatingActionButton.small( + onPressed: scroll.jumpToBottom, + child: const Icon(Icons.keyboard_arrow_down), + ), + ), + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: MeasureSize( + onChange: (size) => composerSize.value = size.height, + child: switch (tombstone) { + final tombstone? => TombstoneBanner(tombstone), + null => Composer( + roomId, + threadRoot: chat.threadRoot, + node: composer.node, + onSend: (text, {required shouldMention, required tags}) => + notifier + .send( + text, + tags: tags, + relationType: composer.relationType, + shouldMention: shouldMention, + relatedEventRowId: composer.relatedEvent, + ) + .onError(showError), + onSendSticker: (shortcode, image, {required shouldMention}) => + notifier + .sendSticker( + shortcode, + image, + shouldMention: shouldMention, + repliedEventRowId: repliedEventRowId, + ) + .onError(showError), + onSendPoll: (poll, {required shouldMention}) => notifier + .sendPoll( + poll, + shouldMention: shouldMention, + repliedEventRowId: repliedEventRowId, + ) + .onError(showError), + onSendLocation: (location, {required shouldMention}) => notifier + .sendLocation( + location, + shouldMention: shouldMention, + repliedEventRowId: repliedEventRowId, + ) + .onError(showError), + onSendBotCommand: (draft) => notifier + .sendBotCommand(draft, repliedEventRowId: repliedEventRowId) + .onError(showError), + ), + }, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart new file mode 100644 index 0000000..d6aaef7 --- /dev/null +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -0,0 +1,96 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/renderers/event.dart"; +import "package:nexus/widgets/common/highlight_wrapper.dart"; +import "package:super_sliver_list/super_sliver_list.dart"; + +class const ChatTimeline({ + required final ChatParams chat, + required final ChatScroll scroll, + required final JumpToEvent jumpToEvent, + required final IList Function(Event) getEventOptions, + required final double composerHeight, + + /// The typing indicator's height, or zero while it's hidden. + required final double typingIndicatorHeight, + final void Function(int rowId)? onSwipe, + final void Function(String threadRoot)? onOpenThread, + super.key, +}) extends StatelessWidget { + Widget eventRow( + int eventRowId, + int? previousEventRowId, { + required Key key, + }) => HighlightWrapper( + // To work around an AWFUL SuperSliverList bug + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 0.01), + child: EventRenderer( + eventRowId, + previousEventRowId: previousEventRowId, + roomId: chat.roomId, + jumpToEvent: jumpToEvent, + onOpenThread: onOpenThread, + getEventOptions: getEventOptions, + onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId), + readReceiptsChat: chat, + threadRoot: chat.threadRoot, + ), + ), + key: key, + isHighlighted: scroll.highlightedEvent == eventRowId, + ); + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) => TweenAnimationBuilder( + // Makes room for the typing indicator, with a small gap above it, so it + // doesn't cover messages + tween: Tween( + end: typingIndicatorHeight == 0 ? 0.0 : typingIndicatorHeight + 4, + ), + duration: Durations.medium2, + curve: Curves.easeInOutCubicEmphasized, + builder: (context, typingIndicatorGap, _) { + final bottomPadding = composerHeight + typingIndicatorGap; + + return CustomScrollView( + reverse: true, + center: scroll.centerKey, + // Offset 0 is the newest event above the padding, so short + // timelines can't be scrolled past either end + anchor: (bottomPadding / constraints.maxHeight).clamp(0, 1), + keyboardDismissBehavior: .onDrag, + controller: scroll.scrollController, + slivers: [ + SliverToBoxAdapter(child: SizedBox(height: bottomPadding)), + + SuperSliverList.builder( + itemCount: scroll.liveRows.length, + itemBuilder: (_, index) => eventRow( + scroll.liveRows[index], + index > 0 + ? scroll.liveRows.getOrNull(index - 1) + : scroll.historyRows.firstOrNull, + key: scroll.keyFor(scroll.liveRows[index]), + ), + ), + + SuperSliverList.builder( + key: scroll.centerKey, + itemCount: scroll.historyRows.length, + itemBuilder: (_, index) => eventRow( + scroll.historyRows[index], + scroll.historyRows.getOrNull(index + 1), + key: scroll.keyFor(scroll.historyRows[index]), + ), + ), + ], + ); + }, + ), + ); +} diff --git a/lib/widgets/room_chat/member_list.dart b/lib/widgets/room_chat/member_list.dart new file mode 100644 index 0000000..bf86b9c --- /dev/null +++ b/lib/widgets/room_chat/member_list.dart @@ -0,0 +1,181 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.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.dart"; +import "package:nexus/controllers/members_grouped.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/helpers/extensions/displayname_color.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/membership_status.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/common/divider_text.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; + +class const MemberList(final String roomId, {super.key}) + extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final statusIndex = useState(0); + + final options = { + "Joined": .join, + "Invited": .invite, + "Knocked": .knock, + "Banned": .ban, + }; + final status = options.values.toIList()[statusIndex.value]; + + return Drawer( + shape: Border(), + child: Scaffold( + backgroundColor: Colors.transparent, + body: Column( + children: [ + Padding( + padding: .all(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: value.expand((group) { + final MapEntry(key: powerLevel, value: members) = + group; + + return [ + 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, + ) => + Consumer( + builder: (context, ref, _) => + ListTile( + onTap: () => + context.showUserPopover( + members[index].stateKey!, + roomId: roomId, + ), + title: Text( + displayName ?? + members[index] + .stateKey! + .localpart, + overflow: .ellipsis, + style: .new( + color: ref.displaynameColor( + roomId, + members[index].stateKey!, + ), + fontWeight: .bold, + ), + ), + subtitle: Text( + members[index].stateKey!, + overflow: .ellipsis, + ), + leading: Transform.translate( + offset: .new(0, 2), + child: AvatarOrHash( + avatarUrl, + dimension: 40, + displayName ?? + members[index] + .stateKey! + .localpart, + ), + ), + ), + ), + _ => throw Exception( + "Member content was not MembershipContent", + ), + }, + ), + ]; + }).toList(), + ), + ), + AsyncLoading _ => Loading(), + }, + ], + ), + appBar: 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", + ), + ], + ) + : null, + ), + ); + } +} diff --git a/lib/widgets/room_chat/pinned_events_drawer.dart b/lib/widgets/room_chat/pinned_events_drawer.dart new file mode 100644 index 0000000..1e0ab6a --- /dev/null +++ b/lib/widgets/room_chat/pinned_events_drawer.dart @@ -0,0 +1,91 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/pinned_events.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; +import "package:nexus/widgets/renderers/event.dart"; + +final class const PinnedEventsDrawer( + final String roomId, { + required final IList Function(Event event) getEventOptions, + required final JumpToEvent jumpToEvent, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final pinsProvider = ref.watch(PinnedEventsController.provider(roomId)); + final theme = Theme.of(context); + + return Drawer( + width: 400, + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + scrolledUnderElevation: 0, + leading: Icon(Icons.push_pin), + title: Text("Pinned Events"), + actionsPadding: .only(right: 4), + actions: [ + IconButton( + onPressed: Scaffold.of(context).closeEndDrawer, + icon: Icon(Icons.close), + tooltip: "Close pinned events", + ), + ], + ), + body: switch (pinsProvider) { + AsyncData(:final value) when value.isEmpty => Center( + child: Column( + mainAxisSize: .min, + children: [ + Icon( + Icons.push_pin_outlined, + size: 48, + color: theme.colorScheme.onSurface, + ), + SizedBox(height: 12), + Text("No pinned events", style: theme.textTheme.headlineSmall), + ], + ), + ), + AsyncData(:final value) || + AsyncLoading(:final value?) => ListView.builder( + padding: .all(8), + reverse: true, + itemCount: value.length, + itemBuilder: (context, index) { + final rowId = value.reversed[index]; + + return InkWell( + borderRadius: .circular(12), + onTap: () { + Navigator.of(context).pop(); + jumpToEvent(rowId); + }, + child: Padding( + padding: .symmetric(vertical: 4), + child: IgnorePointer( + child: EventRenderer( + rowId, + roomId: roomId, + maxLines: 2, + getEventOptions: getEventOptions, + ), + ), + ), + ); + }, + ), + AsyncLoading() => Loading(), + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + }, + ), + ); + } +} diff --git a/lib/widgets/room_chat/room_appbar.dart b/lib/widgets/room_chat/room_appbar.dart new file mode 100644 index 0000000..14b9df5 --- /dev/null +++ b/lib/widgets/room_chat/room_appbar.dart @@ -0,0 +1,86 @@ +import "package:material_ui/material_ui.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/widgets/common/appbar.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/common/count_badge.dart"; +import "package:nexus/widgets/rooms/room_menu_button.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; +import "package:nexus/widgets/rooms/room_title.dart"; +import "package:nexus/widgets/sidebar/sidebar.dart"; + +final class const RoomAppbar({ + required final String? roomId, + required final bool isDesktop, + required final void Function() onOpenDrawer, + final void Function(BuildContext context)? onOpenMemberList, + final void Function()? onOpenPinnedMessagesList, + final void Function()? onOpenSearch, + final void Function()? onOpenThreads, + super.key, +}) extends ConsumerWidget implements PreferredSizeWidget { + @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?.metadata == null + ? null + : () => context.showAdaptiveSheet( + (context) => RoomSummarySheet(.fromMetadata(room!.metadata!)), + ), + leading: isDesktop + ? room == null + ? null + : AvatarOrHash( + room.metadata?.avatar, + room.metadata?.name ?? "Unnamed Room", + dimension: 24, + fallback: Icon(Icons.numbers), + ) + : IconButton( + onPressed: onOpenDrawer, + tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip, + icon: CountBadge( + Sidebar.totalBadgeCount(ref), + child: const DrawerButtonIcon(), + ), + ), + scrolledUnderElevation: 0, + title: room == null + ? null + : RoomTitle( + room.metadata?.name ?? "Unnamed Room", + topic: room.metadata?.topic, + ), + actions: .new( + room == null + ? [] + : [ + IconButton( + onPressed: onOpenThreads?.call, + icon: Icon(Icons.forum), + tooltip: "Open threads", + ), + IconButton( + onPressed: onOpenPinnedMessagesList?.call, + icon: Icon(Icons.push_pin), + tooltip: "Open pinned messages", + ), + IconButton( + onPressed: () => onOpenMemberList?.call(context), + tooltip: "Open member list", + icon: Icon(Icons.people), + ), + RoomMenuButton(room, onOpenSearch: onOpenSearch), + ], + ), + ); + } +} diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart new file mode 100644 index 0000000..929be84 --- /dev/null +++ b/lib/widgets/room_chat/room_chat.dart @@ -0,0 +1,248 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/member_list_opened.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/helpers/hooks/chat_composer.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/room_chat/pinned_events_drawer.dart"; +import "package:nexus/widgets/room_chat/member_list.dart"; +import "package:nexus/widgets/room_chat/room_appbar.dart"; +import "package:nexus/widgets/room_chat/search_drawer.dart"; +import "package:nexus/widgets/common/sync_status_banner.dart"; +import "package:nexus/widgets/threads/thread_drawer.dart"; +import "package:nexus/widgets/threads/threads_drawer.dart"; +import "package:nexus/widgets/room_chat/chat_body.dart"; +import "package:nexus/helpers/extensions/build_event_options.dart"; +import "package:nexus/controllers/jump_request.dart"; + +final class const RoomChat({ + required final String? roomId, + required final bool isDesktop, + required final bool showMembersByDefault, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final pendingJump = ref.read(JumpRequestController.provider); + final contextualEvent = useState(switch (pendingJump) { + // Threaded events open their thread instead, leaving the timeline + (:final roomId, :final rowId) + when roomId == this.roomId && + ref + .read(RoomsController.provider)[roomId] + ?.events[rowId] + ?.threadRoot == + null => + rowId, + _ => null, + }); + + final thread = useState<({String root, int? event})?>(null); + final threadScaffold = useMemoized(GlobalKey.new); + + final userId = ref.watch(ClientStateController.provider)?.userId; + final memberListOpened = ref + .watch(MemberListOpenedController.provider) + .requireValue; + 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, + ), + body: Column( + children: [ + const SyncStatusBanner(), + Expanded(child: nothing), + ], + ), + ); + } + + final roomId = this.roomId!; + final composer = ChatComposer.use(ref, (roomId: roomId, threadRoot: null)); + + final jumpRowId = ref.watch( + JumpRequestController.provider.select( + (request) => request?.roomId == roomId ? request?.rowId : null, + ), + ); + + final ChatParams chat = ( + roomId: roomId, + contextualEvent: contextualEvent.value, + threadRoot: null, + ); + final controllerProvider = RoomChatController.provider(chat); + final notifier = ref.watch(controllerProvider.notifier); + + final client = ref.read(ClientController.provider.notifier); + + final controllerData = ref.watch(controllerProvider); + + final scroll = ChatScroll.use( + controllerData: controllerData, + paginate: notifier.paginate, + contextualEvent: contextualEvent, + markRead: () async { + final room = ref.read( + RoomsController.provider.select((rooms) => rooms[roomId]), + ); + + if (room != null) { + await client.markRead(room); + } + }, + ); + + void openThread(String root, {int? event}) { + thread.value = (root: root, event: event); + + // The drawer is only there once rebuilt with the thread + WidgetsBinding.instance.addPostFrameCallback( + (_) => threadScaffold.currentState?.openEndDrawer(), + ); + } + + // Threaded events aren't in the timeline, only their thread + Future jumpToEvent(int rowId) async { + final threadRoot = ref + .read(RoomsController.provider)[roomId] + ?.events[rowId] + ?.threadRoot; + + if (threadRoot == null) return scroll.jumpToEvent(rowId); + openThread(threadRoot, event: rowId); + } + + useEffect(() { + if (jumpRowId == null) return null; + + void check() { + if (!context.mounted) return; + + if (scroll.scrollController.hasClients) { + jumpToEvent(jumpRowId); + Future.microtask( + () => ref.read(JumpRequestController.provider.notifier).consume(), + ); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) => check()); + } + } + + check(); + + return null; + }, [jumpRowId]); + + IList getEventOptions(Event event) => + event.buildEventOptions( + context: context, + ref: ref, + roomId: roomId, + userId: userId, + onRelation: composer.relate, + onStartThread: openThread, + ); + + return Scaffold( + key: threadScaffold, + endDrawerEnableOpenDragGesture: false, + endDrawer: switch (thread.value) { + (:final root, :final event) => ThreadDrawer( + roomId, + threadRoot: root, + initialEvent: event, + jumpToRoomEvent: jumpToEvent, + userId: userId, + key: ValueKey((roomId, root, event)), + ), + null => null, + }, + body: Scaffold( + endDrawerEnableOpenDragGesture: false, + endDrawer: SearchDrawer( + roomId, + getEventOptions: getEventOptions, + jumpToEvent: jumpToEvent, + ), + body: Builder( + builder: (searchContext) => Scaffold( + endDrawerEnableOpenDragGesture: false, + endDrawer: ThreadsDrawer(roomId, onOpenThread: openThread), + body: Builder( + builder: (threadsContext) => Scaffold( + endDrawerEnableOpenDragGesture: false, + endDrawer: PinnedEventsDrawer( + roomId, + getEventOptions: getEventOptions, + jumpToEvent: jumpToEvent, + ), + body: Builder( + builder: (middleContext) => Scaffold( + endDrawer: showMembersByDefault ? null : MemberList(roomId), + appBar: RoomAppbar( + roomId: roomId, + isDesktop: isDesktop, + onOpenDrawer: Scaffold.of(context).openDrawer, + onOpenMemberList: (thisContext) { + ref + .read(MemberListOpenedController.provider.notifier) + .set(!memberListOpened); + Scaffold.of(thisContext).openEndDrawer(); + }, + onOpenPinnedMessagesList: Scaffold.of(middleContext) + .openEndDrawer, + onOpenSearch: Scaffold.of(searchContext).openEndDrawer, + onOpenThreads: Scaffold.of(threadsContext).openEndDrawer, + ), + body: Column( + children: [ + const SyncStatusBanner(), + Expanded( + child: Row( + children: [ + Expanded( + child: ChatBody( + chat: chat, + scroll: scroll, + jumpToEvent: jumpToEvent, + getEventOptions: getEventOptions, + composer: composer, + onOpenThread: openThread, + ), + ), + + if (memberListOpened && showMembersByDefault) + MemberList(roomId), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/room_chat/search_drawer.dart b/lib/widgets/room_chat/search_drawer.dart new file mode 100644 index 0000000..2905789 --- /dev/null +++ b/lib/widgets/room_chat/search_drawer.dart @@ -0,0 +1,147 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/event_search.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; +import "package:nexus/widgets/renderers/event.dart"; + +final class const SearchDrawer( + final String roomId, { + required final IList Function(Event event) getEventOptions, + required final JumpToEvent jumpToEvent, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useTextEditingController(); + final query = useState(""); + final provider = EventSearchController.provider(( + roomId: roomId, + query: query.value, + )); + final results = ref.watch(provider); + final theme = Theme.of(context); + + void search() { + final text = controller.text.trim(); + + // The provider is keyed on the query, so searching again needs a refetch + if (text == query.value) { + ref.invalidate(provider, asReload: true); + } else { + query.value = text; + } + } + + Widget placeholder(IconData icon, String text) => Center( + child: Column( + mainAxisSize: .min, + children: [ + Icon(icon, size: 48, color: theme.colorScheme.onSurface), + SizedBox(height: 12), + Text(text, style: theme.textTheme.headlineSmall), + ], + ), + ); + + return Drawer( + width: 400, + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + scrolledUnderElevation: 0, + leading: Icon(Icons.search), + title: Text("Search"), + actionsPadding: .only(right: 4), + actions: [ + IconButton( + onPressed: Scaffold.of(context).closeEndDrawer, + icon: Icon(Icons.close), + tooltip: "Close search", + ), + ], + ), + body: Column( + children: [ + Padding( + padding: .symmetric(horizontal: 12, vertical: 4), + child: TextField( + controller: controller, + autofocus: true, + textInputAction: .search, + onSubmitted: (_) => search(), + decoration: .new( + hintText: "Search this room", + prefixIcon: Icon(Icons.search), + suffixIcon: IconButton( + onPressed: search, + icon: Icon(Icons.arrow_forward), + tooltip: "Search", + ), + ), + ), + ), + Expanded( + child: query.value.isEmpty + ? placeholder(Icons.search, "Search for events") + : switch (results) { + AsyncData(:final value) when value.rowIds.isEmpty => + placeholder(Icons.search_off, "No results"), + AsyncData(:final value) || + AsyncLoading(:final value?) => ListView.builder( + padding: .all(8), + itemCount: + value.rowIds.length + + (value.nextBatch == null ? 0 : 1), + itemBuilder: (context, index) { + if (index == value.rowIds.length) { + return Center( + child: TextButton( + onPressed: results.isLoading + ? null + : () => ref + .read(provider.notifier) + .paginate() + .onError(showError), + child: Text("Load more"), + ), + ); + } + + final rowId = value.rowIds[index]; + return InkWell( + borderRadius: .circular(12), + onTap: () { + Navigator.of(context).pop(); + jumpToEvent(rowId); + }, + child: Padding( + padding: .symmetric(vertical: 4), + child: IgnorePointer( + child: EventRenderer( + rowId, + roomId: roomId, + maxLines: 2, + getEventOptions: getEventOptions, + ), + ), + ), + ); + }, + ), + AsyncLoading() => Loading(), + AsyncError(:final error, :final stackTrace) => + ErrorDialog(error, stackTrace), + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/room_chat/tombstone_banner.dart b/lib/widgets/room_chat/tombstone_banner.dart new file mode 100644 index 0000000..8227ba7 --- /dev/null +++ b/lib/widgets/room_chat/tombstone_banner.dart @@ -0,0 +1,56 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/open_link_info.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/tombstone.dart"; +import "package:nexus/models/event.dart"; + +class const TombstoneBanner(final Event tombstone, {super.key}) + extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final content = tombstone.content; + final sender = tombstone.sender; + + return Container( + color: theme.colorScheme.surfaceContainerHighest, + padding: .symmetric(horizontal: 16, vertical: 8), + margin: .only(top: 4), + child: Row( + spacing: 12, + children: [ + Icon(Icons.upgrade), + Expanded( + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text( + "This room has been replaced and is no longer active.", + style: .new(fontWeight: .bold), + ), + if (content case TombstoneContent(:final body) + when body.isNotEmpty) + Text(body, style: theme.textTheme.bodySmall), + ], + ), + ), + if (content case TombstoneContent(:final replacementRoom)) + FilledButton.icon( + onPressed: () => ref + .openLinkInfo(context, ( + identifier: replacementRoom, + eventId: null, + via: [sender.substring(sender.indexOf(":") + 1)].lock, + )) + .onError(showError), + label: Text("New room"), + icon: Icon(Icons.open_in_new), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/rooms/add_directory_server_sheet.dart b/lib/widgets/rooms/add_directory_server_sheet.dart new file mode 100644 index 0000000..2fefe3b --- /dev/null +++ b/lib/widgets/rooms/add_directory_server_sheet.dart @@ -0,0 +1,54 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; + +class const AddDirectoryServerSheet({ + required final ISet existing, + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(); + final text = useValueListenable(controller).text; + final server = text.trim().toLowerCase(); + final error = server.isEmpty + ? null + : !server.isValidServerName + ? "Enter a server name, without https:// or a path" + : existing.contains(server) + ? "This server has already been added" + : null; + final valid = server.isNotEmpty && error == null; + + void submit() { + if (valid) Navigator.of(context).pop(server); + } + + return SheetLayout( + title: const Text("Add Server"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton(onPressed: valid ? submit : null, child: const Text("Add")), + ], + children: [ + const Text("Enter a server to browse the public rooms of."), + TextField( + controller: controller, + autofocus: true, + keyboardType: .url, + onSubmitted: (_) => submit(), + decoration: .new( + labelText: "Server name", + hintText: "matrix.org", + errorText: error, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/rooms/add_space_rooms_sheet.dart b/lib/widgets/rooms/add_space_rooms_sheet.dart new file mode 100644 index 0000000..e78ba7a --- /dev/null +++ b/lib/widgets/rooms/add_space_rooms_sheet.dart @@ -0,0 +1,181 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/space_hierarchy.dart"; +import "package:nexus/controllers/via.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/space_child.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/settings/search_chooser.dart"; + +/// Chooses rooms to add to the space [spaceId], then adds them. +class const AddSpaceRoomsSheet( + final String spaceId, { + required final String spaceName, + + /// The rooms already in the space, which aren't offered. + required final ISet existing, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final chosen = useState(const ISet.empty()); + final error = useState(null); + final resolving = useState(false); + final controller = useTextEditingController(); + final rooms = ref.watch( + RoomsController.provider.select( + (rooms) => IMap.fromEntries( + rooms.values + .map((room) => room.metadata) + .nonNulls + .map( + (metadata) => MapEntry(metadata.id, ( + name: metadata.name, + avatar: metadata.avatar, + )), + ), + ), + ), + ); + + String nameOf(String id) => rooms[id]?.name ?? id; + + final suggestions = rooms.entries + .where((entry) => entry.key != spaceId && !existing.contains(entry.key)) + .sortedBy((entry) => entry.value.name?.toLowerCase() ?? "") + .map((entry) => entry.key) + .toIList(); + + void add(String id) { + if (id == spaceId) { + error.value = "A space can't contain itself"; + return; + } + if (existing.contains(id)) { + error.value = "This room is already in the space"; + return; + } + if (!chosen.value.contains(id)) chosen.value = chosen.value.add(id); + controller.clear(); + } + + Future addRoom(String text) async { + if (text.startsWith("!")) return add(text); + + resolving.value = true; + try { + final id = await ref + .read(ClientController.provider.notifier) + .resolveAlias(text); + + if (!context.mounted) return; + + if (id == null) { + error.value = "No room has this alias"; + } else { + add(id); + } + } finally { + if (context.mounted) resolving.value = false; + } + } + + // Runs after the sheet closes, so it can't use [ref] + Future save(ProviderContainer container, ISet roomIds) async { + final client = container.read(ClientController.provider.notifier); + final joined = container.read(RoomsController.provider); + final ownServer = container + .read(ClientStateController.provider) + ?.userId + ?.serverName; + + await Future.wait( + roomIds.map( + (roomId) => client.setState( + .new( + roomId: spaceId, + type: EventType.spaceChild.type, + stateKey: roomId, + content: SpaceChildContent( + via: switch (joined[roomId]) { + final room? => container.read(ViaController.provider(room)), + // Rooms the user isn't in can only be routed through their + // own server + null => ISet([?ownServer]), + }.toIList(), + ), + ), + ), + ), + ); + container.invalidate(SpaceHierarchyController.provider(spaceId)); + } + + return SheetLayout( + title: Text("Add Rooms"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton( + onPressed: chosen.value.isEmpty + ? null + : () { + final container = ProviderScope.containerOf(context); + final roomIds = chosen.value; + SpaceHierarchyController.addChildrenMutation(spaceId).run( + container, + (_) => context.withProgressSnackbar( + "Adding ${roomIds.length == 1 ? nameOf(roomIds.single) : "${roomIds.length} rooms"} to $spaceName...", + () => save(container, roomIds), + ), + ); + Navigator.of(context).pop(); + }, + child: Text("Add"), + ), + ], + children: [ + Text("Choose rooms to add to \"$spaceName\"."), + SearchChooser( + controller: controller, + chosen: chosen.value, + options: suggestions, + nameOf: nameOf, + avatarOf: (id) => AvatarOrHash( + rooms[id]?.avatar, + nameOf(id), + fallback: Icon(Icons.numbers), + ), + subtitleOf: (id) => rooms[id]?.name == null ? null : id, + onAdded: add, + onRemoved: (id) => chosen.value = chosen.value.remove(id), + onSubmitted: (text, topMatch) { + if (text.startsWith(RegExp("[!#]"))) { + addRoom(text).onError(showError); + } else if (topMatch != null) { + add(topMatch); + } else { + error.value = "Enter a room ID or alias"; + } + }, + onChanged: (_) => error.value = null, + label: "Add room", + hint: "Search, or enter a room ID or alias", + removeTooltip: "Remove room", + error: error.value, + loading: resolving.value, + autofocus: true, + ), + ], + ); + } +} diff --git a/lib/widgets/rooms/create_room_sheet.dart b/lib/widgets/rooms/create_room_sheet.dart new file mode 100644 index 0000000..d91c2f4 --- /dev/null +++ b/lib/widgets/rooms/create_room_sheet.dart @@ -0,0 +1,149 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/helpers/extensions/create_room.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; + +class const CreateRoomSheet({super.key}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final serverName = ref + .watch(ClientStateController.provider) + ?.userId + ?.serverName; + final nameController = useTextEditingController(); + final topicController = useTextEditingController(); + final aliasController = useTextEditingController(); + final name = useValueListenable(nameController).text.trim(); + final isPublic = useState(false); + final isPublished = useState(false); + final isEncrypted = useState(false); + final isSpace = useState(false); + + void submit() { + if (name.isEmpty) return; + + final topic = topicController.text.trim(); + final alias = aliasController.text.trim(); + + context.createRoom( + .new( + name: name, + topic: topic.isEmpty ? null : topic, + roomAliasName: isPublic.value && alias.isNotEmpty ? alias : null, + visibility: isPublic.value && isPublished.value ? .public : .private, + preset: isPublic.value ? .publicChat : .privateChat, + initialState: isEncrypted.value && !isSpace.value + ? .new([.encryption]) + : const .empty(), + creationContent: isSpace.value ? .new({"type": "m.space"}) : null, + ), + name: name, + ); + Navigator.of(context).pop(); + } + + AnimatedSize animated({ + required bool visible, + required Widget child, + double gap = 12, + }) => AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: visible + ? Padding( + padding: .only(top: gap), + child: child, + ) + : const SizedBox(width: .infinity), + ); + + return SheetLayout( + title: Text("Create a ${isSpace.value ? "Space" : "Room"}"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: name.isEmpty ? null : submit, + child: const Text("Create"), + ), + ], + children: [ + TextField( + controller: nameController, + autofocus: true, + textCapitalization: .sentences, + onSubmitted: (_) => submit(), + decoration: const .new(labelText: "Name"), + ), + TextField( + controller: topicController, + textCapitalization: .sentences, + decoration: const .new(labelText: "Topic (optional)"), + ), + Column( + children: [ + SwitchListTile( + secondary: const Icon(Icons.public), + title: const Text("Public"), + subtitle: const Text("Anyone can join"), + value: isPublic.value, + onChanged: (value) { + isPublic.value = value; + if (value) isEncrypted.value = false; + }, + ), + animated( + visible: isPublic.value, + child: TextField( + controller: aliasController, + decoration: .new( + labelText: "Canonical alias (optional)", + prefixText: "# ", + suffixText: serverName == null ? null : ":$serverName", + hintText: "my-room", + ), + ), + ), + animated( + visible: isPublic.value, + child: SwitchListTile( + secondary: const Icon(Icons.travel_explore), + title: const Text("Publish to room directory"), + subtitle: const Text("List it in your homeserver's directory"), + value: isPublished.value, + onChanged: (value) => isPublished.value = value, + ), + ), + ], + ), + Column( + children: [ + SwitchListTile( + secondary: const Icon(Icons.workspaces), + title: const Text("Space"), + subtitle: const Text("A group of rooms"), + value: isSpace.value, + onChanged: (value) => isSpace.value = value, + ), + animated( + visible: !isSpace.value, + child: SwitchListTile( + secondary: const Icon(Icons.lock), + title: const Text("Encrypted"), + subtitle: const Text("Can't be turned off later"), + value: isEncrypted.value, + onChanged: (value) => isEncrypted.value = value, + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/rooms/join_sheet.dart b/lib/widgets/rooms/join_sheet.dart new file mode 100644 index 0000000..e9ee719 --- /dev/null +++ b/lib/widgets/rooms/join_sheet.dart @@ -0,0 +1,61 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/room_summary.dart"; +import "package:nexus/helpers/extensions/get_link_info.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; + +class const JoinSheet(final WidgetRef ref, {super.key}) extends HookWidget { + @override + Widget build(BuildContext context) { + final roomAlias = useTextEditingController(); + + Future onJoin() async { + Navigator.of(context).pop(); + + final linkInfo = roomAlias.text.linkInfo; + final via = linkInfo?.via ?? const .empty(); + final roomIdOrAlias = linkInfo?.identifier ?? roomAlias.text; + + context.showAdaptiveSheet( + (context) => Consumer( + builder: (context, ref, _) => switch (ref.watch( + RoomSummaryController.provider( + .new(roomIdOrAlias: roomIdOrAlias, via: via), + ), + )) { + AsyncData(:final value) => RoomSummarySheet(value, via: via), + AsyncError _ || AsyncLoading _ => RoomSummarySheet( + .new(roomId: roomIdOrAlias), + via: via, + ), + }, + ), + ); + } + + return SheetLayout( + title: Text("Join a Room"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton(onPressed: onJoin, child: Text("Join")), + ], + children: [ + 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.name"), + onEditingComplete: onJoin, + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/rooms/knock_sheet.dart b/lib/widgets/rooms/knock_sheet.dart new file mode 100644 index 0000000..524856c --- /dev/null +++ b/lib/widgets/rooms/knock_sheet.dart @@ -0,0 +1,51 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/helpers/extensions/join_room.dart"; + +/// Asks the moderators of a room to let the user in, with an optional reason. +/// +/// Pops with true once the request has been sent. +class const KnockSheet( + final String roomId, { + required final String name, + final IList via = const .empty(), + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final reason = useTextEditingController(); + + void onKnock() { + context.knockRoom( + .new( + roomIdOrAlias: roomId, + via: via, + reason: reason.text.isEmpty ? null : reason.text, + ), + name: name, + ); + Navigator.of(context).pop(true); + } + + return SheetLayout( + title: Text("Knock on $name"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton(onPressed: onKnock, child: Text("Knock")), + ], + children: [ + Text( + "This room requires knocking to join. Its moderators will be able to see your knock and let you in.", + ), + TextField( + textCapitalization: .sentences, + controller: reason, + decoration: .new(labelText: "Reason (optional)"), + onEditingComplete: onKnock, + ), + ], + ); + } +} diff --git a/lib/widgets/rooms/room_directory_card.dart b/lib/widgets/rooms/room_directory_card.dart new file mode 100644 index 0000000..10cc071 --- /dev/null +++ b/lib/widgets/rooms/room_directory_card.dart @@ -0,0 +1,130 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/sidebar_page.dart"; +import "package:nexus/helpers/extensions/focus_room.dart"; +import "package:nexus/helpers/extensions/join_room.dart"; +import "package:nexus/helpers/extensions/room_summary_display.dart"; +import "package:nexus/models/room_summary.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/rooms/knock_sheet.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; + +class const RoomDirectoryCard( + final RoomSummary summary, { + required final IList via, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final joined = ref.watch( + RoomsController.provider.select( + (rooms) => rooms.containsKey(summary.roomId), + ), + ); + final mustKnock = ref.watch( + RoomsController.provider.select( + (rooms) => summary.mustKnock( + summary.allowedRoomIds.where(rooms.containsKey).toISet(), + ), + ), + ); + final theme = Theme.of(context); + final name = summary.displayName; + + return Card.filled( + clipBehavior: .antiAlias, + margin: .zero, + child: InkWell( + onTap: () => context.showAdaptiveSheet( + (_) => RoomSummarySheet(summary, via: joined ? null : via), + ), + child: Padding( + padding: const .all(16), + child: Column( + crossAxisAlignment: .stretch, + spacing: 12, + children: [ + Row( + spacing: 12, + children: [ + AvatarOrHash( + summary.avatarUrl, + name, + dimension: 48, + fallback: Icon(summary.icon), + ), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + Text( + name, + maxLines: 1, + overflow: .ellipsis, + style: theme.textTheme.titleMedium, + ), + if (summary.details case final details + when details.isNotEmpty) + Text( + details, + maxLines: 1, + overflow: .ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + Expanded( + child: Text( + summary.topic ?? summary.canonicalAlias ?? "", + maxLines: 3, + overflow: .ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Align( + alignment: .centerRight, + child: joined + ? FilledButton.tonal( + onPressed: () async { + final container = ProviderScope.containerOf(context); + Navigator.of(context).pop(); + await container.focusRoom(summary.roomId); + container + .read(SidebarPageController.provider.notifier) + .set(.rooms); + }, + child: const Text("Open"), + ) + : mustKnock + ? FilledButton( + onPressed: () => context.showAdaptiveSheet( + (_) => + KnockSheet(summary.roomId, name: name, via: via), + ), + child: const Text("Knock"), + ) + : FilledButton( + onPressed: () => context.joinRoom( + .new(roomIdOrAlias: summary.roomId, via: via), + name: name, + ), + child: const Text("Join"), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/rooms/room_menu_button.dart b/lib/widgets/rooms/room_menu_button.dart new file mode 100644 index 0000000..22d94f9 --- /dev/null +++ b/lib/widgets/rooms/room_menu_button.dart @@ -0,0 +1,200 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_leave_room_sheet.dart"; +import "package:flutter/services.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/account_data.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/models/room_notification_level.dart"; +import "package:nexus/controllers/room_settings_sections.dart"; +import "package:nexus/widgets/settings/settings_layout.dart"; +import "package:nexus/controllers/via.dart"; +import "package:nexus/models/room.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:nexus/main.dart"; + +final class const RoomMenuButton( + final Room? room, { + final IList children = const .empty(), + final VoidCallback? onOpenSearch, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final danger = Theme.of(context).colorScheme.error; + final client = ref.read(ClientController.provider.notifier); + + return AdaptiveMenuButton( + icon: const Icon(Icons.more_vert), + tooltip: "Room options", + itemBuilder: (_) => [ + if (onOpenSearch != null) + PopupMenuItem( + onTap: onOpenSearch, + child: ListTile(leading: Icon(Icons.search), title: Text("Search")), + ), + PopupMenuItem( + onTap: () async { + if (room != null) await client.markRead(room!); + await Future.wait(children.map((child) => client.markRead(child))); + }, + child: ListTile( + leading: Icon(Icons.check), + title: Text("Mark as Read"), + ), + ), + if (room != null) ...[ + PopupMenuItem( + onTap: () async { + final roomId = room!.metadata!.id; + final accountData = ref.read( + AccountDataController.provider.notifier, + ); + final current = RoomNotificationLevel.of( + ref.read(AccountDataController.provider).pushRules.global, + roomId, + ); + + final level = await context.showAdaptiveMenu( + RoomNotificationLevel.values + .map( + (level) => PopupMenuItem( + value: level, + child: ListTile( + selected: level == current, + leading: Icon(level.icon), + title: Text(level.title), + subtitle: Text(level.description), + trailing: level == current ? Icon(Icons.check) : null, + ), + ), + ) + .toList(), + ); + if (level == null) return; + await accountData + .setRoomNotificationLevel(roomId, level) + .onError(showError); + }, + child: ListTile( + leading: Icon(Icons.notifications), + title: Text("Notifications"), + trailing: Icon(Icons.expand_more), + ), + ), + PopupMenuItem( + onTap: () async { + final vias = ref.read(ViaController.provider(room!)).viaQuery; + + 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: () => context.showAdaptiveSheet( + (context) => HookBuilder( + builder: (context) { + final userIdController = useTextEditingController(); + final reasonController = useTextEditingController(); + final text = useValueListenable(userIdController).text.trim(); + final valid = text.isValidUserId; + + void submit() { + if (!valid) return; + Navigator.of(context).pop(); + final reason = reasonController.text.trim(); + client + .setMembership( + .new( + userId: text, + roomId: room!.metadata!.id, + action: .invite, + reason: reason.isEmpty ? null : reason, + ), + ) + .onError(showError); + } + + return SheetLayout( + title: Text("Invite User"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: valid ? submit : null, + child: Text("Invite"), + ), + ], + children: [ + TextField( + controller: userIdController, + autofocus: true, + keyboardType: .emailAddress, + onSubmitted: (_) => submit(), + decoration: .new( + labelText: "User ID", + hintText: "@user:example.com", + errorText: text.isEmpty || valid + ? null + : "Enter a valid user ID", + ), + ), + TextField( + controller: reasonController, + textCapitalization: .sentences, + onSubmitted: (_) => submit(), + decoration: const .new( + labelText: "Invite reason (optional)", + ), + ), + ], + ); + }, + ), + ), + child: ListTile( + leading: Icon(Icons.person_add), + title: Text("Invite"), + ), + ), + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (_) => SettingsLayout( + title: "Room Settings", + sectionsProvider: RoomSettingsSectionsController.provider( + room!.metadata!.id, + ), + ), + ), + child: ListTile( + leading: Icon(Icons.settings), + title: Text("Settings"), + ), + ), + PopupMenuItem( + onTap: () => context.showLeaveRoomSheet( + room!.metadata!.id, + name: room!.metadata?.name ?? "Unnamed Room", + ), + child: ListTile( + leading: Icon(Icons.logout, color: danger), + title: Text("Leave", style: .new(color: danger)), + ), + ), + ], + ], + ); + } +} diff --git a/lib/widgets/rooms/room_summary_sheet.dart b/lib/widgets/rooms/room_summary_sheet.dart new file mode 100644 index 0000000..2195101 --- /dev/null +++ b/lib/widgets/rooms/room_summary_sheet.dart @@ -0,0 +1,209 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/invites.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/join_room.dart"; +import "package:nexus/helpers/extensions/room_summary_display.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/common/expandable_image.dart"; +import "package:nexus/widgets/rooms/knock_sheet.dart"; +import "package:nexus/widgets/common/linkified_text.dart"; +import "package:nexus/models/room_summary.dart"; + +class const RoomSummarySheet( + final RoomSummary summary, { + final IList? via, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final name = summary.name ?? summary.canonicalAlias ?? summary.roomId; + final isInvite = summary.membership == .invite; + final isKnocking = summary.membership == .knock; + final mustKnock = summary.mustKnock( + ref.watch( + RoomsController.provider.select((rooms) => rooms.keys.toISet()), + ), + ); + final colorScheme = Theme.of(context).colorScheme; + + return SheetLayout( + title: Row( + spacing: 12, + mainAxisSize: .min, + children: [ + if (summary.avatarUrl != null) + ExpandableImage( + summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!), + downloadFilename: name, + child: AvatarOrHash( + summary.avatarUrl, + "", + dimension: 64, + fallback: Icon(summary.icon), + ), + ), + Expanded( + child: Text(summary.displayName, overflow: .ellipsis, maxLines: 3), + ), + ], + ), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + if (isInvite) ...[ + TextButton( + onPressed: () { + final container = ProviderScope.containerOf(context); + Navigator.of(context).pop(); + + InvitesController.respondMutation(summary.roomId) + .run( + container, + (_) => container + .read(InvitesController.provider.notifier) + .decline(summary.roomId), + ) + .onError(showError); + }, + style: TextButton.styleFrom(foregroundColor: colorScheme.error), + child: Text("Decline"), + ), + TextButton( + onPressed: () { + final container = ProviderScope.containerOf(context); + + InvitesController.respondMutation(summary.roomId) + .run(container, (_) async { + final roomId = await context.joinRoom( + .new(roomIdOrAlias: summary.roomId, fromInvite: true), + name: name, + ); + if (roomId != null) { + container + .read(InvitesController.provider.notifier) + .remove(summary.roomId); + } + }) + .onError(showError); + Navigator.of(context).pop(); + }, + child: Text("Accept"), + ), + ] else if (isKnocking) + TextButton( + onPressed: () { + final container = ProviderScope.containerOf(context); + Navigator.of(context).pop(); + + container + .read(ClientController.provider.notifier) + .leaveRoom(summary.roomId) + .onError(showError); + }, + style: TextButton.styleFrom(foregroundColor: colorScheme.error), + child: Text("Retract knock"), + ) + else if (via != null && mustKnock) + TextButton( + onPressed: () async { + if (await context.showAdaptiveSheet( + (_) => + KnockSheet(summary.roomId, name: name, via: via!), + ) == + true && + context.mounted) { + Navigator.of(context).pop(); + } + }, + child: Text("Knock"), + ) + else if (via != null) + TextButton( + onPressed: () { + context.joinRoom( + .new(roomIdOrAlias: summary.roomId, via: via!), + name: name, + ); + Navigator.of(context).pop(); + }, + child: Text("Join"), + ), + ], + children: [ + Column( + children: [ + if (summary.invitedBy case final invitedBy?) + ListTile( + leading: AvatarOrHash( + summary.inviterProfile?.avatarUrl, + summary.inviterProfile?.displayName ?? invitedBy.localpart, + ), + title: Text( + "${summary.isDirect ? "Direct message from" : "Invited by"} ${summary.inviterProfile?.displayName ?? invitedBy.localpart}", + ), + subtitle: SelectableText(invitedBy), + ), + if (summary.inviteReason case final reason?) + ListTile( + titleAlignment: ListTileTitleAlignment.titleHeight, + leading: const Icon(Icons.format_quote), + title: const Text("Invite reason"), + subtitle: SelectableText(reason), + ), + + if (summary.joinedMembers != null) + ListTile( + title: Text( + "${summary.joinedMembers ?? "Unknown number of"} members", + ), + leading: Icon(Icons.people), + ), + + if (summary.canonicalAlias case final canonicalAlias?) + ListTile( + title: Text("Canonical Alias"), + subtitle: SelectableText(canonicalAlias), + leading: Icon(Icons.numbers), + ), + + ListTile( + title: Text("Room ID"), + subtitle: SelectableText(summary.roomId), + leading: Icon(Icons.badge), + ), + + if (summary.topic case final topic?) + ListTile( + title: Text("Topic"), + subtitle: LinkifiedText(topic, roomId: summary.roomId), + leading: Icon(Icons.info), + ), + if (isKnocking) + ListTile( + title: Text("You've knocked"), + subtitle: Text("Waiting for a moderator to let you in"), + leading: Icon(Icons.hourglass_top), + ) + else if (mustKnock) + ListTile( + title: Text("Knock to join"), + leading: Icon(Icons.door_front_door), + ), + if (summary.encryption != null) + ListTile(title: Text("Encrypted"), leading: Icon(Icons.lock)), + if (summary.worldReadable == true) + ListTile( + title: Text("Anyone can read room history"), + leading: Icon(Icons.public), + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/rooms/room_title.dart b/lib/widgets/rooms/room_title.dart new file mode 100644 index 0000000..94eacc8 --- /dev/null +++ b/lib/widgets/rooms/room_title.dart @@ -0,0 +1,22 @@ +import "package:material_ui/material_ui.dart"; + +/// A room or space's name, with its topic underneath if it has one, for app +/// bars. +class const RoomTitle(final String name, {final String? topic, super.key}) + extends StatelessWidget { + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: .start, + children: [ + Text(name, overflow: .ellipsis, maxLines: 1), + if (topic?.isNotEmpty == true) + Text( + topic!, + maxLines: 1, + overflow: .ellipsis, + style: Theme.of(context).textTheme.labelMedium + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ); +} diff --git a/lib/widgets/rooms/space_hierarchy_sheet.dart b/lib/widgets/rooms/space_hierarchy_sheet.dart new file mode 100644 index 0000000..65fb5da --- /dev/null +++ b/lib/widgets/rooms/space_hierarchy_sheet.dart @@ -0,0 +1,405 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/space_hierarchy.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/join_room.dart"; +import "package:nexus/helpers/extensions/room_summary_display.dart"; +import "package:nexus/helpers/extensions/show_leave_room_sheet.dart"; +import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/space_hierarchy.dart"; +import "package:nexus/widgets/rooms/add_space_rooms_sheet.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/common/expandable_image.dart"; +import "package:nexus/widgets/rooms/knock_sheet.dart"; +import "package:nexus/widgets/common/loading.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; + +class const SpaceHierarchySheet(final String spaceId, {super.key}) + extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final joinedIds = ref.watch( + RoomsController.provider.select((rooms) => rooms.keys.toISet()), + ); + + void showSummary(SpaceHierarchyRoom room, SpaceHierarchyChild child) => + context.showAdaptiveSheet( + (_) => RoomSummarySheet( + room.summary, + via: joinedIds.contains(room.summary.roomId) ? null : child.via, + ), + ); + + Widget membershipButton( + SpaceHierarchyRoom room, + SpaceHierarchyChild child, { + required String name, + }) => joinedIds.contains(room.summary.roomId) + ? TextButton( + onPressed: () => + context.showLeaveRoomSheet(room.summary.roomId, name: name), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text("Leave"), + ) + : room.summary.mustKnock(joinedIds) + ? TextButton( + onPressed: () => context.showAdaptiveSheet( + (_) => + KnockSheet(room.summary.roomId, name: name, via: child.via), + ), + child: const Text("Knock"), + ) + : TextButton( + onPressed: () => context.joinRoom( + .new(roomIdOrAlias: room.summary.roomId, via: child.via), + name: name, + ), + child: const Text("Join"), + ); + + Widget removeButton( + String spaceId, + String roomId, { + required String name, + required String spaceName, + }) => Consumer( + builder: (context, ref, _) { + final mutation = SpaceHierarchyController.removeChildMutation(( + spaceId, + roomId, + )); + if (ref.watch(mutation).isPending) { + return const Padding( + padding: .all(12), + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(), + ), + ); + } + + return IconButton( + tooltip: "Remove from space", + icon: const Icon(Icons.remove_circle_outline), + onPressed: () => context.showAdaptiveSheet( + (context) => SheetLayout( + title: const Text("Remove Room"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: () { + final container = ProviderScope.containerOf(context); + mutation.run( + container, + (_) => context.withProgressSnackbar( + "Removing $name from $spaceName...", + () async { + await container + .read(ClientController.provider.notifier) + .setState( + .new( + roomId: spaceId, + type: EventType.spaceChild.type, + stateKey: roomId, + content: const Content(), + ), + ); + container.invalidate( + SpaceHierarchyController.provider(spaceId), + ); + }, + ), + ); + Navigator.of(context).pop(); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text("Remove"), + ), + ], + children: [ + Text( + "Are you sure you want to remove \"$name\" from \"$spaceName\"? Its members will stay in it.", + ), + ], + ), + ), + ); + }, + ); + + Widget level( + String spaceId, + SpaceHierarchy hierarchy, + ISet ancestors, + ) => HookConsumer( + builder: (context, ref, _) { + final loadingMore = useState(false); + final adding = ref + .watch(SpaceHierarchyController.addChildrenMutation(spaceId)) + .isPending; + final canManage = ref.watch( + PowerLevelController.provider( + .state(eventType: .spaceChild, roomId: spaceId), + ), + ); + + final byId = IMap.fromValues( + keyMapper: (room) => room.summary.roomId, + values: hierarchy.rooms, + ); + final spaceName = byId[spaceId]?.summary.displayName ?? "Unnamed Space"; + final children = (byId[spaceId]?.children ?? const .empty()) + .map( + (child) => switch (byId[child.roomId]) { + // Skip spaces that contain themselves somewhere up the tree. + final room? when !ancestors.contains(child.roomId) => ( + child, + room, + ), + _ => null, + }, + ) + .nonNulls + // Suggested rooms first, then sorted by member count + .sorted((a, b) { + if (a.$1.suggested != b.$1.suggested) { + return a.$1.suggested ? -1 : 1; + } + return (b.$2.summary.joinedMembers ?? 0).compareTo( + a.$2.summary.joinedMembers ?? 0, + ); + }); + + return Column( + crossAxisAlignment: .stretch, + children: [ + for (final (child, room) in children) + if (room.summary.roomType != RoomType.space) + Builder( + builder: (context) { + final summary = room.summary; + final name = summary.displayName; + + return ListTile( + leading: AvatarOrHash( + summary.avatarUrl, + name, + fallback: Icon(summary.icon), + ), + title: Text(name, maxLines: 1, overflow: .ellipsis), + subtitle: summary.topic == null + ? null + : Text( + summary.topic!, + maxLines: 2, + overflow: .ellipsis, + ), + onTap: () => showSummary(room, child), + trailing: Row( + mainAxisSize: .min, + children: [ + membershipButton(room, child, name: name), + if (canManage) + removeButton( + spaceId, + summary.roomId, + name: name, + spaceName: spaceName, + ), + ], + ), + ); + }, + ), + for (final (child, room) in children) + if (room.summary.roomType == RoomType.space) + HookConsumer( + builder: (context, ref, _) { + final expanded = useState(false); + final summary = room.summary; + final name = summary.displayName; + final joined = joinedIds.contains(summary.roomId); + + return Column( + crossAxisAlignment: .stretch, + children: [ + ListTile( + onTap: joined + ? () => expanded.value = !expanded.value + : () => showSummary(room, child), + leading: AvatarOrHash( + summary.avatarUrl, + name, + fallback: Icon(summary.icon), + ), + title: Text(name, maxLines: 1, overflow: .ellipsis), + subtitle: summary.topic == null + ? null + : Text( + summary.topic!, + maxLines: 2, + overflow: .ellipsis, + ), + trailing: Row( + mainAxisSize: .min, + children: [ + if (joined) + AnimatedRotation( + turns: expanded.value ? 0.5 : 0, + duration: Durations.medium2, + child: const Icon(Icons.expand_more), + ), + membershipButton(room, child, name: name), + if (canManage) + removeButton( + spaceId, + summary.roomId, + name: name, + spaceName: spaceName, + ), + ], + ), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: joined && expanded.value + ? Padding( + // Indented so it's clear which rooms are in which space + padding: const .only(left: 16), + child: ref + .watch( + SpaceHierarchyController.provider( + summary.roomId, + ), + ) + .betterWhen( + skipLoadingOnRefresh: true, + loading: () => + const Loading(dimension: 24), + data: (hierarchy) => level( + summary.roomId, + hierarchy, + ancestors.add(child.roomId), + ), + ), + ) + : const SizedBox(width: .infinity), + ), + ], + ); + }, + ), + if (hierarchy.nextBatch != null || canManage) + Row( + mainAxisAlignment: .center, + spacing: 8, + children: [ + if (hierarchy.nextBatch != null) + TextButton( + onPressed: loadingMore.value + ? null + : () async { + loadingMore.value = true; + await ref + .read( + SpaceHierarchyController.provider(spaceId) + .notifier, + ) + .loadMore() + .onError(showError); + if (context.mounted) loadingMore.value = false; + }, + child: const Text("Load more"), + ), + if (canManage) + TextButton.icon( + onPressed: adding + ? null + : () => context.showAdaptiveSheet( + (_) => AddSpaceRoomsSheet( + spaceId, + spaceName: spaceName, + existing: + (byId[spaceId]?.children ?? const .empty()) + .map((child) => child.roomId) + .toISet(), + ), + ), + icon: adding + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(), + ) + : const Icon(Icons.add), + label: const Text("Add rooms"), + ), + ], + ), + ], + ); + }, + ); + + return ref + .watch(SpaceHierarchyController.provider(spaceId)) + .betterWhen( + skipLoadingOnRefresh: true, + data: (hierarchy) { + final space = hierarchy.rooms + .where((room) => room.summary.roomId == spaceId) + .firstOrNull + ?.summary; + + return SheetLayout( + title: Row( + spacing: 12, + children: [ + if (space?.avatarUrl case final avatarUrl?) + ExpandableImage( + .new(mxc: avatarUrl), + downloadFilename: + space?.name ?? space?.canonicalAlias ?? spaceId, + child: AvatarOrHash(avatarUrl, "", dimension: 32), + ), + Expanded( + child: Text( + space?.displayName ?? "Unnamed Space", + overflow: .ellipsis, + maxLines: 3, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Close"), + ), + ], + children: [ + level(spaceId, hierarchy, .new([spaceId])), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/settings/add_users_power_level_sheet.dart b/lib/widgets/settings/add_users_power_level_sheet.dart new file mode 100644 index 0000000..b406414 --- /dev/null +++ b/lib/widgets/settings/add_users_power_level_sheet.dart @@ -0,0 +1,61 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/widgets/settings/member_chooser.dart"; +import "package:nexus/widgets/settings/power_level_field.dart"; + +/// Chooses users of [roomId] to give a power level of their own. +class const AddUsersPowerLevelSheet({ + required final String roomId, + + /// Users who already have a level, or can't be given one, so aren't suggested. + required final ISet existing, + + /// The user's own power level, which they can't set levels above. + required final double ownLevel, + required final void Function(ISet users, int level) onSave, + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final chosen = useState(const ISet.empty()); + final levelController = useTextEditingController(text: "0"); + final level = PowerLevelField.levelFrom( + useValueListenable(levelController).text, + ownLevel, + ); + + final valid = chosen.value.isNotEmpty && level != null; + void save() { + Navigator.of(context).pop(); + onSave(chosen.value, level!); + } + + return SheetLayout( + title: Text("Add Users"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton(onPressed: valid ? save : null, child: Text("Save")), + ], + children: [ + MemberChooser( + roomId: roomId, + chosen: chosen.value, + excluded: existing, + excludedError: "Already has a power level", + onAdded: (id) => chosen.value = chosen.value.add(id), + onRemoved: (id) => chosen.value = chosen.value.remove(id), + label: "Add user", + removeTooltip: "Remove user", + autofocus: true, + ), + PowerLevelField( + controller: levelController, + ownLevel: ownLevel, + onSubmitted: valid ? save : null, + ), + ], + ); + } +} diff --git a/lib/widgets/settings/aliases_sheet.dart b/lib/widgets/settings/aliases_sheet.dart new file mode 100644 index 0000000..2eec533 --- /dev/null +++ b/lib/widgets/settings/aliases_sheet.dart @@ -0,0 +1,78 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; + +/// Chooses which of a room's [aliases] it publishes, and which one of those is canonical. +class const AliasesSheet({ + required final ISet aliases, + required final String? canonical, + required final ISet alternatives, + + /// The canonical alias, and the other published ones. + required final void Function(String? canonical, ISet alternatives) + onSave, + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final chosenCanonical = useState(canonical); + final published = useState(alternatives.addAll([?canonical])); + + void setPublished(String alias, bool isPublished) { + published.value = isPublished + ? published.value.add(alias) + : published.value.remove(alias); + if (!isPublished && chosenCanonical.value == alias) { + chosenCanonical.value = null; + } + } + + return SheetLayout( + title: Text("Aliases"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onSave( + chosenCanonical.value, + published.value.removeAll([?chosenCanonical.value]), + ); + }, + child: Text("Save"), + ), + ], + children: [ + Text( + "Star an item to set it as the room's canonical alias, the alias used to advertise and access the room", + ), + Text( + "Other checked items will be set as alternate aliases, which can still be used to join/access the room but are not as prominently advertised.", + ), + if (aliases.isEmpty) + Text("No aliases, add a local alias first") + else + ...aliases.map( + (alias) => CheckboxListTile( + value: published.value.contains(alias), + onChanged: (checked) => setPublished(alias, checked ?? false), + title: Text(alias, overflow: .ellipsis), + secondary: IconButton( + tooltip: chosenCanonical.value == alias + ? "Canonical alias" + : "Make canonical alias", + isSelected: chosenCanonical.value == alias, + icon: Icon(Icons.star_border), + selectedIcon: Icon(Icons.star), + onPressed: () { + chosenCanonical.value = alias; + setPublished(alias, true); + }, + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/allowed_rooms_sheet.dart b/lib/widgets/settings/allowed_rooms_sheet.dart new file mode 100644 index 0000000..63aac8b --- /dev/null +++ b/lib/widgets/settings/allowed_rooms_sheet.dart @@ -0,0 +1,138 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/settings/search_chooser.dart"; + +/// Chooses the rooms whose members can join [roomId] without an invite. +class const AllowedRoomsSheet({ + required final String roomId, + required final ISet allowed, + + /// Suggested first, as they're the usual choice. + required final ISet parentSpaces, + required final void Function(ISet allowed) onSave, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final chosen = useState(allowed); + final error = useState(null); + final resolving = useState(false); + final controller = useTextEditingController(); + final rooms = ref.watch( + RoomsController.provider.select( + (rooms) => IMap.fromEntries( + rooms.values + .map((room) => room.metadata) + .nonNulls + .map( + (metadata) => MapEntry(metadata.id, ( + name: metadata.name, + avatar: metadata.avatar, + )), + ), + ), + ), + ); + + String nameOf(String id) => rooms[id]?.name ?? id; + + final suggestions = parentSpaces + .addAll( + rooms.entries + .where((entry) => !parentSpaces.contains(entry.key)) + .sortedBy((entry) => entry.value.name?.toLowerCase() ?? "") + .map((entry) => entry.key), + ) + .where((id) => id != roomId) + .toIList(); + + void add(String id) { + if (id == roomId) { + error.value = "A room can't allow itself"; + return; + } + if (!chosen.value.contains(id)) chosen.value = chosen.value.add(id); + controller.clear(); + } + + Future addRoom(String text) async { + if (text.startsWith("!")) return add(text); + + resolving.value = true; + try { + final id = await ref + .read(ClientController.provider.notifier) + .resolveAlias(text); + + if (!context.mounted) return; + + if (id == null) { + error.value = "No room has this alias"; + } else { + add(id); + } + } finally { + if (context.mounted) resolving.value = false; + } + } + + return SheetLayout( + title: Text("Allowed Rooms"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onSave(chosen.value); + }, + child: Text("Save"), + ), + ], + children: [ + Text("Members of these rooms can join without an invite."), + SearchChooser( + controller: controller, + chosen: chosen.value, + options: suggestions, + nameOf: nameOf, + avatarOf: (id) => AvatarOrHash( + rooms[id]?.avatar, + nameOf(id), + fallback: Icon(Icons.numbers), + ), + subtitleOf: (id) => parentSpaces.contains(id) + ? "Parent space" + : rooms[id]?.name == null + ? null + : id, + onAdded: add, + onRemoved: (id) => chosen.value = chosen.value.remove(id), + onSubmitted: (text, topMatch) { + if (text.startsWith(RegExp("[!#]"))) { + addRoom(text).onError(showError); + } else if (topMatch != null) { + add(topMatch); + } else { + error.value = "Enter a room ID or alias"; + } + }, + onChanged: (_) => error.value = null, + label: "Add room", + hint: "Search, or enter a room ID or alias", + removeTooltip: "Remove room", + emptyText: "None, so only invited users can join", + error: error.value, + loading: resolving.value, + ), + ], + ); + } +} diff --git a/lib/widgets/settings/block_user_field.dart b/lib/widgets/settings/block_user_field.dart new file mode 100644 index 0000000..ad0a829 --- /dev/null +++ b/lib/widgets/settings/block_user_field.dart @@ -0,0 +1,66 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/account_data.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/main.dart"; + +/// Blocks a user by their ID, hiding their messages and invites. +class const BlockUserField({super.key}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final saving = Saving.use(); + final controller = useTextEditingController(); + final userId = useValueListenable(controller).text.trim(); + final blocked = ref.watch( + AccountDataController.provider.select( + (data) => data.ignoredUserList.contains(userId), + ), + ); + final ownId = ref.watch( + ClientStateController.provider.select((state) => state?.userId), + ); + + final error = userId.isEmpty || userId.isValidUserId + ? userId == ownId + ? "You can't block yourself" + : blocked + ? "Already blocked" + : null + : "Enter a user ID, like @user:example.com"; + final canBlock = userId.isNotEmpty && error == null && !saving.active; + + void block() { + if (!canBlock) return; + saving + .run(() async { + await ref + .read(AccountDataController.provider.notifier) + .setIgnored(userId, ignored: true); + if (context.mounted) controller.clear(); + }) + .onError(showError); + } + + return Padding( + padding: EdgeInsets.symmetric(horizontal: 8).copyWith(top: 8), + child: TextField( + controller: controller, + enabled: !saving.active, + onSubmitted: (_) => block(), + decoration: .new( + labelText: "User ID", + hintText: "@user:example.com", + errorText: error, + suffixIcon: IconButton( + tooltip: "Block user", + icon: Icon(Icons.block), + onPressed: canBlock ? block : null, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/settings/dialog_list_tile.dart b/lib/widgets/settings/dialog_list_tile.dart new file mode 100644 index 0000000..2c9e5de --- /dev/null +++ b/lib/widgets/settings/dialog_list_tile.dart @@ -0,0 +1,54 @@ +import "dart:async"; + +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/widgets/settings/radio_sheet.dart"; + +final class const DialogListTile({ + required final T? value, + required final Icon icon, + required final String title, + required final List options, + + /// While a returned future runs, the tile is disabled. + required final FutureOr Function(T value)? onChanged, + required final String Function(T option) getName, + final Widget? subtitle, + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final saving = Saving.use(); + Future change(T value) => saving.run(() => onChanged!(value)); + + return ListTile( + enabled: onChanged != null && !saving.active, + onTap: () => context.showAdaptiveSheet( + (context) => RadioSheet( + title: title, + getName: getName, + onChanged: onChanged == null ? null : change, + value: value, + options: options, + ), + ), + title: Text(title), + subtitle: subtitle, + leading: icon, + trailing: ConstrainedBox( + constraints: const .new(maxWidth: 160), + child: Chip( + label: Text( + value is T ? getName(value as T) : "None", + overflow: .ellipsis, + style: onChanged == null || saving.active + ? .new(color: Colors.grey) + : null, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/settings/enable_image_packs_sheet.dart b/lib/widgets/settings/enable_image_packs_sheet.dart new file mode 100644 index 0000000..427f7d8 --- /dev/null +++ b/lib/widgets/settings/enable_image_packs_sheet.dart @@ -0,0 +1,118 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/room_image_packs.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/models/account_data.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/avatars/image_pack_avatar.dart"; + +/// Chooses a room, then which of its image packs to use in every room. +class const EnableImagePacksSheet({ + required final ImagePackRooms enabled, + required final void Function(ImagePackRooms enabled) onSave, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final chosen = useState(enabled); + final roomId = useState(null); + final rooms = ref.watch( + RoomsController.provider.select( + (rooms) => rooms.values + .map((room) => room.metadata) + .nonNulls + .map( + (metadata) => ( + id: metadata.id, + name: metadata.name ?? metadata.id, + avatar: metadata.avatar, + ), + ) + .sortedBy((room) => room.name.toLowerCase()) + .toIList(), + ), + ); + + return SheetLayout( + title: Text("Add Image Packs"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onSave(chosen.value); + }, + child: Text("Save"), + ), + ], + children: [ + Text("Packs you add can be used in every room."), + DropdownMenu( + expandedInsets: .zero, + menuHeight: 300, + enableFilter: true, + requestFocusOnTap: true, + label: Text("Room"), + onSelected: (id) => roomId.value = id, + dropdownMenuEntries: rooms + .map( + (room) => DropdownMenuEntry( + value: room.id, + label: room.name, + leadingIcon: AvatarOrHash( + room.avatar, + room.name, + fallback: Icon(Icons.numbers), + ), + ), + ) + .toList(), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: switch (roomId.value) { + final roomId? => + ref + .watch(RoomImagePacksController.provider(roomId)) + .betterWhen( + data: (packs) => packs.isEmpty + ? Text("This room has no image packs") + : Column( + mainAxisSize: .min, + children: packs.entries + .map( + (entry) => CheckboxListTile( + secondary: ImagePackAvatar( + entry.value.icon, + ), + title: Text(entry.value.name), + subtitle: Text(entry.value.imageCountLabel), + value: chosen.value.contains( + roomId, + entry.key, + ), + onChanged: (value) => + chosen.value = chosen.value.withPack( + roomId, + entry.key, + enabled: value ?? false, + ), + ), + ) + .toList(), + ), + ), + null => const SizedBox(width: .infinity), + }, + ), + ], + ); + } +} diff --git a/lib/widgets/settings/image_pack_sheet.dart b/lib/widgets/settings/image_pack_sheet.dart new file mode 100644 index 0000000..d18d3f6 --- /dev/null +++ b/lib/widgets/settings/image_pack_sheet.dart @@ -0,0 +1,249 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/file_dialog_helper.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/widgets/avatars/image_pack_avatar.dart"; +import "package:nexus/widgets/emoji/mxc_emoji.dart"; +import "package:nexus/widgets/settings/image_usage_chips.dart"; +import "package:nexus/widgets/settings/pack_image_sheet.dart"; + +/// Edits an image pack, or only shows it if [onSave] is null. +class const ImagePackSheet({ + required final String title, + required final ImagePackContent content, + required final void Function(ImagePackContent content)? onSave, + final VoidCallback? onDeleted, + + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final editable = onSave != null; + final nameController = useTextEditingController( + text: content.pack?.displayName, + ); + final name = useValueListenable(nameController).text.trim(); + final avatar = useState(content.pack?.avatarUrl); + final usage = useState(content.pack?.usage ?? const ISet.empty()); + final images = useState(content.images); + final uploading = Saving.use(); + final packUsage = usage.value.isEmpty + ? ISet(ImageUsage.values) + : usage.value; + final valid = !uploading.active && name.isNotEmpty; + + Future upload( + void Function(String filename, PackImage image) onUploaded, + ) async { + final file = await ref + .read(FileDialogHelper.provider) + .pickFile(images: true); + if (file == null || !context.mounted) return; + + await uploading.run(() async { + final content = await ref + .read(ClientController.provider.notifier) + .uploadMedia(.new(path: file.path, encrypt: false)); + if (!context.mounted) return; + + if (content case ImageMessageContent(:final url, :final info)) { + onUploaded(file.name, PackImage(url: url, info: info)); + } else { + throw Exception("The file isn't an image"); + } + }); + } + + void editImage(String shortcode, PackImage image, {bool isNew = false}) => + context.showAdaptiveSheet( + (_) => PackImageSheet( + title: isNew + ? "Add Image" + : editable + ? "Edit Image" + : ":$shortcode:", + shortcode: shortcode, + image: image, + taken: isNew + ? images.value.keys.toISet() + : images.value.keys.toISet().remove(shortcode), + packUsage: packUsage, + onSave: editable + ? (newShortcode, newImage) => images.value = isNew + ? images.value.add(newShortcode, newImage) + // Replaced in place, to keep the images' order + : .fromEntries( + images.value.entries.map( + (entry) => entry.key == shortcode + ? MapEntry(newShortcode, newImage) + : entry, + ), + ) + : null, + onRemoved: editable && !isNew + ? () => images.value = images.value.remove(shortcode) + : null, + ), + ); + + Future delete() async { + final confirmed = await context.showAdaptiveSheet( + (context) => SheetLayout( + title: Text("Delete Pack"), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text("Cancel"), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text("Delete"), + ), + ], + children: [ + Text( + "Delete this pack? Its images will no longer be available here, or to anyone using it in other rooms.", + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + + Navigator.of(context).pop(); + onDeleted?.call(); + } + + void save() { + Navigator.of(context).pop(); + onSave!( + content.copyWith( + images: images.value, + pack: (content.pack ?? const PackInfo()).copyWith( + displayName: name, + avatarUrl: avatar.value, + usage: usage.value, + ), + ), + ); + } + + final textTheme = Theme.of(context).textTheme; + + return SheetLayout( + title: Text(title), + actions: [ + if (onDeleted != null) + TextButton( + onPressed: uploading.active ? null : delete, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text("Delete"), + ), + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(editable ? "Cancel" : "Close"), + ), + if (editable) + TextButton(onPressed: valid ? save : null, child: Text("Save")), + ], + children: [ + TextField( + controller: nameController, + enabled: editable, + autofocus: true, + decoration: .new(labelText: "Name"), + ), + ListTile( + enabled: editable && !uploading.active, + leading: Icon(Icons.image), + title: Text("Avatar"), + subtitle: Text("The pack's first image if not set."), + trailing: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + ImagePackAvatar( + avatar.value ?? images.value.values.firstOrNull?.url, + ), + if (avatar.value != null && editable) + IconButton( + tooltip: "Remove avatar", + icon: Icon(Icons.delete), + onPressed: uploading.active + ? null + : () => avatar.value = null, + ), + ], + ), + onTap: () => + upload((_, image) => avatar.value = image.url).onError(showError), + ), + Text("Default usage", style: textTheme.titleSmall), + ImageUsageChips( + value: packUsage, + onChanged: editable + ? (value) => + usage.value = value.length == ImageUsage.values.length + ? const .empty() + : value + : null, + ), + Text("Images", style: textTheme.titleSmall), + if (images.value.isEmpty && !editable) Text("No images"), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + ...images.value.entries.map( + (entry) => Tooltip( + message: ":${entry.key}:", + child: InkWell( + borderRadius: .circular(8), + onTap: () => editImage(entry.key, entry.value), + child: Padding( + padding: .all(4), + child: SizedBox.square( + dimension: 56, + child: MxcEmoji(entry.value.url, label: entry.key), + ), + ), + ), + ), + ), + if (editable) + SizedBox.square( + dimension: 64, + child: uploading.active + ? Center(child: CircularProgressIndicator()) + : IconButton( + tooltip: "Add image", + icon: Icon(Icons.add_photo_alternate), + onPressed: () => upload( + (filename, image) => editImage( + filename + .split(".") + .first + .replaceAll(RegExp(r"[^\w-]"), ""), + image, + isNew: true, + ), + ).onError(showError), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/settings/image_usage_chips.dart b/lib/widgets/settings/image_usage_chips.dart new file mode 100644 index 0000000..e9d1fbb --- /dev/null +++ b/lib/widgets/settings/image_usage_chips.dart @@ -0,0 +1,38 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/content/image_pack.dart"; + +/// Chooses what images are used as, keeping at least one usage chosen. +class const ImageUsageChips({ + required final ISet value, + + /// Disables the chips if null. + required final void Function(ISet value)? onChanged, + super.key, +}) extends StatelessWidget { + static String nameOf(ImageUsage usage) => switch (usage) { + .emoticon => "Emoticons", + .sticker => "Stickers", + }; + + @override + Widget build(BuildContext context) => Wrap( + spacing: 8, + runSpacing: 8, + children: ImageUsage.values + .map( + (usage) => FilterChip( + label: Text(nameOf(usage)), + selected: value.contains(usage), + onSelected: + onChanged == null || + (value.length == 1 && value.contains(usage)) + ? null + : (selected) => onChanged!( + selected ? value.add(usage) : value.remove(usage), + ), + ), + ) + .toList(), + ); +} diff --git a/lib/widgets/settings/inline_text_field.dart b/lib/widgets/settings/inline_text_field.dart new file mode 100644 index 0000000..1ccddb7 --- /dev/null +++ b/lib/widgets/settings/inline_text_field.dart @@ -0,0 +1,131 @@ +import "dart:async"; + +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/hooks/saving.dart"; + +/// Centered text that's edited in place, saving when submitted or unfocused. +class const InlineTextField({ + required final String? value, + required final String hint, + + /// Disables the field if null, and while a returned future runs. + required final FutureOr Function(String value)? onChanged, + final TextStyle? style, + + /// Pads the start to match the edit icon, keeping the text centered when the + /// field is on its own, rather than beside others. + final bool balanced = true, + super.key, +}) extends HookWidget { + static double iconSizeOf(TextStyle? style) => + ((style?.fontSize ?? 16) * 0.7).clamp(16.0, 24.0); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final saving = Saving.use(); + final controller = useTextEditingController(text: value); + final isEmpty = useValueListenable(controller).text.isEmpty; + final focusNode = useFocusNode(); + final focused = useListenableSelector(focusNode, () => focusNode.hasFocus); + final latest = useRef(value); + latest.value = value; + + // Show changes made elsewhere, unless they'd overwrite an edit. Effects run + // while building, so this waits until after, to not rebuild the field mid-build. + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted && !focusNode.hasFocus) { + controller.text = value ?? ""; + } + }); + return null; + }, [value]); + + useEffect(() { + Future listener() async { + if (focusNode.hasFocus) return; + final text = controller.text.trim(); + if (text != (latest.value ?? "")) { + await saving.run(() => onChanged?.call(text)); + } + + if (context.mounted && !focusNode.hasFocus) { + controller.text = latest.value ?? ""; + } + } + + focusNode.addListener(listener); + return () => focusNode.removeListener(listener); + }, [focusNode, onChanged]); + + const border = UnderlineInputBorder(borderRadius: .zero, borderSide: .none); + final iconSize = iconSizeOf(style); + final iconConstraints = BoxConstraints.tight(Size(iconSize + 12, iconSize)); + + return CallbackShortcuts( + bindings: { + const SingleActivator(.escape): () { + controller.text = value ?? ""; + focusNode.unfocus(); + }, + }, + // Sized to the text, so the edit icon sits beside it + child: IntrinsicWidth( + child: TextField( + controller: controller, + focusNode: focusNode, + enabled: onChanged != null && !saving.active, + style: style, + textAlign: .center, + textCapitalization: .sentences, + // Wraps long text, but stays one line, with enter saving it + maxLines: null, + keyboardType: .text, + textInputAction: .done, + inputFormatters: [FilteringTextInputFormatter.singleLineFormatter], + onSubmitted: (_) => focusNode.unfocus(), + onTapOutside: (_) => focusNode.unfocus(), + decoration: .new( + // The hint counts towards the field's width, so it's only set when it shows + hintText: isEmpty ? hint : null, + hintStyle: style?.copyWith(color: colorScheme.onSurfaceVariant), + filled: true, + fillColor: Colors.transparent, + hoverColor: colorScheme.surfaceContainerHighest, + isDense: true, + // Without the prefix, this keeps the text off the edge of the hover background + contentPadding: EdgeInsetsDirectional.only( + start: balanced ? 0 : 6, + top: 8, + bottom: 8, + ), + border: border, + focusedBorder: border.copyWith( + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + prefixIcon: balanced ? const SizedBox() : null, + prefixIconConstraints: iconConstraints, + suffixIcon: saving.active + ? Center( + child: SizedBox.square( + dimension: iconSize - 4, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : Icon( + Icons.edit, + size: iconSize, + color: focused + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.38) + : colorScheme.onSurfaceVariant, + ), + suffixIconConstraints: iconConstraints, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/settings/keyword_field.dart b/lib/widgets/settings/keyword_field.dart new file mode 100644 index 0000000..3d4ee61 --- /dev/null +++ b/lib/widgets/settings/keyword_field.dart @@ -0,0 +1,70 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/account_data.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/main.dart"; + +/// Adds a keyword, which notifies with a highlight and sound for messages +/// containing it. +class const KeywordField({super.key}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final saving = Saving.use(); + final controller = useTextEditingController(); + final keyword = useValueListenable(controller).text.trim(); + final exists = ref.watch( + AccountDataController.provider.select( + (data) => data.pushRules.global.find(.content, keyword) != null, + ), + ); + + // Keywords are their rules' IDs, which can't be like these + final error = keyword.startsWith(".") + ? "Can't start with a ." + : keyword.contains(RegExp(r"[/\\]")) + ? r"Can't contain / or \" + : exists + ? "Already a keyword" + : null; + final canAdd = keyword.isNotEmpty && error == null && !saving.active; + + void add() { + if (!canAdd) return; + saving + .run(() async { + await ref + .read(AccountDataController.provider.notifier) + .savePushRule( + .content, + .new( + ruleId: keyword, + pattern: keyword, + actions: .highlightWithSound, + ), + ); + if (context.mounted) controller.clear(); + }) + .onError(showError); + } + + return Padding( + padding: .symmetric(horizontal: 16), + child: TextField( + controller: controller, + enabled: !saving.active, + onSubmitted: (_) => add(), + decoration: .new( + labelText: "New keyword", + helperText: "Matches whole words. * matches anything, and ? matches one character.", + errorText: error, + suffixIcon: IconButton( + tooltip: "Add keyword", + icon: Icon(Icons.add), + onPressed: canAdd ? add : null, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/settings/local_aliases_sheet.dart b/lib/widgets/settings/local_aliases_sheet.dart new file mode 100644 index 0000000..b51b7c3 --- /dev/null +++ b/lib/widgets/settings/local_aliases_sheet.dart @@ -0,0 +1,107 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/local_aliases.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/gomuks_exception.dart"; + +class const LocalAliasesSheet( + final String roomId, { + + /// Called after an alias is deleted, e.g. to stop publishing it. + final Future Function(String alias)? onRemoved, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final provider = LocalAliasesController.provider(roomId); + final saving = Saving.use(); + final controller = useTextEditingController(); + final localpart = useValueListenable(controller).text.trim(); + final server = ref.watch( + ClientStateController.provider.select( + (state) => state?.userId?.split(":").skip(1).join(":"), + ), + ); + + final error = useState(null); + + void add() { + if (localpart.isEmpty || server == null) return; + saving + .run(() async { + try { + await ref.read(provider.notifier).add("#$localpart:$server"); + controller.clear(); + } on GomuksException catch (exception) { + // Taken, by this room or another, which is shown on the field + if (exception.statusCode != 409) rethrow; + if (context.mounted) error.value = "This alias already exists"; + } + }) + .onError(showError); + } + + return SheetLayout( + title: Text("Local Aliases"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Close")), + ], + children: [ + Text( + "Addresses for the room on your server. Publish them to show them to others.", + ), + ref + .watch(provider) + .betterWhen( + data: (aliases) => aliases.isEmpty + ? Text("No local aliases") + : Column( + children: aliases + .map( + (alias) => ListTile( + title: Text(alias, overflow: .ellipsis), + trailing: IconButton( + tooltip: "Delete alias", + icon: Icon(Icons.delete), + onPressed: saving.active + ? null + : () => saving + .run(() async { + await ref + .read(provider.notifier) + .remove(alias); + await onRemoved?.call(alias); + }) + .onError(showError), + ), + ), + ) + .toList(), + ), + ), + TextField( + controller: controller, + enabled: !saving.active, + onChanged: (_) => error.value = null, + onSubmitted: (_) => add(), + decoration: .new( + labelText: "New alias", + errorText: error.value, + prefixText: "#", + suffixText: server == null ? null : ":$server", + suffixIcon: IconButton( + tooltip: "Add alias", + icon: Icon(Icons.add), + onPressed: localpart.isEmpty || saving.active ? null : add, + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/member_chooser.dart b/lib/widgets/settings/member_chooser.dart new file mode 100644 index 0000000..fbaaa8e --- /dev/null +++ b/lib/widgets/settings/member_chooser.dart @@ -0,0 +1,91 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/members.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/settings/search_chooser.dart"; + +/// The [chosen] users, above a field searching [roomId]'s members, or taking a user ID, to choose more. +class const MemberChooser({ + required final String roomId, + required final ISet chosen, + required final void Function(String userId) onAdded, + required final void Function(String userId) onRemoved, + required final String label, + required final String removeTooltip, + + /// Users who can't be chosen, so aren't suggested. + final ISet excluded = const .empty(), + + /// Shown when trying to choose one of the [excluded] users. + final String excludedError = "Can't be chosen", + final String? emptyText, + final bool autofocus = false, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final error = useState(null); + final searchController = useTextEditingController(); + + final members = IMap.fromEntries( + (ref.watch(MembersController.provider(roomId)).value ?? + const ISet.empty()) + .map( + (event) => switch ((event.stateKey, event.content)) { + (final userId?, final MembershipContent content) + when content.status == .join || content.status == .invite => + MapEntry(userId, content), + _ => null, + }, + ) + .nonNulls, + ); + + String nameOf(String id) => members[id]?.displayName ?? id.localpart; + + void add(String id) { + if (excluded.contains(id)) { + error.value = excludedError; + return; + } + onAdded(id); + searchController.clear(); + } + + return SearchChooser( + controller: searchController, + chosen: chosen, + options: members.keys + .where((id) => !excluded.contains(id)) + .sortedBy((id) => nameOf(id).toLowerCase()) + .toIList(), + nameOf: nameOf, + avatarOf: (id) => + AvatarOrHash(members[id]?.avatarUrl, nameOf(id), dimension: 40), + subtitleOf: (id) => id, + onAdded: add, + onRemoved: onRemoved, + onSubmitted: (text, topMatch) { + if (RegExp(r"^@[^:]+:.+$").hasMatch(text)) { + add(text); + } else if (topMatch != null) { + add(topMatch); + } else { + error.value = "Enter a user ID"; + } + }, + onChanged: (_) => error.value = null, + label: label, + hint: "Search members, or enter a user ID", + removeTooltip: removeTooltip, + emptyText: emptyText, + error: error.value, + autofocus: autofocus, + ); + } +} diff --git a/lib/widgets/settings/pack_image_sheet.dart b/lib/widgets/settings/pack_image_sheet.dart new file mode 100644 index 0000000..89ed4f7 --- /dev/null +++ b/lib/widgets/settings/pack_image_sheet.dart @@ -0,0 +1,134 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/widgets/emoji/mxc_emoji.dart"; +import "package:nexus/widgets/settings/image_usage_chips.dart"; + +/// Edits an image in a pack, or only shows it if [onSave] is null. +class const PackImageSheet({ + required final String title, + required final String shortcode, + required final PackImage image, + + /// The other images' shortcodes, which this one can't also have. + required final ISet taken, + + /// The pack's default usage, which images have unless they set their own. + required final ISet packUsage, + required final void Function(String shortcode, PackImage image)? onSave, + final VoidCallback? onRemoved, + super.key, +}) extends HookWidget { + static final shortcodePattern = RegExp(r"^[\w-]+$"); + + @override + Widget build(BuildContext context) { + final shortcodeController = useTextEditingController(text: shortcode); + final bodyController = useTextEditingController(text: image.body); + final usage = useState(image.usage); + final newShortcode = useValueListenable(shortcodeController).text.trim(); + final error = taken.contains(newShortcode) + ? "Another image has this shortcode" + : newShortcode.isNotEmpty && !shortcodePattern.hasMatch(newShortcode) + ? "Only letters, numbers, - and _ are allowed" + : null; + final valid = newShortcode.isNotEmpty && error == null; + + void save() { + final body = bodyController.text.trim(); + Navigator.of(context).pop(); + onSave!( + newShortcode, + image.copyWith(body: body.isEmpty ? null : body, usage: usage.value), + ); + } + + return SheetLayout( + title: Text(title), + actions: [ + if (onRemoved case final onRemoved?) + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onRemoved(); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text("Remove"), + ), + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(onSave == null ? "Close" : "Cancel"), + ), + if (onSave != null) + TextButton(onPressed: valid ? save : null, child: Text("Save")), + ], + children: [ + Center( + child: SizedBox.square( + dimension: 128, + child: MxcEmoji(image.url, label: shortcode), + ), + ), + TextField( + controller: shortcodeController, + enabled: onSave != null, + onSubmitted: valid ? (_) => save() : null, + decoration: .new( + labelText: "Shortcode", + prefixText: ":", + suffixText: ":", + errorText: error, + ), + ), + TextField( + controller: bodyController, + enabled: onSave != null, + onSubmitted: valid ? (_) => save() : null, + decoration: .new( + labelText: "Description", + hintText: "Read by screen readers", + ), + ), + Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + SwitchListTile( + title: Text("Custom usage"), + subtitle: Text( + usage.value.isEmpty + ? "Same as the pack's default (${packUsage.map(ImageUsageChips.nameOf).join(", ")})" + : "Used as chosen below, instead of the pack's default", + ), + value: usage.value.isNotEmpty, + onChanged: onSave == null + ? null + : (custom) => + usage.value = custom ? packUsage : const .empty(), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: usage.value.isEmpty + ? const SizedBox(width: .infinity) + : Padding( + padding: .only(top: 8), + child: ImageUsageChips( + value: usage.value, + onChanged: onSave == null + ? null + : (value) => usage.value = value, + ), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/settings/power_level_entry_sheet.dart b/lib/widgets/settings/power_level_entry_sheet.dart new file mode 100644 index 0000000..05b9831 --- /dev/null +++ b/lib/widgets/settings/power_level_entry_sheet.dart @@ -0,0 +1,55 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/widgets/settings/power_level_field.dart"; + +/// Gives something, like an event type, its own power level. +class const PowerLevelEntrySheet({ + required final String title, + + /// The label of the field for what's given the power level. + required final String keyLabel, + required final String keyHint, + + /// The user's own power level, which they can't set levels above. + required final double ownLevel, + required final void Function(String key, int level) onSave, + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final keyController = useTextEditingController(); + final levelController = useTextEditingController(text: "0"); + final key = useValueListenable(keyController).text.trim(); + final level = PowerLevelField.levelFrom( + useValueListenable(levelController).text, + ownLevel, + ); + final valid = key.isNotEmpty && level != null; + + void save() { + Navigator.of(context).pop(); + onSave(key, level!); + } + + return SheetLayout( + title: Text(title), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton(onPressed: valid ? save : null, child: Text("Save")), + ], + children: [ + TextField( + controller: keyController, + autofocus: true, + decoration: .new(labelText: keyLabel, hintText: keyHint), + ), + PowerLevelField( + controller: levelController, + ownLevel: ownLevel, + onSubmitted: valid ? save : null, + ), + ], + ); + } +} diff --git a/lib/widgets/settings/power_level_field.dart b/lib/widgets/settings/power_level_field.dart new file mode 100644 index 0000000..5e2bf35 --- /dev/null +++ b/lib/widgets/settings/power_level_field.dart @@ -0,0 +1,48 @@ +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; + +/// A field for a power level, which can't be above the user's own. +class const PowerLevelField({ + required final TextEditingController controller, + + /// The user's own power level, which they can't set levels above. + required final double ownLevel, + final VoidCallback? onSubmitted, + final bool autofocus = false, + super.key, +}) extends HookWidget { + /// The furthest from zero Matrix allows integers to be. + static const maxMagnitude = (1 << 53) - 1; + + /// The level in [text], if it's one someone with [ownLevel] can set. + static int? levelFrom(String text, double ownLevel) => switch (int.tryParse( + text, + )) { + final level? when level.abs() <= maxMagnitude && level <= ownLevel => level, + _ => null, + }; + + @override + Widget build(BuildContext context) { + final text = useValueListenable(controller).text; + + return TextField( + controller: controller, + autofocus: autofocus, + keyboardType: .numberWithOptions(signed: true), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r"^-?\d*"))], + onSubmitted: onSubmitted == null ? null : (_) => onSubmitted!(), + decoration: .new( + labelText: "Power level", + errorText: switch (int.tryParse(text)) { + _ when text.isEmpty || text == "-" => null, + final level? when level.abs() <= maxMagnitude => + level > ownLevel ? "Can't be above your own power level" : null, + // Including ones too far from zero to even parse + _ => "Must be from -$maxMagnitude to $maxMagnitude", + }, + ), + ); + } +} diff --git a/lib/widgets/settings/power_level_tile.dart b/lib/widgets/settings/power_level_tile.dart new file mode 100644 index 0000000..e7df995 --- /dev/null +++ b/lib/widgets/settings/power_level_tile.dart @@ -0,0 +1,127 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/widgets/settings/power_level_field.dart"; + +/// Sets the power level needed for something. +class const PowerLevelTile({ + required final Widget leading, + required final String title, + required final String? description, + + /// Null for several levels that differ, shown as custom. + required final int? value, + + /// The user's own power level, which they can't set or change levels above. + required final double ownLevel, + + /// Disables the tile if null. + required final void Function(int value)? onChanged, + + /// Shows a button to remove the level if given, disabled along with the tile. + final VoidCallback? onRemoved, + + /// If given, asked before removing the level. + final String? removeConfirmation, + + /// Shown after the level, like a button to expand what it sets. + final Widget? trailing, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final enabled = onChanged != null && (value ?? 0) <= ownLevel; + + Future remove() async { + if (removeConfirmation case final confirmation?) { + final confirmed = await context.showAdaptiveSheet( + (context) => SheetLayout( + title: Text("Remove Power Level"), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text("Cancel"), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text("Remove"), + ), + ], + children: [Text(confirmation)], + ), + ); + if (confirmed != true) return; + } + onRemoved!(); + } + + return ListTile( + enabled: enabled, + leading: leading, + title: Text(title), + subtitle: description == null ? null : Text(description!), + trailing: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + Chip( + label: Text( + value?.toString() ?? "Custom", + style: enabled ? null : .new(color: Colors.grey), + ), + ), + if (onRemoved != null) + IconButton( + tooltip: "Remove", + icon: Icon(Icons.delete), + onPressed: enabled ? remove : null, + ), + ?trailing, + ], + ), + onTap: () => context.showAdaptiveSheet( + (context) => HookBuilder( + builder: (context) { + final controller = useTextEditingController( + text: value?.toString(), + ); + final level = PowerLevelField.levelFrom( + useValueListenable(controller).text, + ownLevel, + ); + + void save() { + Navigator.of(context).pop(); + if (level != value) onChanged!(level!); + } + + return SheetLayout( + title: Text(title), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: level == null ? null : save, + child: Text("Save"), + ), + ], + children: [ + PowerLevelField( + controller: controller, + ownLevel: ownLevel, + autofocus: true, + onSubmitted: level == null ? null : save, + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/widgets/settings/radio_sheet.dart b/lib/widgets/settings/radio_sheet.dart new file mode 100644 index 0000000..2a58707 --- /dev/null +++ b/lib/widgets/settings/radio_sheet.dart @@ -0,0 +1,55 @@ +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; + +final class const RadioSheet({ + required final T? value, + required final String title, + required final List options, + required final void Function(T value)? onChanged, + required final String Function(T option) getName, + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + // Wrapped, so a null option can be chosen, rather than counting as nothing being chosen yet + final chosen = useState<(T,)?>(null); + return SheetLayout( + title: Text(title), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + if (onChanged != null) + TextButton( + onPressed: () { + if (chosen.value case (final picked,)) onChanged!(picked); + Navigator.of(context).pop(); + }, + child: Text("OK"), + ), + ], + children: [ + RadioGroup( + groupValue: switch (chosen.value) { + (final picked,) => picked, + null => value, + }, + onChanged: (value) => chosen.value = (value as T,), + + child: Column( + mainAxisSize: .min, + children: options + .map( + (option) => RadioListTile( + enabled: onChanged != null, + value: option, + title: Text(getName(option)), + dense: true, + ), + ) + .toList(), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/search_chooser.dart b/lib/widgets/settings/search_chooser.dart new file mode 100644 index 0000000..f78d476 --- /dev/null +++ b/lib/widgets/settings/search_chooser.dart @@ -0,0 +1,138 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/weighted_search.dart"; + +/// The [chosen] items, above a field searching [options] to choose more. +class const SearchChooser({ + required final TextEditingController controller, + required final ISet chosen, + + required final IList options, + + required final String Function(String id) nameOf, + required final Widget Function(String id) avatarOf, + required final String? Function(String id) subtitleOf, + required final void Function(String id) onAdded, + required final void Function(String id) onRemoved, + + /// The submitted text, and the top suggestion matching it if there is one. + required final void Function(String text, String? topMatch) onSubmitted, + required final String label, + required final String hint, + required final String removeTooltip, + + final String? emptyText, + final String? error, + final bool loading = false, + final bool autofocus = false, + final void Function(String text)? onChanged, + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final query = useValueListenable(controller).text; + final shown = options + .where((id) => !chosen.contains(id)) + .search(query, (id) => [(nameOf(id), 2), (id, 1)]); + + ListTile tileOf(String id, {Widget? trailing, VoidCallback? onTap}) => + ListTile( + leading: avatarOf(id), + title: Text(nameOf(id), overflow: .ellipsis), + subtitle: switch (subtitleOf(id)) { + final subtitle? => Text(subtitle, overflow: .ellipsis), + null => null, + }, + trailing: trailing, + onTap: onTap, + ); + + AnimatedSize animated(Widget? child) => AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: child ?? const SizedBox(width: .infinity), + ); + + return Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + animated(switch ((chosen.isEmpty, emptyText)) { + (true, null) => null, + (true, final emptyText?) => Padding( + padding: .only(bottom: 12), + child: Text(emptyText), + ), + (false, _) => Padding( + padding: .only(bottom: 12), + child: Column( + children: chosen + .map( + (id) => tileOf( + id, + trailing: IconButton( + tooltip: removeTooltip, + icon: Icon(Icons.delete), + onPressed: () => onRemoved(id), + ), + ), + ) + .toList(), + ), + ), + }), + TextField( + controller: controller, + enabled: !loading, + autofocus: autofocus, + onChanged: onChanged, + onSubmitted: (text) { + final trimmed = text.trim(); + if (trimmed.isNotEmpty) onSubmitted(trimmed, shown.firstOrNull); + }, + decoration: .new( + labelText: label, + hintText: hint, + errorText: error, + suffixIcon: loading + ? Padding( + padding: .all(12), + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(), + ), + ) + : null, + ), + ), + animated( + shown.isEmpty + ? null + : Padding( + padding: .only(top: 12, bottom: 4), + child: Material( + elevation: 4, + borderRadius: .circular(12), + clipBehavior: .antiAlias, + child: ConstrainedBox( + constraints: const .new(maxHeight: 240), + child: ListView.builder( + padding: .zero, + shrinkWrap: true, + itemExtent: 72, + itemCount: shown.length, + itemBuilder: (context, index) => tileOf( + shown[index], + onTap: () => onAdded(shown[index]), + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/settings_layout.dart b/lib/widgets/settings/settings_layout.dart new file mode 100644 index 0000000..de20137 --- /dev/null +++ b/lib/widgets/settings/settings_layout.dart @@ -0,0 +1,248 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:hooks_riverpod/misc.dart"; +import "package:m3e_card_list/m3e_card_list.dart"; +import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/weighted_search.dart"; +import "package:nexus/models/settings_category.dart"; +import "package:nexus/widgets/pages/settings_category.dart"; +import "package:nexus/widgets/common/divider_text.dart"; +import "package:nexus/widgets/common/highlight_wrapper.dart"; +import "package:nexus/widgets/common/m3e_rail_theme.dart"; +import "package:super_sliver_list/super_sliver_list.dart"; + +/// A settings screen split into categories: a list of them that opens each +/// as its own page on narrow screens, or a rail beside the selected one. +class const SettingsLayout({ + required final String title, + required final ProviderListenable< + AsyncValue>> + > + sectionsProvider, + final List actions = const [], + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder( + builder: (_, constraints) => HookBuilder( + builder: (context) { + final categoriesArePages = constraints.maxWidth < 550; + + final selected = useState(0); + + final highlightedMatch = useState<(int, int)?>(null); + final listController = useRef(ListController()); + final scrollController = useScrollController(); + + final searchBar = SearchAnchor.bar( + barHintText: "Search...", + isFullScreen: categoriesArePages, + suggestionsBuilder: (suggestionsContext, controller) async { + // Only searched once shown, by which point these have loaded + final categories = + ref.read(sectionsProvider).value ?? const IMap.empty(); + final matches = categories.values.flattenedToList + .asMap() + .entries + .expand( + (categoryEntry) => + categoryEntry.value.settings.asMap().entries.map( + (settingEntry) => ( + (categoryEntry.key, settingEntry.key), + settingEntry.value, + ), + ), + ) + .search( + controller.text, + (match) => [(match.$2.title, 2), (match.$2.description, 1)], + ); + + return matches.map( + (match) => ListTile( + onTap: () async { + if (context.mounted) Navigator.of(suggestionsContext).pop(); + controller.text = ""; + + if (categoriesArePages) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => SettingsCategoryPage( + match.$1.$1, + sectionsProvider: sectionsProvider, + initialHighlight: match.$1.$2, + ), + ), + ); + } else { + selected.value = match.$1.$1; + listController.value.animateToItem( + index: match.$1.$2, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => Durations.extralong1, + curve: (_) => Curves.easeInOut, + ); + highlightedMatch.value = match.$1; + await Future.delayed(Durations.extralong4, () { + if (highlightedMatch.value == match.$1) { + highlightedMatch.value = null; + } + }); + } + }, + leading: Icon(match.$2.icon), + title: Text(match.$2.title), + subtitle: Text(match.$2.description), + ), + ); + }, + ); + + final settingsContent = Scaffold( + appBar: AppBar( + title: Text(title), + actionsPadding: .symmetric(horizontal: 12), + actions: actions, + ), + body: ref + .watch(sectionsProvider) + .betterWhen( + skipLoadingOnReload: true, + data: (sections) => categoriesArePages + ? CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(12).copyWith(bottom: 8), + child: searchBar, + ), + ), + ...sections + .mapTo( + (section, categories) => [ + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 16, + ).copyWith(bottom: 4), + child: DividerText(section), + ), + ), + SliverM3ECardList( + padding: .symmetric( + horizontal: 4, + vertical: 8, + ), + margin: .symmetric(horizontal: 12), + color: Theme.of(context) + .colorScheme + .primaryContainer, + itemCount: categories.length, + onTap: (index) => Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => + SettingsCategoryPage( + sections + .values + .flattenedToList + .indexOf( + categories[index], + ), + sectionsProvider: + sectionsProvider, + ), + ), + ), + itemBuilder: (context, index) => ListTile( + leading: Icon(categories[index].icon), + title: Text(categories[index].title), + ), + ), + ], + ) + .flattened, + ], + ) + : Row( + children: [ + M3ERailTheme( + builder: (_) => NavigationRailM3E( + type: .alwaysExpand, + trailing: searchBar, + scrollable: true, + sections: sections + .mapTo( + ( + categoryGroup, + categories, + ) => NavigationRailM3ESection( + header: DividerText(categoryGroup), + destinations: categories + .map( + (category) => + NavigationRailM3EDestination( + icon: Icon(category.icon), + label: category.title, + ), + ) + .toList(), + ), + ) + .toList(), + selectedIndex: selected.value, + onDestinationSelected: (value) => + selected.value = value, + ), + ), + VerticalDivider(), + Expanded( + child: SuperListView( + listController: listController.value, + controller: scrollController, + padding: .symmetric(vertical: 12), + children: sections + .values + .flattenedToList[selected.value] + .settings + .mapIndexed( + (index, setting) => Padding( + padding: .only(bottom: 4), + child: HighlightWrapper( + setting.builder( + setting.title, + setting.description, + setting.icon, + ), + isHighlighted: + highlightedMatch.value == + (selected.value, index), + ), + ), + ) + .toList(), + ), + ), + ], + ), + ), + ); + + return constraints.maxWidth < 650 + ? settingsContent + : Dialog( + constraints: .loose(Size(900, 600)), + child: ClipRRect( + borderRadius: BorderRadiusGeometry.circular(12), + child: settingsContent, + ), + ); + }, + ), + ); +} diff --git a/lib/widgets/settings/text_setting_tile.dart b/lib/widgets/settings/text_setting_tile.dart new file mode 100644 index 0000000..4789cc3 --- /dev/null +++ b/lib/widgets/settings/text_setting_tile.dart @@ -0,0 +1,69 @@ +import "dart:async"; + +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/helpers/hooks/saving.dart"; + +class const TextSettingTile({ + required final Icon icon, + required final String title, + required final String description, + required final String? value, + + /// Disables the tile if null, and while a returned future runs. + required final FutureOr Function(String value)? onChanged, + final bool multiline = false, + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final saving = Saving.use(); + Future change(String value) => saving.run(() => onChanged!(value)); + + return ListTile( + enabled: onChanged != null && !saving.active, + leading: icon, + title: Text(title), + subtitle: Text( + value?.isNotEmpty == true ? value! : description, + maxLines: 2, + overflow: .ellipsis, + ), + onTap: () => context.showAdaptiveSheet( + (context) => HookBuilder( + builder: (context) { + final controller = useTextEditingController(text: value); + + void save() { + Navigator.of(context).pop(); + change(controller.text.trim()); + } + + return SheetLayout( + title: Text(title), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton(onPressed: save, child: Text("Save")), + ], + children: [ + TextField( + controller: controller, + autofocus: true, + textCapitalization: .sentences, + minLines: 1, + maxLines: multiline ? 6 : 1, + onSubmitted: multiline ? null : (_) => save(), + decoration: .new(labelText: title), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/widgets/settings/upgrade_room_sheet.dart b/lib/widgets/settings/upgrade_room_sheet.dart new file mode 100644 index 0000000..a54325b --- /dev/null +++ b/lib/widgets/settings/upgrade_room_sheet.dart @@ -0,0 +1,100 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/capabilities.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/room_version_at_least.dart"; +import "package:nexus/widgets/settings/dialog_list_tile.dart"; +import "package:nexus/widgets/settings/member_chooser.dart"; + +/// Chooses a room version to upgrade [roomId] to, and its additional creators if the version has them. +class const UpgradeRoomSheet({ + required final String roomId, + required final String currentVersion, + + /// The room's current creators, other than the user, to keep in the new room. + required final ISet creators, + required final String? userId, + required final void Function(String version, ISet creators) onSave, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final roomVersions = ref + .watch(CapabilitiesController.provider) + .whenData((capabilities) => capabilities.roomVersions); + final chosen = useState(null); + final chosenCreators = useState(creators); + + final versions = roomVersions.value; + final version = chosen.value ?? versions?.defaultVersion; + + final valid = version != null; + final withCreators = valid && version.versionAtLeast(12); + void save() { + Navigator.of(context).pop(); + onSave(version!, withCreators ? chosenCreators.value : const .empty()); + } + + return SheetLayout( + title: Text("Upgrade Room"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton(onPressed: valid ? save : null, child: Text("Upgrade")), + ], + children: [ + Text( + "Upgrading creates a new room with the chosen version and closes this one, pointing its members to the new room. Members will have to join the new room themselves.", + ), + roomVersions.betterWhen( + data: (versions) => versions == null + ? Text( + "Your homeserver didn't list the room versions it supports.", + ) + : DialogListTile( + icon: Icon(Icons.upgrade), + title: "Room Version", + subtitle: Text("The version the new room will have."), + value: version, + options: versions.available.keys.sortedBy( + (option) => int.tryParse(option) ?? double.infinity, + ), + getName: (option) => switch ([ + if (option == currentVersion) "current", + if (option == versions.defaultVersion) "default", + if (versions.available[option] == .unstable) "unstable", + ]) { + final labels when labels.isNotEmpty => + "$option (${labels.join(", ")})", + _ => option, + }, + onChanged: (value) => chosen.value = value, + ), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: withCreators + ? MemberChooser( + roomId: roomId, + chosen: chosenCreators.value, + excluded: ISet([?userId]), + excludedError: "You'll be a creator already", + onAdded: (id) => + chosenCreators.value = chosenCreators.value.add(id), + onRemoved: (id) => + chosenCreators.value = chosenCreators.value.remove(id), + label: "Add creator", + removeTooltip: "Remove creator", + emptyText: "Creators outrank everyone in the new room, and can't be demoted. You'll be one, and can add others.", + ) + : const SizedBox(width: .infinity), + ), + ], + ); + } +} diff --git a/lib/widgets/sidebar/invite_card.dart b/lib/widgets/sidebar/invite_card.dart new file mode 100644 index 0000000..239d3c1 --- /dev/null +++ b/lib/widgets/sidebar/invite_card.dart @@ -0,0 +1,52 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/invites.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/room_summary_display.dart"; +import "package:nexus/models/room_summary.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; + +class const InviteCard(final RoomSummary invite, {super.key}) + extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final responding = ref + .watch(InvitesController.respondMutation(invite.roomId)) + .isPending; + final name = invite.name ?? invite.canonicalAlias ?? invite.roomId; + final inviterName = + invite.inviterProfile?.displayName ?? invite.invitedBy?.localpart; + + return Card( + clipBehavior: .antiAlias, + child: ListTile( + onTap: responding + ? null + : () => context.showAdaptiveSheet((_) => RoomSummarySheet(invite)), + trailing: responding + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(), + ) + : null, + leading: AvatarOrHash( + invite.avatarUrl, + name, + dimension: 40, + fallback: invite.isDirect ? null : Icon(invite.icon), + ), + title: Text(name, maxLines: 1, overflow: .ellipsis), + subtitle: Text( + [ + if (inviterName != null) + "${invite.isDirect ? "Direct message from" : "Invited by"} $inviterName", + ].join("\n"), + maxLines: 2, + overflow: .ellipsis, + ), + ), + ); + } +} diff --git a/lib/widgets/sidebar/invites_list.dart b/lib/widgets/sidebar/invites_list.dart new file mode 100644 index 0000000..18fb5f0 --- /dev/null +++ b/lib/widgets/sidebar/invites_list.dart @@ -0,0 +1,90 @@ +import "package:nexus/widgets/sidebar/invite_card.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/invites.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/nav_page.dart"; + +class const InvitesList({super.key}) extends ConsumerWidget implements NavPage { + @override + String get title => "Invites"; + + @override + IconData get icon => Icons.inbox; + + @override + int? badgeCount(WidgetRef ref) => switch (ref.watch( + InvitesController.provider.select((invites) => invites.length), + )) { + 0 => null, + final count => count, + }; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final invites = ref.watch(InvitesController.provider); + final decliningAll = ref + .watch(InvitesController.declineAllMutation) + .isPending; + + Future declineAll() async { + final confirmed = await context.showAdaptiveSheet( + (context) => SheetLayout( + title: const Text("Decline All Invites"), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text("Decline All"), + ), + ], + children: [ + Text( + "Are you sure you want to decline all ${invites.length} invites?", + ), + ], + ), + ); + if (confirmed != true) return; + + await InvitesController.declineAllMutation + .run( + ref, + (_) => ref.read(InvitesController.provider.notifier).declineAll(), + ) + .onError(showError); + } + + return Scaffold( + backgroundColor: Colors.transparent, + floatingActionButton: invites.isEmpty + ? null + : FloatingActionButton.extended( + onPressed: decliningAll ? null : declineAll, + icon: decliningAll + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(), + ) + : const Icon(Icons.clear_all), + label: const Text("Decline all"), + ), + body: invites.isEmpty + ? Center( + child: Text( + "No invites", + style: Theme.of(context).textTheme.headlineMedium, + ), + ) + : ListView( + // Leave room for the FAB under the last invite + padding: const .only(left: 8, top: 8, right: 8, bottom: 88), + children: invites.map(InviteCard.new).toList(), + ), + ); + } +} diff --git a/lib/widgets/sidebar/notifications_list.dart b/lib/widgets/sidebar/notifications_list.dart new file mode 100644 index 0000000..f52f944 --- /dev/null +++ b/lib/widgets/sidebar/notifications_list.dart @@ -0,0 +1,206 @@ +import "dart:async"; + +import "package:m3e_buttons/m3e_buttons.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/notification_request.dart"; +import "package:nexus/controllers/notifications.dart"; +import "package:nexus/helpers/extensions/focus_room.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/nav_page.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/highlight_wrapper.dart"; +import "package:nexus/widgets/common/loading.dart"; +import "package:nexus/widgets/renderers/event.dart"; +import "package:super_sliver_list/super_sliver_list.dart"; + +class const NotificationsList({super.key}) + extends HookConsumerWidget + implements NavPage { + @override + String get title => "Notifications"; + + @override + IconData get icon => Icons.notifications; + + @override + Null badgeCount(_) => null; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final options = { + M3EToggleButtonGroupAction( + checkedLabel: Text("Mentions"), + icon: Icon(Icons.alternate_email), + ): .highlight, + M3EToggleButtonGroupAction( + checkedLabel: Text("All Notifications"), + icon: Icon(Icons.notifications), + ): .notify, + }; + final allNotificationsIndex = options.values.toList().indexOf(.notify); + + final initialRequest = useMemoized( + () => ref.read(NotificationRequestController.provider), + ); + final unreadTypeIndex = useState( + initialRequest == null ? 0 : allNotificationsIndex, + ); + final highlightedId = useState(initialRequest); + final listController = useRef(ListController()); + final scrollController = useScrollController(); + + final provider = NotificationsController.provider(( + options.values.toList()[unreadTypeIndex.value], + null, + )); + final notifications = ref.watch(provider); + final notifier = ref.watch(provider.notifier); + + void consumeRequest() => Future.microtask( + ref.read(NotificationRequestController.provider.notifier).consume, + ); + + useEffect(() { + if (initialRequest != null) consumeRequest(); + return null; + }, []); + + ref.listen(NotificationRequestController.provider, (_, next) { + if (next == null) return; + unreadTypeIndex.value = allNotificationsIndex; + highlightedId.value = next; + consumeRequest(); + }); + + useEffect(() { + final target = highlightedId.value; + if (target == null) return null; + + Timer? timer; + var done = false; + + void listener() => WidgetsBinding.instance.addPostFrameCallback(( + _, + ) async { + if (done || !listController.value.isAttached) return; + + final notifications = await ref.read(provider.future); + + final index = notifications.indexWhere((tuple) => tuple.$1 == target); + + if (done || index == -1) return; + done = true; + listController.value.removeListener(listener); + + listController.value.animateToItem( + index: index, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => Durations.extralong1, + curve: (_) => Curves.easeInOut, + ); + timer = Timer(Durations.extralong4, () { + highlightedId.value = null; + }); + }); + + listController.value.addListener(listener); + // The list may already be laid out, in which case no change is notified + listener(); + + return () { + done = true; + listController.value.removeListener(listener); + timer?.cancel(); + }; + }, [highlightedId.value]); + + useEffect(() { + Future listener() async { + if (!scrollController.hasClients || notifications.isLoading) return; + + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent) { + await notifier.loadOlder(); + } + } + + scrollController.addListener(listener); + return () => scrollController.removeListener(listener); + }, [scrollController, notifications]); + + return Stack( + children: [ + Column( + children: [ + if (notifications is AsyncLoading && notifications.value != null) + const LinearProgressIndicator(minHeight: 2), + Expanded( + child: switch (notifications) { + AsyncData(:final value) || AsyncLoading(:final value?) => + value.isEmpty + ? Center( + child: Text( + "No notifications yet", + style: Theme.of(context).textTheme.headlineMedium, + ), + ) + : SuperListView.builder( + listController: listController.value, + controller: scrollController, + itemCount: value.length, + padding: const .symmetric( + horizontal: 8, + vertical: 12, + ), + reverse: true, + itemBuilder: (context, index) { + final (rowId, roomId) = value[index]; + final isHighlighted = rowId == highlightedId.value; + + return Padding( + padding: .only(top: 8), + child: HighlightWrapper( + InkWell( + onTap: () async { + final navigator = Navigator.of(context); + if (navigator.canPop()) navigator.pop(); + + await ref.focusRoom(roomId, rowId); + }, + child: IgnorePointer( + child: EventRenderer(rowId, roomId: roomId), + ), + ), + isHighlighted: isHighlighted, + ), + ); + }, + ), + AsyncLoading() => const Loading(), + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + }, + ), + ], + ), + Align( + alignment: .topRight, + child: Padding( + padding: .all(16), + child: M3EToggleButtonGroup( + selectedIndex: unreadTypeIndex.value, + onSelectedIndexChanged: (index) => + unreadTypeIndex.value = index ?? unreadTypeIndex.value, + actions: options.keys.toList(), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/sidebar/room_list.dart b/lib/widgets/sidebar/room_list.dart new file mode 100644 index 0000000..0e94cdb --- /dev/null +++ b/lib/widgets/sidebar/room_list.dart @@ -0,0 +1,59 @@ +import "package:nexus/widgets/sidebar/spaces_rail.dart"; +import "package:nexus/widgets/sidebar/rooms_pane.dart"; + +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/nav_page.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/spaces.dart"; + +class const RoomList({super.key}) extends ConsumerWidget implements NavPage { + @override + String get title => "Rooms"; + + @override + IconData get icon => Icons.list; + + @override + int? badgeCount(WidgetRef ref) { + final (notifications, hasUnread) = ref.watch( + RoomsController.provider.select( + (rooms) => rooms.values.fold( + (0, false), + (total, room) => ( + total.$1 + (room.metadata?.unreadNotifications ?? 0), + total.$2 || (room.metadata?.unreadMessages ?? 0) > 0, + ), + ), + ), + ); + + return notifications > 0 + ? notifications + : hasUnread + ? 0 + : null; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final spacesAsync = ref.watch(SpacesController.provider); + if (spacesAsync.value == null) { + return switch (spacesAsync) { + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + _ => const Center(child: CircularProgressIndicator()), + }; + } + + return const Row( + children: [ + SpacesRail(), + Expanded(child: RoomsPane()), + ], + ); + } +} diff --git a/lib/widgets/sidebar/rooms_pane.dart b/lib/widgets/sidebar/rooms_pane.dart new file mode 100644 index 0000000..aabebbb --- /dev/null +++ b/lib/widgets/sidebar/rooms_pane.dart @@ -0,0 +1,143 @@ +import "dart:math"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/helpers/extensions/get_all_child_rooms.dart"; +import "package:nexus/helpers/extensions/room_destinations.dart"; +import "package:nexus/widgets/common/appbar.dart"; +import "package:nexus/widgets/rooms/room_title.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/common/divider_widget.dart"; +import "package:nexus/widgets/common/m3e_rail_theme.dart"; +import "package:nexus/widgets/rooms/room_summary_sheet.dart"; +import "package:nexus/widgets/rooms/room_menu_button.dart"; +import "package:nexus/widgets/rooms/space_hierarchy_sheet.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/selected_room.dart"; +import "package:nexus/controllers/selected_space.dart"; + +class const RoomsPane({super.key}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedRoomId = ref.watch(SelectedRoomController.provider); + final selectedSpace = ref.watch(SelectedSpaceController.provider)!; + final isDm = selectedSpace.id == "dms"; + final spaceMetadata = selectedSpace.room?.metadata; + // In the order the rail shows them, with the space's own timeline first + final destinationIds = IList([?spaceMetadata?.id]) + .addAll(selectedSpace.allChildRooms.map((room) => room.metadata?.id)); + + return Scaffold( + backgroundColor: Colors.transparent, + appBar: Appbar( + // Home and DMs aren't real spaces, so they don't have a summary. + onTap: spaceMetadata == null + ? null + : () => context.showAdaptiveSheet( + (_) => RoomSummarySheet(.fromMetadata(spaceMetadata)), + ), + windowControls: false, + leading: AvatarOrHash( + spaceMetadata?.avatar, + fallback: selectedSpace.icon == null + ? null + : Icon(selectedSpace.icon), + selectedSpace.title, + ), + title: RoomTitle(selectedSpace.title, topic: spaceMetadata?.topic), + backgroundColor: Colors.transparent, + actions: .new([ + RoomMenuButton( + selectedSpace.room, + children: selectedSpace.allChildRooms, + ), + ]), + ), + body: M3ERailTheme( + extensions: [ + NavigationRailM3ETheme( + itemExpandedHeight: 48, + iconLabelGap: 16, + sectionHeaderSpacingTop: 0, + ), + ], + builder: (context) => Padding( + padding: const .only(top: 4), + child: NavigationRailM3E( + fab: selectedSpace.room == null + ? null + : .new( + icon: const Icon(Icons.explore), + label: "Browse space", + tooltip: "Browse and join rooms in this space", + onPressed: () => context.showAdaptiveSheet( + (_) => SpaceHierarchySheet(selectedSpace.id), + ), + ), + expandedWidth: double.infinity, + scrollable: true, + background: Colors.transparent, + type: .alwaysExpand, + selectedIndex: max(0, destinationIds.indexOf(selectedRoomId)), + sections: [ + if (selectedSpace.room case final room? + when spaceMetadata != null) + .new( + destinations: [ + room.toDestination( + isDm: isDm, + label: "Space timeline", + icon: const Icon(Icons.forum), + ), + ], + ), + .new( + header: selectedSpace.room == null + ? null + : const DividerWidget(Text("Rooms")), + destinations: selectedSpace.children.toDestinations(isDm: isDm), + ), + ...selectedSpace.subSpaces.map( + (subSpace) => NavigationRailM3ESection( + header: DividerWidget( + Row( + mainAxisSize: .min, + spacing: 8, + children: [ + if (subSpace.room.metadata?.avatar != null) + AvatarOrHash( + subSpace.room.metadata?.avatar, + subSpace.room.metadata?.name ?? "Unnamed Room", + dimension: 16, + ), + Flexible( + child: Text( + subSpace.room.metadata?.name ?? "Unnamed Space", + maxLines: 1, + overflow: .ellipsis, + ), + ), + ], + ), + ), + destinations: subSpace.children.toDestinations(isDm: isDm), + ), + ), + ], + onDestinationSelected: (value) { + ref + .read(KeyController.provider(KeyController.roomKey).notifier) + .set(destinationIds[value]); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/sidebar/sidebar.dart b/lib/widgets/sidebar/sidebar.dart new file mode 100644 index 0000000..d8f98cb --- /dev/null +++ b/lib/widgets/sidebar/sidebar.dart @@ -0,0 +1,61 @@ +import "package:collection/collection.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/sidebar_page.dart"; +import "package:nexus/models/nav_page.dart"; +import "package:nexus/models/sidebar_page.dart"; +import "package:nexus/widgets/common/count_badge.dart"; +import "package:nexus/widgets/sidebar/invites_list.dart"; +import "package:nexus/widgets/sidebar/notifications_list.dart"; +import "package:nexus/widgets/sidebar/room_list.dart"; + +class const Sidebar({super.key}) extends ConsumerWidget { + static NavPage pageFor(SidebarPage page) => switch (page) { + .rooms => const RoomList(), + .notifications => const NotificationsList(), + .invites => const InvitesList(), + }; + + /// The badges of every tab added together, e.g. for the drawer button. + /// + /// Like [NavPage.badgeCount], null means no badges and 0 means only dots. + static int? totalBadgeCount(WidgetRef ref) { + final counts = SidebarPage.values + .map((page) => pageFor(page).badgeCount(ref)) + .nonNulls; + + return counts.isEmpty ? null : counts.sum; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selected = ref.watch(SidebarPageController.provider); + final pages = SidebarPage.values.map(pageFor); + + return Drawer( + width: 330, + shape: Border(), + child: Scaffold( + backgroundColor: Colors.transparent, + body: pages.elementAt(selected.index), + bottomNavigationBar: NavigationBar( + selectedIndex: selected.index, + destinations: pages + .map( + (page) => NavigationDestination( + icon: CountBadge( + page.badgeCount(ref), + child: Icon(page.icon), + ), + label: page.title, + ), + ) + .toList(), + onDestinationSelected: (index) => ref + .read(SidebarPageController.provider.notifier) + .set(SidebarPage.values[index]), + ), + ), + ); + } +} diff --git a/lib/widgets/sidebar/spaces_rail.dart b/lib/widgets/sidebar/spaces_rail.dart new file mode 100644 index 0000000..d84a40a --- /dev/null +++ b/lib/widgets/sidebar/spaces_rail.dart @@ -0,0 +1,142 @@ +import "dart:math"; + +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/helpers/extensions/get_all_child_rooms.dart"; +import "package:nexus/widgets/common/account_switcher.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/widgets/rooms/create_room_sheet.dart"; +import "package:nexus/widgets/users/start_dm_sheet.dart"; +import "package:nexus/widgets/pages/explore.dart"; +import "package:nexus/widgets/pages/settings.dart"; +import "package:nexus/widgets/rooms/join_sheet.dart"; +import "package:nexus/widgets/common/m3e_rail_theme.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/spaces.dart"; + +class const SpacesRail({super.key}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final spaces = ref.watch( + SpacesController.provider.select((async) => async.requireValue), + ); + final selectedSpaceId = ref.watch( + KeyController.provider(KeyController.spaceKey) + .select((async) => async.requireValue), + ); + + return M3ERailTheme( + extensions: [ + NavigationRailM3ETheme(itemCollapsedHeight: 48, itemVerticalGap: 0), + ], + builder: (context) => Container( + color: NavigationRailTokensAdapter(context).containerColor, + padding: .only(top: 16), + child: NavigationRailM3E( + type: .alwaysCollapse, + labelBehavior: .alwaysHide, + scrollable: true, + onDestinationSelected: (value) { + ref + .read(KeyController.provider(KeyController.spaceKey).notifier) + .set(spaces[value].id); + ref + .read(KeyController.provider(KeyController.roomKey).notifier) + .set(spaces[value].children.firstOrNull?.metadata?.id); + }, + sections: [ + .new( + destinations: spaces.map((space) { + final notifications = space.allChildRooms.fold( + 0, + (sum, room) => + sum + (room.metadata?.unreadNotifications ?? 0), + ); + + return NavigationRailM3EDestination( + badgeCount: notifications > 0 + ? notifications + : space.allChildRooms.any( + (room) => room.metadata?.unreadMessages != 0, + ) + ? 0 + : null, + short: true, + icon: AvatarOrHash( + dimension: 28, + space.room?.metadata?.avatar, + fallback: space.icon == null ? null : Icon(space.icon), + space.title, + ), + label: space.title, + ); + }).toList(), + ), + ], + selectedIndex: max( + 0, + spaces.indexWhere((space) => space.id == selectedSpaceId), + ), + trailingAtBottom: true, + trailing: Padding( + padding: .only(top: 8), + child: Column( + children: [ + AdaptiveMenuButton( + tooltip: "Add a room", + itemBuilder: (_) => [ + PopupMenuItem( + onTap: () => + context.showAdaptiveSheet((_) => JoinSheet(ref)), + child: const ListTile( + title: Text("Join an existing room (or space)"), + leading: Icon(Icons.numbers), + ), + ), + PopupMenuItem( + onTap: () => context.showAdaptiveSheet( + (_) => const CreateRoomSheet(), + ), + child: const ListTile( + title: Text("Create a new room (or space)"), + leading: Icon(Icons.add), + ), + ), + PopupMenuItem( + onTap: () => context.showAdaptiveSheet( + (_) => const StartDmSheet(), + ), + child: const ListTile( + title: Text("Start a direct message"), + leading: Icon(Icons.person_add), + ), + ), + ], + icon: const Icon(Icons.add), + ), + const AccountSwitcher(), + IconButton( + tooltip: "Explore other rooms", + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ExplorePage()), + ), + icon: const Icon(Icons.explore), + ), + IconButton( + tooltip: "Open settings", + onPressed: () => showDialog( + context: context, + builder: (_) => SettingsPage(), + ), + icon: const Icon(Icons.settings), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/threads/thread_drawer.dart b/lib/widgets/threads/thread_drawer.dart new file mode 100644 index 0000000..bc88933 --- /dev/null +++ b/lib/widgets/threads/thread_drawer.dart @@ -0,0 +1,125 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/build_event_options.dart"; +import "package:nexus/helpers/hooks/chat_composer.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/widgets/room_chat/chat_body.dart"; + +/// The thread from [threadRoot], with a composer to send to it. +final class const ThreadDrawer( + final String roomId, { + required final String threadRoot, + + /// An event in the thread to scroll to once it loads. + final int? initialEvent, + required final String userId, + + /// Jumps to events outside this thread, like ones replied to from it. + required final JumpToEvent jumpToRoomEvent, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final composer = ChatComposer.use(ref, ( + roomId: roomId, + threadRoot: threadRoot, + )); + final contextualEvent = useState(initialEvent); + + final ChatParams chat = ( + roomId: roomId, + contextualEvent: contextualEvent.value, + threadRoot: threadRoot, + ); + final controllerProvider = RoomChatController.provider(chat); + final controllerData = ref.watch(controllerProvider); + + final scroll = ChatScroll.use( + controllerData: controllerData, + paginate: ref.watch(controllerProvider.notifier).paginate, + contextualEvent: contextualEvent, + markRead: () async { + final room = ref.read( + RoomsController.provider.select((rooms) => rooms[roomId]), + ); + + if (room != null) { + await ref + .read(ClientController.provider.notifier) + .markRead(room, threadRoot: threadRoot); + } + }, + ); + + final initialEventLoaded = + controllerData.value?.timeline.contains(initialEvent) ?? false; + // Only once, so returning to the newest events doesn't jump back to it + final jumpedToInitialEvent = useRef(false); + useEffect(() { + if (initialEvent case final rowId? + when initialEventLoaded && !jumpedToInitialEvent.value) { + jumpedToInitialEvent.value = true; + WidgetsBinding.instance.addPostFrameCallback( + (_) => scroll.jumpToEvent(rowId), + ); + } + + return null; + }, [initialEventLoaded]); + + // This chat only has the thread, so other events are left to the room. + // Ones in the thread that aren't loaded are loaded around, like the room. + Future jumpToEvent(int rowId) async { + final isInThread = + ref + .read(RoomsController.provider)[roomId] + ?.events[rowId] + ?.threadRoot == + threadRoot; + if (isInThread || + (controllerData.value?.timeline.contains(rowId) ?? false)) { + return scroll.jumpToEvent(rowId); + } + + Scaffold.of(context).closeEndDrawer(); + await jumpToRoomEvent(rowId); + } + + return Drawer( + width: 400, + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + scrolledUnderElevation: 0, + leading: Icon(Icons.forum), + title: Text("Thread"), + actionsPadding: .only(right: 4), + actions: [ + IconButton( + onPressed: Scaffold.of(context).closeEndDrawer, + icon: Icon(Icons.close), + tooltip: "Close thread", + ), + ], + ), + body: ChatBody( + chat: chat, + scroll: scroll, + jumpToEvent: jumpToEvent, + getEventOptions: (event) => event.buildEventOptions( + context: context, + ref: ref, + roomId: roomId, + userId: userId, + onRelation: composer.relate, + ), + composer: composer, + ), + ), + ); + } +} diff --git a/lib/widgets/threads/thread_summary.dart b/lib/widgets/threads/thread_summary.dart new file mode 100644 index 0000000..fba44ba --- /dev/null +++ b/lib/widgets/threads/thread_summary.dart @@ -0,0 +1,88 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/thread_events.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"; + +/// How many replies are in the thread from [root], and the latest one, or nothing if it has none. +class const ThreadSummary( + final Event root, { + required final VoidCallback onTap, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final thread = ref.watch( + RoomsController.provider.select( + (rooms) => rooms[root.roomId]?.thread(root), + ), + ); + + if (thread case (:final count, :final latest) when count > 0) { + return Card( + margin: .only(top: 8), + color: colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: onTap, + child: Padding( + padding: .symmetric(vertical: 12, horizontal: 16), + child: Column( + crossAxisAlignment: .start, + spacing: 8, + children: [ + Row( + mainAxisSize: .min, + spacing: 6, + children: [ + Icon(Icons.forum, size: 16, color: colorScheme.primary), + Text( + "$count ${count == 1 ? "reply" : "replies"}", + style: theme.textTheme.labelLarge?.copyWith( + color: colorScheme.primary, + ), + ), + Icon( + Icons.chevron_right, + size: 16, + color: colorScheme.primary, + ), + ], + ), + if (latest != null) + IgnorePointer( + child: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + MessageAvatar(latest, height: 24), + Flexible( + child: MessageDisplayname(latest, shouldWrap: false), + ), + Flexible( + flex: 2, + child: EventRenderer( + latest.rowId, + roomId: latest.roomId, + textOnly: true, + maxLines: 1, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + return SizedBox.shrink(); + } +} diff --git a/lib/widgets/threads/threads_drawer.dart b/lib/widgets/threads/threads_drawer.dart new file mode 100644 index 0000000..e7931da --- /dev/null +++ b/lib/widgets/threads/threads_drawer.dart @@ -0,0 +1,157 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/thread_list.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/thread_events.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; +import "package:nexus/widgets/renderers/event_preview.dart"; +import "package:nexus/widgets/common/timestamp.dart"; + +final class const ThreadsDrawer( + final String roomId, { + required final void Function(String threadRoot) onOpenThread, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final threads = ref.watch( + RoomsController.provider.select( + (rooms) => rooms[roomId]?.threads ?? const .empty(), + ), + ); + final serverProvider = ThreadListController.provider(roomId); + final server = ref.watch(serverProvider); + final theme = Theme.of(context); + + void open(String threadRoot) { + Navigator.of(context).pop(); + onOpenThread(threadRoot); + } + + return Drawer( + width: 400, + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + scrolledUnderElevation: 0, + leading: Icon(Icons.forum), + title: Text("Threads"), + actionsPadding: .only(right: 4), + actions: [ + IconButton( + onPressed: Scaffold.of(context).closeEndDrawer, + icon: Icon(Icons.close), + tooltip: "Close threads", + ), + ], + ), + body: switch (server) { + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + AsyncValue(hasValue: true, value: final nextBatch) + when threads.isNotEmpty => + ListView.builder( + padding: .all(8), + itemCount: threads.length + (nextBatch == null ? 0 : 1), + itemBuilder: (context, index) { + if (index == threads.length) { + return Center( + child: TextButton( + onPressed: server.isLoading + ? null + : () => ref + .read(serverProvider.notifier) + .loadMore() + .onError(showError), + child: Text("Load more"), + ), + ); + } + + final (:root, :info) = threads[index]; + return Card( + margin: .symmetric(vertical: 4), + color: theme.colorScheme.surfaceContainerHigh, + clipBehavior: .antiAlias, + child: InkWell( + onTap: () => open(root.eventId), + child: Padding( + padding: .symmetric(vertical: 8, horizontal: 12), + child: Column( + crossAxisAlignment: .start, + spacing: 4, + children: [ + EventPreview(root.rowId, roomId: roomId), + Row( + spacing: 6, + children: [ + Icon( + Icons.forum, + size: 16, + color: theme.colorScheme.primary, + ), + Text( + "${info.count} ${info.count == 1 ? "reply" : "replies"}", + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + ), + ), + if (info.latest case final latest?) + Expanded( + child: Row( + mainAxisAlignment: .end, + spacing: 4, + children: [ + Flexible( + child: Text( + "Last active:", + maxLines: 1, + overflow: .ellipsis, + style: theme.textTheme.labelSmall + ?.copyWith(color: Colors.grey), + ), + ), + Timestamp(latest.timestamp), + ], + ), + ) + else + Spacer(), + Icon( + Icons.chevron_right, + size: 16, + color: theme.colorScheme.primary, + ), + ], + ), + ], + ), + ), + ), + ); + }, + ), + AsyncLoading() => Loading(), + AsyncData() => Center( + child: Column( + mainAxisSize: .min, + children: [ + Icon( + Icons.forum_outlined, + size: 48, + color: theme.colorScheme.onSurface, + ), + SizedBox(height: 12), + Text("No threads", style: theme.textTheme.headlineSmall), + ], + ), + ), + }, + ), + ); + } +} diff --git a/lib/widgets/users/mutual_rooms_sheet.dart b/lib/widgets/users/mutual_rooms_sheet.dart new file mode 100644 index 0000000..16066b0 --- /dev/null +++ b/lib/widgets/users/mutual_rooms_sheet.dart @@ -0,0 +1,72 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/mutual_rooms.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/sidebar_page.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/focus_room.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; + +/// The rooms the user shares with [userId]. +class const MutualRoomsSheet(final String userId, {super.key}) + extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => SheetLayout( + title: Text("Mutual Rooms"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Close")), + ], + children: [ + ref + .watch(MutualRoomsController.provider(userId)) + .betterWhen( + data: (roomIds) { + final rooms = ref.watch( + RoomsController.provider.select( + (allRooms) => roomIds + .map((roomId) => allRooms[roomId]?.metadata) + .nonNulls + .toIList(), + ), + ); + + return rooms.isEmpty + ? Text("No mutual rooms") + : Column( + children: rooms + .map( + (room) => ListTile( + leading: AvatarOrHash( + room.avatar, + room.name ?? "Unnamed Room", + fallback: Icon(Icons.numbers), + ), + title: Text( + room.name ?? "Unnamed Room", + overflow: .ellipsis, + ), + onTap: () async { + final container = ProviderScope.containerOf( + context, + ); + // Closes the user popover under this too + Navigator.of(context) + .popUntil((route) => route.isFirst); + await container.focusRoom(room.id); + container + .read( + SidebarPageController.provider.notifier, + ) + .set(.rooms); + }, + ), + ) + .toList(), + ); + }, + ), + ], + ); +} diff --git a/lib/widgets/users/start_dm_sheet.dart b/lib/widgets/users/start_dm_sheet.dart new file mode 100644 index 0000000..e0f38df --- /dev/null +++ b/lib/widgets/users/start_dm_sheet.dart @@ -0,0 +1,78 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/create_room.dart"; + +/// Starts a DM, asking for a user ID unless [userId] is given. Pops with true +/// if a DM was started. +class const StartDmSheet({final String? userId, final String? name, super.key}) + extends HookWidget { + @override + Widget build(BuildContext context) { + final userIdController = useTextEditingController(); + final text = useValueListenable(userIdController).text.trim(); + final target = userId ?? text; + final valid = target.isValidUserId; + final encrypted = useState(true); + final reasonController = useTextEditingController(); + + void submit() { + if (!valid) return; + context.startDirectMessage( + target, + name: name ?? target, + encrypted: encrypted.value, + inviteReason: reasonController.text.trim().isEmpty + ? null + : reasonController.text.trim(), + ); + Navigator.of(context).pop(true); + } + + return SheetLayout( + title: const Text("Start a Direct Message"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: valid ? submit : null, + child: const Text("Start"), + ), + ], + children: [ + if (userId == null) + TextField( + controller: userIdController, + autofocus: true, + keyboardType: .emailAddress, + onSubmitted: (_) => submit(), + decoration: .new( + labelText: "User ID", + hintText: "@user:example.com", + errorText: text.isEmpty || valid ? null : "Enter a valid user ID", + ), + ) + else + Text( + "Are you sure you want to start a direct message with ${name ?? userId}?", + ), + TextField( + controller: reasonController, + textCapitalization: .sentences, + onSubmitted: (_) => submit(), + decoration: const .new(labelText: "Invite reason (optional)"), + ), + SwitchListTile( + secondary: const Icon(Icons.lock), + title: const Text("Encrypted"), + subtitle: const Text("Can't be turned off later"), + value: encrypted.value, + onChanged: (value) => encrypted.value = value, + ), + ], + ); + } +} diff --git a/lib/widgets/users/timezone_field.dart b/lib/widgets/users/timezone_field.dart new file mode 100644 index 0000000..6f802b6 --- /dev/null +++ b/lib/widgets/users/timezone_field.dart @@ -0,0 +1,103 @@ +import "dart:async"; + +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/timezones.dart"; +import "package:nexus/helpers/extensions/weighted_search.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/widgets/settings/inline_text_field.dart"; +import "package:timezone/timezone.dart"; + +class const TimezoneField({ + required final String? value, + + required final FutureOr Function(String? value)? onChanged, + final TextStyle? style, + super.key, +}) extends HookConsumerWidget { + static String offsetOf(String timezone) { + final offset = TZDateTime.now(getLocation(timezone)).timeZoneOffset; + final hours = offset.inHours.abs().toString().padLeft(2, "0"); + final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, "0"); + return "UTC${offset.isNegative ? "-" : "+"}$hours:$minutes"; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final saving = Saving.use(); + final timezones = ref.watch(TimezonesController.provider); + final enabled = onChanged != null && !saving.active; + final iconSize = InlineTextField.iconSizeOf(style); + + void choose(SearchController controller, String? timezone) { + controller.closeView(""); + if (timezone != value) saving.run(() => onChanged!(timezone)); + } + + return SearchAnchor( + viewHintText: "Search timezones...", + viewConstraints: context.isDesktop + ? const .new(minWidth: 320, maxWidth: 400, maxHeight: 400) + : null, + builder: (context, controller) => InkWell( + hoverColor: colorScheme.surfaceContainerHighest, + onTap: enabled ? controller.openView : null, + child: Padding( + padding: const .fromLTRB(6, 8, 0, 8), + child: Row( + mainAxisSize: .min, + children: [ + Text( + value?.replaceAll("_", " ") ?? "Timezone", + style: value == null + ? style?.copyWith(color: colorScheme.onSurfaceVariant) + : style, + ), + SizedBox( + width: iconSize + 12, + child: saving.active + ? Center( + child: SizedBox.square( + dimension: iconSize - 4, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : Icon( + Icons.edit, + size: iconSize, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + suggestionsBuilder: (context, controller) { + final query = controller.text.trim().toLowerCase().replaceAll(" ", "_"); + + return [ + if (value != null && query.isEmpty) + ListTile( + leading: Icon(Icons.close), + title: Text("Remove timezone"), + onTap: () => choose(controller, null), + ), + ...timezones + .search(query, (timezone) => [(timezone, 1)]) + .map( + (timezone) => ListTile( + leading: Icon( + timezone == value ? Icons.check : Icons.schedule, + ), + title: Text(timezone.replaceAll("_", " ")), + subtitle: Text(offsetOf(timezone)), + onTap: () => choose(controller, timezone), + ), + ), + ]; + }, + ); + } +} diff --git a/lib/widgets/users/user_bottom_sheet.dart b/lib/widgets/users/user_bottom_sheet.dart new file mode 100644 index 0000000..12e0e24 --- /dev/null +++ b/lib/widgets/users/user_bottom_sheet.dart @@ -0,0 +1,530 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.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:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/account_data.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/mutual_rooms.dart"; +import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/profile.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/create_room.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; +import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/widgets/common/error_dialog.dart"; +import "package:nexus/widgets/common/loading.dart"; +import "package:nexus/widgets/users/mutual_rooms_sheet.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/membership_action.dart"; +import "package:nexus/models/profile_response.dart"; +import "package:nexus/widgets/avatars/avatar_or_hash.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/avatars/editable_avatar.dart"; +import "package:nexus/widgets/common/expandable_image.dart"; +import "package:nexus/widgets/settings/inline_text_field.dart"; +import "package:nexus/widgets/users/start_dm_sheet.dart"; +import "package:nexus/widgets/users/timezone_field.dart"; + +final class const UserBottomSheet( + final String userId, { + final String? roomId, + + /// Edits the current user's global profile, instead of showing actions. + final bool editable = false, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final subtitleStyle = textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); + final client = ref.read(ClientController.provider.notifier); + final profileController = ref.read( + ProfileController.provider(userId).notifier, + ); + Future setProfileField(String field, Object? value) => + profileController.setField(field, value).onError(showError); + + final profile = ref + .watch(ProfileController.provider(userId)) + .value + ?.profile; + final englishPronouns = (profile?.pronouns ?? const IList.empty()) + // TODO: Check system language (l10n) + .where((pronoun) => pronoun.language == "en"); + final divider = SizedBox( + height: 16, + child: VerticalDivider( + thickness: 1.5, + width: 4, + color: theme.colorScheme.onSurfaceVariant, + ), + ); + + Widget avatarOf(MembershipContent member) => ExpandableImage( + member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!), + downloadFilename: member.displayName ?? userId.localpart, + child: AvatarOrHash( + member.avatarUrl, + member.displayName ?? userId.localpart, + dimension: 200, + ), + ); + + void showMembershipSheet(MembershipAction action) => + context.showAdaptiveSheet( + (context) => HookBuilder( + builder: (context) { + final actionReasonController = useTextEditingController(); + return SheetLayout( + title: Text( + "${toBeginningOfSentenceCase(action.name)} $userId", + ), + 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)), + ), + ], + children: [ + Text("Are you sure you want to ${action.name} $userId?"), + TextField( + textCapitalization: .sentences, + controller: actionReasonController, + decoration: .new( + labelText: "Reason for ${action.name} (optional)", + ), + ), + ], + ); + }, + ), + ); + + return switch (ref.watch( + UserController.provider(.new(roomId: roomId, userId: userId)), + )) { + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + AsyncData(value: final member) || + AsyncLoading(value: final member?) => SingleChildScrollView( + child: Padding( + padding: .all(editable ? 16 : 42), + child: Column( + spacing: 4, + mainAxisSize: .min, + crossAxisAlignment: .center, + children: [ + if (!editable) ...[ + Row( + mainAxisAlignment: .end, + spacing: 8, + children: [ + if (userId != + ref.watch(ClientStateController.provider)?.userId) + Consumer( + builder: (context, ref, _) { + final ignored = ref.watch( + AccountDataController.provider.select( + (data) => data.ignoredUserList.contains(userId), + ), + ); + final name = member.displayName ?? userId.localpart; + Future setIgnored( + bool ignored, + ) => context.withProgressSnackbar( + "${ignored ? "Blocking" : "Unblocking"} $name...", + () => ref + .read(AccountDataController.provider.notifier) + .setIgnored(userId, ignored: ignored), + ); + + return M3EButton( + tooltip: ignored ? "Unblock" : "Block", + onPressed: ignored + ? () => setIgnored(false) + : () => context.showAdaptiveSheet( + (context) => SheetLayout( + title: Text("Block $name"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + setIgnored(true); + }, + style: TextButton.styleFrom( + foregroundColor: + theme.colorScheme.error, + ), + child: Text("Block"), + ), + ], + children: [ + Text( + "Are you sure you want to block $userId? You won't see their new messages or invites.", + ), + ], + ), + ), + style: .tonal, + child: Icon( + ignored ? Icons.lock_open : Icons.block, + ), + ); + }, + ), + M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), + ), + ], + ), + SizedBox(height: 18), + ], + + if (editable) + EditableAvatar( + hasAvatar: member.avatarUrl != null, + onChanged: (url) => setProfileField( + Profile.avatarUrlKey, + url?.toString() ?? "", + ), + child: avatarOf(member), + ) + else + avatarOf(member), + + SizedBox(height: 8), + + if (editable) + InlineTextField( + value: profile?.displayName, + hint: userId.localpart, + style: textTheme.headlineLarge, + onChanged: profile == null + ? null + : (value) => + setProfileField(Profile.displayNameKey, value), + ) + else + SelectableText( + member.displayName ?? userId.localpart, + style: textTheme.headlineLarge, + textAlign: .center, + ), + SelectableText(userId, textAlign: .center, style: subtitleStyle), + + ref + .watch(ProfileController.provider(userId)) + .betterWhen( + // Keeps showing the profile while it's fetched again after saving + skipLoadingOnRefresh: true, + loading: () => Text(""), + data: (profileResponse) => Column( + children: [ + Wrap( + crossAxisAlignment: .center, + alignment: .center, + spacing: 4, + runSpacing: 4, + children: editable + ? [ + InlineTextField( + value: englishPronouns + .map((pronoun) => pronoun.summary) + .join(", "), + hint: "Pronouns, e.g. they/them", + balanced: false, + style: subtitleStyle, + onChanged: (value) { + final pronouns = profileResponse + .profile + .pronouns + .where( + (pronoun) => + // TODO: Check system language (l10n) + pronoun.language != "en", + ) + .toIList() + .addAll( + value + .split(",") + .map( + (summary) => summary.trim(), + ) + .where( + (summary) => + summary.isNotEmpty, + ) + .map( + (summary) => Pronoun( + language: "en", + summary: summary, + ), + ), + ); + + return setProfileField( + Profile.unstablePronounsKey, + pronouns.isEmpty + ? null + : pronouns + .map( + (pronoun) => + pronoun.toJson(), + ) + .toList(), + ); + }, + ), + divider, + TimezoneField( + value: profileResponse.profile.timezone, + style: subtitleStyle, + onChanged: (value) => setProfileField( + Profile.timezoneKey, + value, + ), + ), + ] + : [ + ...englishPronouns + .mapIndexed( + (index, pronoun) => [ + if (index != 0) + Icon( + Icons.circle, + size: 4, + color: theme + .colorScheme + .onSurfaceVariant, + ), + Text( + pronoun.summary, + textAlign: .center, + style: subtitleStyle, + ), + ], + ) + .flattened, + + if (profileResponse.profile.timezone != + null) ...[ + if (profileResponse + .profile + .pronouns + .isNotEmpty) + divider, + Text( + profileResponse.profile.timezone!, + textAlign: .center, + style: subtitleStyle, + ), + ], + ], + ), + ], + ), + ), + + if (!editable && + userId != ref.watch(ClientStateController.provider)?.userId) + switch (ref.watch(MutualRoomsController.provider(userId))) { + AsyncData(value: final roomIds) => switch (ref.watch( + RoomsController.provider.select( + (rooms) => roomIds.where(rooms.containsKey).length, + ), + )) { + 0 => SizedBox.shrink(), + final count => TextButton( + onPressed: () => context.showAdaptiveSheet( + (_) => MutualRoomsSheet(userId), + ), + child: Text( + "$count mutual ${Intl.plural(count, one: "room", other: "rooms")}", + ), + ), + }, + _ => SizedBox.shrink(), + }, + + SizedBox(height: 8), + if (editable || + userId != + ref.watch(ClientStateController.provider)?.userId) ...[ + Row( + children: [ + Expanded( + child: M3EButton.icon( + onPressed: editable + ? null + : () async { + final name = + member.displayName ?? userId.localpart; + final hasDm = ref + .read(RoomsController.provider) + .values + .any( + (room) => + room.metadata?.dmUserId == userId, + ); + + if (hasDm) { + context.startDirectMessage( + userId, + name: name, + ); + } else if (await context + .showAdaptiveSheet( + (_) => StartDmSheet( + userId: userId, + name: name, + ), + ) != + true) { + return; + } + + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + shape: .square, + style: .tonal, + icon: Icon(Icons.message), + label: Text("Message"), + ), + ), + ], + ), + + if (roomId != null && + member.status == .knock && + ref.watch( + PowerLevelController.provider( + .membershipAction( + action: .invite, + roomId: roomId!, + targetUser: userId, + ), + ), + )) + Padding( + padding: .only(top: 4), + child: Row( + children: [ + Expanded( + child: M3EButton.icon( + onPressed: () => client + .setMembership( + .new( + userId: userId, + roomId: roomId!, + action: .invite, + ), + ) + .onError(showError), + shape: .square, + icon: Icon(Icons.check), + label: Text("Accept knock"), + decoration: .styleFrom( + backgroundColor: theme.successScheme.primary, + foregroundColor: theme.successScheme.onPrimary, + ), + ), + ), + ], + ), + ), + + // Shown as a moderator would see it, while editing + if (editable || + (roomId != null && + ref.watch( + PowerLevelController.provider( + .membershipAction( + action: .kick, + roomId: roomId!, + targetUser: userId, + ), + ), + ))) + Padding( + padding: .only(top: 4), + child: Row( + spacing: 8, + children: [ + if (editable || + switch (member.status) { + .join || .invite || .knock => true, + _ => false, + }) + M3EButton.icon( + onPressed: editable + ? null + : () => showMembershipSheet(.kick), + shape: .square, + icon: Icon(Icons.sports_martial_arts), + label: Text("Kick"), + decoration: .styleFrom( + backgroundColor: theme.colorScheme.error, + foregroundColor: theme.colorScheme.onError, + ), + ), + + M3EButton.icon( + onPressed: editable + ? null + : () => showMembershipSheet(.ban), + shape: .square, + icon: Icon(Icons.gavel), + label: Text("Ban"), + decoration: .styleFrom( + backgroundColor: theme.colorScheme.errorContainer, + foregroundColor: theme.colorScheme.onErrorContainer, + ), + ), + ].map((e) => Expanded(child: e)).toList(), + ), + ), + ], + ], + ), + ), + ), + AsyncLoading _ => Loading(), + }; + } +} diff --git a/lib/widgets/users/user_span.dart b/lib/widgets/users/user_span.dart new file mode 100644 index 0000000..c131a82 --- /dev/null +++ b/lib/widgets/users/user_span.dart @@ -0,0 +1,63 @@ +import "package:flutter/gestures.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; +import "package:nexus/controllers/author.dart"; +import "package:nexus/helpers/extensions/displayname_color.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/event.dart"; + +/// A user's [name] in text, opening their popover when tapped. Unlike a widget +/// in text, it wraps and ellipsizes with the rest of it. +class UserSpan extends TextSpan { + UserSpan( + BuildContext context, + String name, { + required String userId, + required TapGestureRecognizer recognizer, + String? roomId, + super.style, + super.children, + }) : super( + text: name, + recognizer: recognizer + ..onTapUp = (details) { + if (!context.mounted) return; + + context.showUserPopover( + userId, + roomId: roomId, + anchor: context.globalRectAt(details.globalPosition), + ); + }, + ); + + factory UserSpan.sender( + BuildContext context, + WidgetRef ref, + Event event, { + required TapGestureRecognizer recognizer, + }) { + final color = ref.displaynameColor(event.roomId, event.sender); + + return UserSpan( + context, + ref.watch(AuthorController.provider(event)).value?.displayName ?? + event.sender.localpart, + userId: event.sender, + roomId: event.roomId, + recognizer: recognizer, + style: TextStyle(color: color, fontWeight: .bold), + children: [ + if (event.pmp != null) + TextSpan( + text: " (via ${event.sender})", + recognizer: recognizer, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: color, fontWeight: .bold), + ), + ], + ); + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 2e0c766..7f5e185 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -126,3 +126,42 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) endif() + +# On Debug builds, register this build as the handler for the app's URI +# scheme, so links like OAuth redirects open it, based on the packaged +# desktop file but running this bundle's binary. +if(CMAKE_BUILD_TYPE MATCHES "Debug") + install(CODE " + set(URI_DESKTOP_FILE \"${CMAKE_CURRENT_SOURCE_DIR}/${APPLICATION_ID}.uri.desktop\") + set(URI_DESKTOP_NAME \"${APPLICATION_ID}.uri.desktop\") + set(URI_BINARY_NAME \"${BINARY_NAME}\") + " COMPONENT Runtime) + install(CODE [[ + if(DEFINED ENV{XDG_DATA_HOME} AND NOT "$ENV{XDG_DATA_HOME}" STREQUAL "") + set(applications_dir "$ENV{XDG_DATA_HOME}/applications") + else() + set(applications_dir "$ENV{HOME}/.local/share/applications") + endif() + + file(READ "${URI_DESKTOP_FILE}" desktop_entry) + string(REGEX REPLACE "(^|\n)Exec=${URI_BINARY_NAME} " + "\\1Exec=\"${CMAKE_INSTALL_PREFIX}/${URI_BINARY_NAME}\" " + desktop_entry "${desktop_entry}") + + # Only rewritten when it changes, so the desktop database isn't refreshed + # on every build + set(destination "${applications_dir}/${URI_DESKTOP_NAME}") + set(existing "") + if(EXISTS "${destination}") + file(READ "${destination}" existing) + endif() + if(NOT existing STREQUAL desktop_entry) + message(STATUS "Registering URI handler: ${destination}") + file(WRITE "${destination}" "${desktop_entry}") + execute_process( + COMMAND update-desktop-database "${applications_dir}" + RESULT_VARIABLE _ OUTPUT_QUIET ERROR_QUIET + ) + endif() + ]] COMPONENT Runtime) +endif() diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f70fb6e..077376d 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,30 +6,34 @@ #include "generated_plugin_registrant.h" -#include +#include +#include #include -#include +#include +#include +#include #include -#include -#include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) dynamic_system_colors_registrar = + g_autoptr(FlPluginRegistrar) app_links_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AppLinksPluginLinux"); + app_links_plugin_linux_register_with_registrar(app_links_linux_registrar); + g_autoptr(FlPluginRegistrar) dynamic_color_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin"); - dynamic_color_plugin_register_with_registrar(dynamic_system_colors_registrar); + dynamic_color_plugin_register_with_registrar(dynamic_color_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); - g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); - screen_retriever_linux_plugin_register_with_registrar(screen_retriever_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) pasteboard_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin"); + pasteboard_plugin_register_with_registrar(pasteboard_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); - 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 78dcf40..89cfca2 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,15 +3,17 @@ # list(APPEND FLUTTER_PLUGIN_LIST - dynamic_system_colors + app_links_linux + dynamic_color file_selector_linux - screen_retriever_linux + media_kit_libs_linux + media_kit_video + pasteboard url_launcher_linux - window_manager - window_size ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/linux/nexus.federated.nexus.UnifiedPush.service b/linux/nexus.federated.nexus.UnifiedPush.service new file mode 100644 index 0000000..663ab9b --- /dev/null +++ b/linux/nexus.federated.nexus.UnifiedPush.service @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=nexus.federated.nexus.UnifiedPush +Exec=/usr/bin/env FLUTTER_HEADLESS=1 nexus diff --git a/linux/nexus.federated.nexus.desktop b/linux/nexus.federated.nexus.desktop new file mode 100644 index 0000000..64b4264 --- /dev/null +++ b/linux/nexus.federated.nexus.desktop @@ -0,0 +1,10 @@ +[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; +DBusActivatable=true \ No newline at end of file diff --git a/linux/nexus.federated.nexus.service b/linux/nexus.federated.nexus.service new file mode 100644 index 0000000..1757c2c --- /dev/null +++ b/linux/nexus.federated.nexus.service @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=nexus.federated.nexus +Exec=/usr/bin/env nexus diff --git a/linux/nexus.federated.nexus.uri.desktop b/linux/nexus.federated.nexus.uri.desktop new file mode 100644 index 0000000..275c352 --- /dev/null +++ b/linux/nexus.federated.nexus.uri.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=Nexus URI Handler +Exec=nexus %u +Icon=nexus +Terminal=false +Type=Application +NoDisplay=true +MimeType=x-scheme-handler/nexus.federated.nexus;x-scheme-handler/matrix; \ No newline at end of file diff --git a/linux/nix/build_env.nix b/linux/nix/build_env.nix new file mode 100644 index 0000000..cac5fbd --- /dev/null +++ b/linux/nix/build_env.nix @@ -0,0 +1,30 @@ +{ pkgs, lib }: +# What building Nexus needs, shared by the dev shell and the e2e tests +{ + packages = + with pkgs; + [ + stdenv.cc + go + git + + libGL + (flutter.override { + extraPkgConfigPackages = [ + mpv-unwrapped + libass + ]; + }) + ] + ++ lib.optionals pkgs.stdenv.isLinux [ + wayland + # nativeapi includes XInput2.h, which needs these, but xi.pc only lists them privately + libxext + libxfixes + (callPackage ./pkg/hook_pkg_config.nix { }) + ]; + + env = lib.optionalAttrs pkgs.stdenv.isLinux { + LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; + }; +} diff --git a/linux/nix/default.nix b/linux/nix/default.nix new file mode 100644 index 0000000..3d3b395 --- /dev/null +++ b/linux/nix/default.nix @@ -0,0 +1,106 @@ +{ self, inputs, ... }: { + + perSystem = + { + pkgs, + system, + lib, + ... + }: + { + _module.args.pkgs = import inputs.nixpkgs { + inherit system; + config = { + android_sdk.accept_license = true; + allowUnfree = true; + }; + }; + + packages = + let + default = pkgs.callPackage ./pkg { + src = self; + commit = self.shortRev or self.dirtyShortRev; + }; + in + { + inherit default; + + flatpak = inputs.nix2flatpak.lib.${system}.mkFlatpak { + appName = "Nexus"; + developer = "QuadRadical"; + appId = "nexus.federated.nexus"; + package = default; + runtime = "org.gnome.Platform/51"; + permissions = { + share = [ "network" ]; + sockets = [ + "pulseaudio" + "fallback-x11" + "wayland" + ]; + + talk-names = [ + "org.unifiedpush.Distributor.*" + "org.freedesktop.Notifications" + ]; + devices = [ "dri" ]; + }; + }; + + gomuks = pkgs.callPackage ./pkg/gomuks.nix { + src = self; + }; + }; + + devShells.default = pkgs.callPackage ./devshell.nix { }; + + apps = + let + buildEnv = import ./build_env.nix { inherit pkgs lib; }; + e2e = pkgs.writeShellApplication { + name = "nexus-e2e"; + runtimeInputs = + buildEnv.packages + ++ (with pkgs; [ + # TEMPORARY: matrix-continuwuity, once the local fixes are released + inputs.continuwuity.packages.${system}.default + curl + dbus + ]); + runtimeEnv = buildEnv.env; + text = builtins.readFile ./e2e.sh; + }; + in + { + e2e = { + type = "app"; + program = lib.getExe e2e; + }; + + e2e-headless = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "nexus-e2e-headless"; + runtimeInputs = [ pkgs.cage ]; + runtimeEnv = { + WLR_BACKENDS = "headless"; + __EGL_VENDOR_LIBRARY_FILENAMES = "${pkgs.mesa}/share/glvnd/egl_vendor.d/50_mesa.json"; + }; + text = '' + unset DISPLAY WAYLAND_DISPLAY + # Wayland needs one, which CI may not have + XDG_RUNTIME_DIR="''${XDG_RUNTIME_DIR:-$(mktemp -d)}" + export XDG_RUNTIME_DIR + + exec cage -- ${lib.getExe e2e} "$@" + ''; + } + ); + }; + }; + }; + + flake.nixosModules.default = import ./module.nix self; +} diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix new file mode 100644 index 0000000..cc2223e --- /dev/null +++ b/linux/nix/devshell.nix @@ -0,0 +1,39 @@ +{ pkgs, lib }: +let + android = pkgs.androidenv.composeAndroidPackages { + toolsVersion = "26.1.1"; + platformToolsVersion = "37.0.1"; + buildToolsVersions = [ + "35.0.0" + "36.0.0" + ]; + cmakeVersions = [ "3.22.1" ]; + platformVersions = [ + "34" + "35" + "36" + ]; + abiVersions = [ + "armeabi-v7a" + "arm64-v8a" + ]; + includeNDK = true; + ndkVersions = [ "28.2.13676358" ]; + }; + buildEnv = import ./build_env.nix { inherit pkgs lib; }; +in +pkgs.mkShell { + packages = buildEnv.packages ++ [ android.platform-tools ]; + + env = + rec { + LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ]; + + ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk"; + ANDROID_SDK_ROOT = ANDROID_HOME; + JAVA_HOME = pkgs.jdk21; + + GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/36.0.0/aapt2"; + } + // buildEnv.env; +} diff --git a/linux/nix/e2e.sh b/linux/nix/e2e.sh new file mode 100755 index 0000000..6cc9f09 --- /dev/null +++ b/linux/nix/e2e.sh @@ -0,0 +1,77 @@ +# shellcheck shell=bash + +files=() +flutter_args=() +for arg in "$@"; do + if [[ "$arg" == *.dart ]]; then files+=("$arg"); else flutter_args+=("$arg"); fi +done +if [[ ${#files[@]} -eq 0 ]]; then files=(integration_test/*_test.dart); fi + +homeserver="http://localhost:8448" + +if curl -s -o /dev/null "$homeserver"; then + echo "Something is already using port 8448, like another e2e run." >&2 + exit 1 +fi + +work="$(mktemp -d)" +# shellcheck disable=SC2329 # Run by the trap +cleanup() { + [[ -n "${server_pid:-}" ]] && kill "$server_pid" 2>/dev/null && wait "$server_pid" 2>/dev/null + rm -rf "$work" +} +trap cleanup EXIT + +mkdir -p "$work/db" +cat > "$work/continuwuity.toml" < "$work/continuwuity.log" 2>&1 & +server_pid=$! + +all_can_log_in() { + for user in "${users[@]}"; do + curl -sf -XPOST "$homeserver/_matrix/client/v3/login" \ + -d "{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"$user\"},\"password\":\"${user}pass\"}" \ + > /dev/null || return 1 + done +} + +# Wait until login succeeds, otherwise you might try to log in before the account is created +attempts=60 +until all_can_log_in; do + kill -0 "$server_pid" 2>/dev/null || { cat "$work/continuwuity.log"; exit 1; } + (( --attempts )) || { echo "Test users weren't created" >&2; exit 1; } + sleep 0.5 +done + +GOCACHE="$(go env GOCACHE)" +export GOCACHE + +status=0 +for file in "${files[@]}"; do + name="$(basename "$file" .dart)" + mkdir -p "$work/$name/data" "$work/$name/cache" + XDG_DATA_HOME="$work/$name/data" XDG_CACHE_HOME="$work/$name/cache" \ + dbus-run-session -- \ + flutter test "$file" -d linux \ + --dart-define="NEXUS_E2E_HOMESERVER=$homeserver" "${flutter_args[@]}" || + status=$? +done +exit "$status" diff --git a/linux/nix/module.nix b/linux/nix/module.nix new file mode 100644 index 0000000..a8abd6c --- /dev/null +++ b/linux/nix/module.nix @@ -0,0 +1,45 @@ +self: +{ + pkgs, + lib, + config, + ... +}: + +let + inherit (lib) + mkIf + mkMerge + mkEnableOption + mkPackageOption + ; + + cfg = config.programs.nexus; +in +{ + options.programs.nexus = { + enable = mkEnableOption "Nexus, a simple and user-friendly Matrix client"; + package = mkPackageOption self.packages.${pkgs.stdenv.hostPlatform.system} "default" { }; + + enableNotifications = mkEnableOption "notifications support via UnifiedPush"; + }; + + config = mkIf cfg.enable (mkMerge [ + { + environment.systemPackages = [ cfg.package ]; + } + (mkIf cfg.enableNotifications { + environment.systemPackages = [ + pkgs.kdePackages.kunifiedpush + ]; + + systemd.packages = [ + pkgs.kdePackages.kunifiedpush + ]; + + services.dbus.packages = [ + cfg.package + ]; + }) + ]); +} diff --git a/linux/nix/pkg/avif_native.nix b/linux/nix/pkg/avif_native.nix new file mode 100644 index 0000000..59a5b96 --- /dev/null +++ b/linux/nix/pkg/avif_native.nix @@ -0,0 +1,49 @@ +# avif_native's build hook downloads its prebuilt library, which the sandbox +# can't do, so bundle it and point the manifest at the local copy instead +{ + stdenv, + fetchurl, + jq, +}: +let + artifacts = { + x86_64-linux = { + arch = "x64"; + sha256 = "fa36210bc95b70abd14c20fd626204f57d0ef947297e9ff8ee66855c01e2c17f"; + }; + aarch64-linux = { + arch = "arm64"; + sha256 = "163487e98b5eee0676be0dc8d3f60b5e0e5b67191a8a0a2abb5c8fad14a3a506"; + }; + }; + inherit (artifacts.${stdenv.hostPlatform.system}) arch sha256; + key = "linux-${arch}-decode-dav1d"; +in +{ version, src, ... }: +stdenv.mkDerivation { + pname = "avif_native"; + inherit version src; + inherit (src) passthru; + + nativeBuildInputs = [ jq ]; + + library = fetchurl { + url = "https://github.com/Zhangw1998/avif_native/releases/download/native-v0.2.0/${key}-libavif_native.so.gz"; + inherit sha256; + }; + + # The hook checksums the library, so it must stay byte-identical + dontFixup = true; + + installPhase = '' + runHook preInstall + + cp -r . $out + chmod -R u+w $out + gunzip -c $library > $out/native/libavif_native.so + jq '.artifacts["${key}"].path = "libavif_native.so"' \ + native/manifest.json > $out/native/manifest.json + + runHook postInstall + ''; +} diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix new file mode 100644 index 0000000..76e8983 --- /dev/null +++ b/linux/nix/pkg/default.nix @@ -0,0 +1,86 @@ +{ + lib, + callPackage, + mpv-unwrapped, + libass, + libxext, + libxfixes, + libclang, + flutter, + stdenv, + runCommand, + yq, + src, + commit ? "dev", +}: + +let + pubspec = lib.importJSON ( + runCommand "pubspec.json" { nativeBuildInputs = [ yq ]; } '' + yq . ${src}/pubspec.yaml > $out + '' + ); +in +flutter.buildFlutterApplication { + pname = "nexus"; + version = lib.head (lib.splitString "+" pubspec.version); + inherit src; + + preBuild = '' + cp ${callPackage ./gomuks.nix { inherit src; }}/lib/* . + packageRunCustom nexus generate source/scripts test + packageRun build_runner build + ''; + + flutterBuildFlags = [ + "--dart-define=GIT_COMMIT=${commit}" + ]; + + nativeBuildInputs = [ (callPackage ./hook_pkg_config.nix { }) ]; + + buildInputs = [ + mpv-unwrapped + libass + # nativeapi includes XInput2.h, which needs these, but xi.pc only lists them privately + libxext + libxfixes + ]; + + env.LIBCLANG_PATH = lib.makeLibraryPath [ libclang ]; + + autoPubspecLock = src + "/pubspec.lock"; + + customSourceBuilders.avif_native = callPackage ./avif_native.nix { }; + + gitHashes = { + linkify = "sha256-TpMD6+0zyY6i9l+6d8ErnVufmepCv362rCtnbOht/z4="; + fab_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; + fluttertagger = "sha256-tHUNU2+fBPkq5UK5YRi3SWs1zF2PIPz6RgSbwignU/I="; + m3e_design = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; + native_toolchain_cmake = "sha256-/drnHZebmTnsCnHgC1xmlAe+U4PSrjw5Sm9axicuTrY="; + navigation_rail_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; + unifiedpush_linux = "sha256-KQKyBPL2ORK9O89C4K3RHGve/lvFaU1vIjSDnj6+bj4="; + unifiedpush_platform_interface = "sha256-KQKyBPL2ORK9O89C4K3RHGve/lvFaU1vIjSDnj6+bj4="; + webcrypto = "sha256-YHyCMVmroxj047r6KLrk5gMbAGwZ5fZvCSOaX5p49uI="; + xdg_desktop_portal = "sha256-8GgiXHGv+wy6hJgj/rZxbymMitNVmdDLAnedZm9STSc="; + }; + + postInstall = '' + install -D assets/bundled/icon.svg $out/share/icons/hicolor/scalable/apps/nexus.svg + install -Dm755 linux/*.desktop -t $out/share/applications + install -Dm644 linux/*.service -t $out/share/dbus-1/services + 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.unix; + + # https://github.com/NixOS/nixpkgs/blob/c59305bab2065cfecc4944690d9eedbb56f3a9fa/pkgs/development/compilers/flutter/build-support/build-flutter-application.nix#L15 + broken = stdenv.hostPlatform.isDarwin; + + maintainers = with lib.maintainers; [ quadradical ]; + }; +} diff --git a/linux/nix/pkg/gomuks.nix b/linux/nix/pkg/gomuks.nix new file mode 100644 index 0000000..da3bf3d --- /dev/null +++ b/linux/nix/pkg/gomuks.nix @@ -0,0 +1,38 @@ +{ + src, + stdenv, + buildGoModule, +}: + +buildGoModule ( + finalAttrs: + let + filename = "libgomuks${stdenv.hostPlatform.extensions.sharedLibrary}"; + in + { + pname = "gomuks-ffi"; + version = "submodule"; + + doCheck = false; + + src = "${src}/gomuks"; + + vendorHash = "sha256-feW1rw74GsRx0fkqGLZk6PeR6bUkIt9JnjvirOAThcI="; + + buildPhase = '' + runHook preBuild + + go build -buildmode=c-shared -o ${filename} -tags goolm,noheic,sqlite_fts5 ./pkg/ffi + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + install -Dm0644 ${filename} -t $out/lib + + runHook postInstall + ''; + } +) diff --git a/linux/nix/pkg/hook_pkg_config.nix b/linux/nix/pkg/hook_pkg_config.nix new file mode 100644 index 0000000..b5219e5 --- /dev/null +++ b/linux/nix/pkg/hook_pkg_config.nix @@ -0,0 +1,32 @@ +{ + stdenvNoCC, + pkg-config, + pkg-config-unwrapped, + makeWrapper, + gtk3, + libx11, + libxi, +}: + +# Build hooks run with a filtered environment that drops PKG_CONFIG_PATH, so +# nativeapi's hook can only find its libraries through a pkg-config on PATH. +stdenvNoCC.mkDerivation { + name = "hook-pkg-config"; + dontUnpack = true; + + nativeBuildInputs = [ + pkg-config + makeWrapper + ]; + + buildInputs = [ + gtk3 + libx11 + libxi + ]; + + installPhase = '' + makeWrapper ${pkg-config-unwrapped}/bin/pkg-config $out/bin/pkg-config \ + --prefix PKG_CONFIG_PATH : "$PKG_CONFIG_PATH" + ''; +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 58cd859..f5795d0 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,6 +1,11 @@ #include "my_application.h" +#include + #include +#include +#include + #ifdef GDK_WINDOWING_X11 #include #endif @@ -9,136 +14,379 @@ struct _MyApplication { GtkApplication parent_instance; + char** dart_entrypoint_arguments; + + FlEngine* flutter_engine; + FlMethodChannel* notification_channel; + + gchar* pending_notification_event_id; + gboolean flutter_ready; }; G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) -// Called when first Flutter frame received. -static void first_frame_cb(MyApplication* self, FlView *view) -{ - gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +static void my_application_activate(GApplication* application); + +static void send_notification_event_to_flutter( + MyApplication* self, + const gchar* event_id) { + if (self->notification_channel == nullptr || + !self->flutter_ready) { + g_free(self->pending_notification_event_id); + self->pending_notification_event_id = g_strdup(event_id); + return; + } + + g_autoptr(FlValue) args = + fl_value_new_string(event_id); + + fl_method_channel_invoke_method( + self->notification_channel, + "notificationClicked", + args, + nullptr, + nullptr, + nullptr); } -// Implements GApplication::activate. -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); +static void notification_event_action( + GSimpleAction* action, + GVariant* parameter, + gpointer user_data) { + MyApplication* self = + MY_APPLICATION(user_data); + + if (parameter == nullptr) { + g_warning( + "Notification event action invoked without a target"); + return; + } + + if (!g_variant_is_of_type( + parameter, + G_VARIANT_TYPE_STRING)) { + g_warning( + "Notification event action received unexpected parameter type: %s", + g_variant_get_type_string(parameter)); + return; + } + + const gchar* event_id = + g_variant_get_string(parameter, nullptr); + + g_message( + "Notification event activated: %s", + event_id); + + send_notification_event_to_flutter( + self, + event_id); + + GList* windows = + gtk_application_get_windows( + GTK_APPLICATION(self)); + + if (windows != nullptr) { + gtk_window_present( + GTK_WINDOW(windows->data)); + } else { + my_application_activate( + G_APPLICATION(self)); + } +} + +static void first_frame_cb( + MyApplication* self, + FlView* view) { + self->flutter_ready = TRUE; + + gtk_widget_show( + gtk_widget_get_toplevel( + GTK_WIDGET(view))); + + if (self->pending_notification_event_id != nullptr) { + gchar* event_id = + self->pending_notification_event_id; + + self->pending_notification_event_id = nullptr; + + send_notification_event_to_flutter( + self, + event_id); + + g_free(event_id); + } +} + +static void my_application_activate( + GApplication* application) { + MyApplication* self = + MY_APPLICATION(application); + + GList* windows = + gtk_application_get_windows( + GTK_APPLICATION(application)); + + if (windows != nullptr) { + gtk_window_present( + GTK_WINDOW(windows->data)); + return; + } + GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + GTK_WINDOW( + gtk_application_window_new( + GTK_APPLICATION(application))); - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). gboolean use_header_bar = TRUE; + #ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); + GdkScreen* screen = + gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + const gchar* wm_name = + gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { use_header_bar = FALSE; } } #endif + if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "nexus"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + GtkHeaderBar* header_bar = + GTK_HEADER_BAR( + gtk_header_bar_new()); + + gtk_widget_show( + GTK_WIDGET(header_bar)); + + gtk_header_bar_set_title( + header_bar, + "nexus"); + + gtk_header_bar_set_show_close_button( + header_bar, + TRUE); + + gtk_window_set_titlebar( + window, + GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "nexus"); + gtk_window_set_title( + window, + "nexus"); } - gtk_window_set_default_size(window, 1280, 720); + gtk_window_set_default_size( + window, + 1280, + 720); - g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + g_autoptr(FlDartProject) project = + fl_dart_project_new(); + + fl_dart_project_set_dart_entrypoint_arguments( + project, + self->dart_entrypoint_arguments); + + FlView* view = + fl_view_new(project); + + self->flutter_engine = + fl_view_get_engine(view); + + g_autoptr(FlStandardMethodCodec) codec = + fl_standard_method_codec_new(); + + self->notification_channel = + fl_method_channel_new( + fl_engine_get_binary_messenger( + self->flutter_engine), + "nexus/notifications", + FL_METHOD_CODEC(codec)); - FlView* view = fl_view_new(project); GdkRGBA background_color; - // Background defaults to black, override it here if necessary, e.g. #00000000 for transparent. - gdk_rgba_parse(&background_color, "#000000"); - fl_view_set_background_color(view, &background_color); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); - // Show the window when Flutter renders. - // Requires the view to be realized so we can start rendering. - g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); - gtk_widget_realize(GTK_WIDGET(view)); + gdk_rgba_parse( + &background_color, + "#000000"); - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + fl_view_set_background_color( + view, + &background_color); - gtk_widget_grab_focus(GTK_WIDGET(view)); + gtk_widget_show( + GTK_WIDGET(view)); + + if (std::getenv("FLUTTER_HEADLESS")) { + gtk_widget_hide( + GTK_WIDGET(window)); + } + + gtk_container_add( + GTK_CONTAINER(window), + GTK_WIDGET(view)); + + g_signal_connect_swapped( + view, + "first-frame", + G_CALLBACK(first_frame_cb), + self); + + gtk_widget_realize( + GTK_WIDGET(view)); + + fl_register_plugins( + FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus( + GTK_WIDGET(view)); } -// Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { - MyApplication* self = MY_APPLICATION(application); - // Strip out the first argument as it is the binary name. - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); +static gboolean my_application_local_command_line( + GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = + MY_APPLICATION(application); + + self->dart_entrypoint_arguments = + g_strdupv(*arguments + 1); g_autoptr(GError) error = nullptr; - if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; + + if (!g_application_register( + application, + nullptr, + &error)) { + g_warning( + "Failed to register: %s", + error->message); + + *exit_status = 1; + return TRUE; } g_application_activate(application); + *exit_status = 0; - return TRUE; + return FALSE; } -// Implements GApplication::startup. -static void my_application_startup(GApplication* application) { - //MyApplication* self = MY_APPLICATION(object); +static void my_application_startup( + GApplication* application) { + g_message( + "GApplication application-id: %s", + g_application_get_application_id( + application)); - // Perform any actions required at application startup. + g_message( + "GApplication is remote: %d", + g_application_get_is_remote( + application)); - G_APPLICATION_CLASS(my_application_parent_class)->startup(application); + g_message( + "GApplication is registered: %d", + g_application_get_is_registered( + application)); + + g_message( + "prgname: %s", + g_get_prgname()); + + G_APPLICATION_CLASS( + my_application_parent_class) + ->startup(application); } -// Implements GApplication::shutdown. -static void my_application_shutdown(GApplication* application) { - //MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application shutdown. - - G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +static void my_application_shutdown( + GApplication* application) { + G_APPLICATION_CLASS( + my_application_parent_class) + ->shutdown(application); } -// Implements GObject::dispose. -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); - g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); - G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +static void my_application_dispose( + GObject* object) { + MyApplication* self = + MY_APPLICATION(object); + + g_clear_pointer( + &self->dart_entrypoint_arguments, + g_strfreev); + + g_clear_pointer( + &self->pending_notification_event_id, + g_free); + + g_clear_object( + &self->notification_channel); + + self->flutter_engine = nullptr; + + G_OBJECT_CLASS( + my_application_parent_class) + ->dispose(object); } -static void my_application_class_init(MyApplicationClass* klass) { - G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; - G_APPLICATION_CLASS(klass)->startup = my_application_startup; - G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; - G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +static void my_application_class_init( + MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = + my_application_activate; + + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + + G_APPLICATION_CLASS(klass)->startup = + my_application_startup; + + G_APPLICATION_CLASS(klass)->shutdown = + my_application_shutdown; + + G_OBJECT_CLASS(klass)->dispose = + my_application_dispose; } -static void my_application_init(MyApplication* self) {} +static void my_application_init( + MyApplication* self) { + self->dart_entrypoint_arguments = nullptr; + self->flutter_engine = nullptr; + self->notification_channel = nullptr; + self->pending_notification_event_id = nullptr; + self->flutter_ready = FALSE; + + GSimpleAction* action = + g_simple_action_new( + "event", + G_VARIANT_TYPE_STRING); + + g_signal_connect( + action, + "activate", + G_CALLBACK(notification_event_action), + self); + + g_action_map_add_action( + G_ACTION_MAP(self), + G_ACTION(action)); + + g_object_unref(action); +} MyApplication* my_application_new() { - // Set the program name to the application ID, which helps various systems - // like GTK and desktop environments map this running application to its - // corresponding .desktop file. This ensures better integration by allowing - // the application to be recognized beyond its binary name. g_set_prgname(APPLICATION_ID); - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, - "flags", G_APPLICATION_NON_UNIQUE, - nullptr)); -} + return MY_APPLICATION( + g_object_new( + my_application_get_type(), + "application-id", + APPLICATION_ID, + "flags", + G_APPLICATION_HANDLES_COMMAND_LINE | + G_APPLICATION_HANDLES_OPEN, + nullptr)); +} \ No newline at end of file diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..88181b0 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,34 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import app_links +import dynamic_color +import file_selector_macos +import flutter_local_notifications +import geolocator_apple +import media_kit_libs_macos_video +import media_kit_video +import package_info_plus +import pasteboard +import shared_preferences_foundation +import url_launcher_macos +import wakelock_plus + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) + MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..65543e2 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..08cb773 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,28 @@ +PODS: + - FlutterMacOS (1.0.0) + - media_kit_libs_macos_video (1.0.4): + - FlutterMacOS + - media_kit_video (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) + - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + media_kit_libs_macos_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos + media_kit_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos + +SPEC CHECKSUMS: + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 + media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.17.0 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..fa18d44 --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,825 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 34AEA7295333B7F48CD3BE4C /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D76F30AE9CCFCA04C354BD4C /* Pods_RunnerTests.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + BAF79690465DBAC1A1E19D28 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70FD05041ACDA7E801BB9823 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 18EDDEF1FB58EC56ABDA1BEB /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* nexus.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nexus.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 5099E6191AE87E21D356D9C8 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 6D9F8F8BBED102075ECF1DF9 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 70FD05041ACDA7E801BB9823 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 8F5ECEC7BF9DD35CB3E7196E /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9DDE815F827B87E2C55DF94C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + CBD1D9203BEE9731F981EEF8 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + D76F30AE9CCFCA04C354BD4C /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 34AEA7295333B7F48CD3BE4C /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + BAF79690465DBAC1A1E19D28 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + FC780AF47288088B9004ACE2 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* nexus.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 70FD05041ACDA7E801BB9823 /* Pods_Runner.framework */, + D76F30AE9CCFCA04C354BD4C /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + FC780AF47288088B9004ACE2 /* Pods */ = { + isa = PBXGroup; + children = ( + 6D9F8F8BBED102075ECF1DF9 /* Pods-Runner.debug.xcconfig */, + 9DDE815F827B87E2C55DF94C /* Pods-Runner.release.xcconfig */, + CBD1D9203BEE9731F981EEF8 /* Pods-Runner.profile.xcconfig */, + 18EDDEF1FB58EC56ABDA1BEB /* Pods-RunnerTests.debug.xcconfig */, + 8F5ECEC7BF9DD35CB3E7196E /* Pods-RunnerTests.release.xcconfig */, + 5099E6191AE87E21D356D9C8 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + C413A8A75594D2165C9D56BD /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 446C82D5FE82BD05F78EDA6A /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + C4D158541D7059C07D3CA9B3 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* nexus.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 446C82D5FE82BD05F78EDA6A /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + C413A8A75594D2165C9D56BD /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + C4D158541D7059C07D3CA9B3 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 18EDDEF1FB58EC56ABDA1BEB /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/nexus.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/nexus"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8F5ECEC7BF9DD35CB3E7196E /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/nexus.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/nexus"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5099E6191AE87E21D356D9C8 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/nexus.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/nexus"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..746b383 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..96d3fee --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "info": { + "version": 1, + "author": "xcode" + }, + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ] +} \ No newline at end of file diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..1682af0 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..c2e488b Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..e2bf256 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..983d7fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..c8f0676 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..468be1f Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..a77207d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..b4c5133 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = nexus + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 nexus.federated. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..575757f --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,18 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.files.user-selected.read-only + + com.apple.security.personal-information.location + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..d4f66d1 --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,50 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + CFBundleURLTypes + + + CFBundleURLName + + auth_callback + CFBundleURLSchemes + + + nexus.federated.nexus + matrix + + + + NSLocationUsageDescription + Nexus uses your location only when you choose to share it in a message. + NSLocationWhenInUseUsageDescription + Nexus uses your location only when you choose to share it in a message. + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..14e2faf --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + + com.apple.security.personal-information.location + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/nix/android.nix b/nix/android.nix deleted file mode 100644 index f373968..0000000 --- a/nix/android.nix +++ /dev/null @@ -1,20 +0,0 @@ -{ - 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 da5de89..0d6edce 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,50 +5,82 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + sha256: "1c5591cc6f252d5f440d96ab87c03e43504be552a3fa49d7cb7563f04c51fb0a" url: "https://pub.dev" source: hosted - version: "91.0.0" + version: "111.0.0" analysis_server_plugin: dependency: transitive description: name: analysis_server_plugin - sha256: "44adba4d74a2541173bad4c11531d2a4d22810c29c5ddb458a38e9f4d0e5eac7" + sha256: "1ff5b5f6bc3d4091ffbc4da2ca9a5de36b49d590332d94e502d0746e80b3d64e" url: "https://pub.dev" source: hosted - version: "0.3.4" + version: "0.3.26" analyzer: - dependency: "direct overridden" + dependency: transitive description: name: analyzer - sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + sha256: "6c39bd5486a310e56217bed7cb1691377959f31be4227e41750107db7e6dd55c" url: "https://pub.dev" source: hosted - version: "8.4.1" + version: "14.7.0" analyzer_buffer: dependency: transitive description: name: analyzer_buffer - sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033 + sha256: f890a2e708e38fbf4238ad2f9fd3d506538acdddb0cd8eb2c3e8c7bd17784b57 url: "https://pub.dev" source: hosted - version: "0.1.11" + version: "0.3.4" analyzer_plugin: dependency: transitive description: name: analyzer_plugin - sha256: "6645a029da947ffd823d98118f385d4bd26b54eb069c006b22e0b94e451814b5" + sha256: "8929dfe59ae5ea8e012f1238e368875642370beabffd15d4048545544ae44aa8" url: "https://pub.dev" source: hosted - version: "0.13.11" + version: "0.14.20" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "08ba88bf19d9edea33130cb71c8ced2cb982b4e00b82efef4a434d1357bc69c3" + url: "https://pub.dev" + source: hosted + version: "7.2.2" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: "9ccb5802085a1e57bb07b6256fd1d61a78c7ec104944d4965e62938b4fefd9cb" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + app_links_platform_interface: + dependency: "direct dev" + description: + name: app_links_platform_interface + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: "382d32c6f486f15b1d7f5d6a37a70a481a4fc08382eef419d373169921a3e765" + url: "https://pub.dev" + source: hosted + version: "1.0.5" archive: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: de5ac590b8155e4e676088d714bd41ea620449a6adc0f7950c5073874b287ddb url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.6.0" args: dependency: transitive description: @@ -57,30 +89,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" - asn1lib: - dependency: transitive - description: - name: asn1lib - sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" - url: "https://pub.dev" - source: hosted - version: "1.6.5" async: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" - blurhash_dart: - dependency: transitive + version: "2.13.1" + avif_native: + dependency: "direct main" description: - name: blurhash_dart - sha256: "43955b6c2e30a7d440028d1af0fa185852f3534b795cc6eb81fbf397b464409f" + name: avif_native + sha256: fed1aad8f8e855abf40a16484dcbfacea89b65154832d6af8ad46bf9353faf7a url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "0.2.0" boolean_selector: dependency: transitive description: @@ -93,66 +117,74 @@ packages: dependency: transitive description: name: build - sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413 + sha256: "50df3d1558953a0d936401c1687cc41dbc039f9132f538c7bcb2aac2a580ae7f" url: "https://pub.dev" source: hosted - version: "4.0.3" + version: "5.0.0" build_config: dependency: transitive description: name: build_config - sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + sha256: "4703a785fbc3c266c5c7518589d0863f6a05078ee33f7ca5aa4b87a56f1cedd2" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.0" build_daemon: dependency: transitive description: name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + sha256: "39ab65f7c60c19af64b9ef96e6a5d3ab48355f7dddfd98a9079d55a8cb6628fc" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.7" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057" + sha256: "96b9da39a3a8d4365aa1ca6f7655437d80f514ba13d4c21d8408cc8aacb94681" url: "https://pub.dev" source: hosted - version: "2.10.4" + version: "2.17.1" built_collection: dependency: transitive description: name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + sha256: "44922ecebd8e3d0cef743dadd3b54df4e87e2a4c52a29465a1872aa9e192caa8" url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "5.1.2" built_value: dependency: transitive description: name: built_value - sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139" + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 url: "https://pub.dev" source: hosted - version: "8.12.1" + version: "8.13.0" + button_m3e: + dependency: transitive + description: + name: button_m3e + sha256: "6754ddeb9068ad2005bd26d5ceabc41268029465095686d7d228296c2e706909" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + change_case: + dependency: transitive + description: + name: change_case + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 + url: "https://pub.dev" + source: hosted + version: "2.2.0" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a - url: "https://pub.dev" - source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -161,22 +193,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" - ci: - dependency: transitive - description: - name: ci - sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" - url: "https://pub.dev" - source: hosted - version: "0.1.0" - cli_config: - dependency: transitive - description: - name: cli_config - sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec - url: "https://pub.dev" - source: hosted - version: "0.2.0" cli_util: dependency: transitive description: @@ -189,26 +205,26 @@ packages: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" + cnativeapi: + dependency: transitive + description: + name: cnativeapi + sha256: "3c9e30d513cdc8ecb116f3017308f2480828c766e45aa00083c3d15d621e5d57" + url: "https://pub.dev" + source: hosted + version: "0.5.8" code_assets: dependency: "direct main" description: name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57" 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" + version: "2.1.0" collection: dependency: "direct main" description: @@ -233,38 +249,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - coverage: - dependency: transitive - description: - name: coverage - sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" - url: "https://pub.dev" - source: hosted - version: "1.15.0" - cross_cache: - dependency: "direct main" - description: - name: cross_cache - sha256: "4983a16603cc99b0a14de6a772fa8ee4533411f46f3c423f1386fea7566049c5" - url: "https://pub.dev" - source: hosted - version: "1.1.0" cross_file: dependency: transitive description: name: cross_file - sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 url: "https://pub.dev" source: hosted - version: "0.3.5+1" + version: "0.3.5+5" crypto: dependency: transitive description: name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4 url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "3.0.8" csslib: dependency: transitive description: @@ -273,94 +273,71 @@ 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: + cupertino_ui: dependency: transitive description: - name: custom_lint_core - sha256: "85b339346154d5646952d44d682965dfe9e12cae5febd706f0db3aa5010d6423" + name: cupertino_ui + sha256: e9dfe7fac704028f8928cbe4028a0be5e8a709498e8daf8247de99e99a32aef3 url: "https://pub.dev" source: hosted - version: "0.8.1" - custom_lint_visitor: + version: "1.0.2" + dart_earcut: dependency: transitive description: - name: custom_lint_visitor - sha256: e466d17856197cf9bce7ca03804d784fddab809db7bda787f3d2799ac89faadd + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b url: "https://pub.dev" source: hosted - version: "1.0.0+9.0.0" + version: "1.2.0" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" dart_style: dependency: transitive description: name: dart_style - sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + sha256: a0e4f65339b31eb6a9e6a12e14b4682a48d46fbc54a82f4a702396fbdbac63a7 url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.14" dbus: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 url: "https://pub.dev" source: hosted - version: "0.7.11" - diffutil_dart: - dependency: transitive - description: - name: diffutil_dart - sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81" - url: "https://pub.dev" - source: hosted - version: "4.0.1" - dio: - dependency: transitive - description: - name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 - url: "https://pub.dev" - source: hosted - version: "5.9.0" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - dynamic_polls: + version: "0.7.15" + dynamic_color: dependency: "direct main" description: - name: dynamic_polls - sha256: fba71ee6fb0ae8f3bebf7d07b3f2a79347d496956de88fb24d3daa32d47e0774 + name: dynamic_color + sha256: "869b3bce0100eb519768ecd71c0111f0b5ea679f5f62e1f59c49d80067e730c5" url: "https://pub.dev" source: hosted - version: "0.0.6" - dynamic_system_colors: - dependency: "direct main" - description: - name: dynamic_system_colors - sha256: "43794e658fa88cbdec9f397dd1afd2eb69b6c9717e99b93b16ba37c3aa3b3a8c" - url: "https://pub.dev" - source: hosted - version: "1.8.0" - encrypt: + version: "2.1.0" + equatable: dependency: transitive description: - name: encrypt - sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "2.1.0" + fab_m3e: + dependency: "direct overridden" + description: + path: "packages/fab_m3e" + ref: HEAD + resolved-ref: "35ff1cea8e1a23523428710b27fc5d190ee81e96" + url: "https://github.com/Henry-Hiles/material_3_expressive" + source: git + version: "0.1.1" fake_async: dependency: transitive description: @@ -373,26 +350,34 @@ packages: dependency: "direct main" description: name: fast_immutable_collections - sha256: "19f70498af299cbce5ff919dbbecd5abfd9d0c28139004f68d3810ce23dedfb3" + sha256: "3f791151fe63e27beffdba12835db33c60031e324351f683879ebe18d1b10588" url: "https://pub.dev" source: hosted - version: "11.1.0" + version: "11.2.1" ffi: dependency: "direct main" description: name: ffi - sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" ffigen: dependency: "direct main" description: name: ffigen - sha256: b7803707faeec4ce3c1b0c2274906504b796e3b70ad573577e72333bd1c9b3ba + sha256: "6f55d99ac1857fc063b58c3b31753df2bba417028ba98d30c6d625a385855064" url: "https://pub.dev" source: hosted - version: "20.1.1" + version: "23.0.0" file: dependency: transitive description: @@ -401,30 +386,46 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - file_picker: + file_selector: dependency: "direct main" description: - name: file_picker - sha256: d974b6ba2606371ac71dd94254beefb6fa81185bde0b59bdc1df09885da85fde + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a url: "https://pub.dev" source: hosted - version: "10.3.8" + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c + url: "https://pub.dev" + source: hosted + version: "0.5.2+11" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: "5b42ec0ef7626124b4624ea244341e9df780406793c204e89fa68a2e71af115c" + url: "https://pub.dev" + source: hosted + version: "0.5.3+7" file_selector_linux: dependency: transitive description: name: file_selector_linux - sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab url: "https://pub.dev" source: hosted - version: "0.9.4" + version: "0.9.4+1" file_selector_macos: dependency: transitive description: name: file_selector_macos - sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + sha256: d785431f1910aac8a50e5c492cae912320158030a88703c67b9ef7f1f96fd7ba url: "https://pub.dev" source: hosted - version: "0.9.5" + version: "0.9.5+2" file_selector_platform_interface: dependency: transitive description: @@ -433,14 +434,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" file_selector_windows: dependency: transitive description: name: file_selector_windows - sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec url: "https://pub.dev" source: hosted - version: "0.9.3+5" + version: "0.9.3+6" fixnum: dependency: transitive description: @@ -454,23 +463,27 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_chat_core: + flutter_blurhash: dependency: "direct main" description: - name: flutter_chat_core - sha256: "8c46790f64f106bf6e610e2a7324b3844320e9e295867c06d45d9deb134d848d" + name: flutter_blurhash + sha256: e97b9aff13b9930bbaa74d0d899fec76e3f320aba3190322dcc5d32104e3d25d url: "https://pub.dev" source: hosted - version: "2.9.0" - flutter_chat_ui: + version: "0.9.1" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_file_dialog: 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" + name: flutter_file_dialog + sha256: "5d270d69b6a5fdf45bdb88ebcf590e073dd89dfb45526ac83a64524ba57303b5" + url: "https://pub.dev" + source: hosted + version: "3.3.3" flutter_hooks: dependency: "direct main" description: @@ -487,15 +500,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.14.4" - flutter_link_previewer: - dependency: "direct main" - description: - path: "packages/flutter_link_previewer" - ref: HEAD - resolved-ref: "03be67c8c81c8f637672ee03dd8f082d2c223627" - url: "https://github.com/Henry-Hiles/flutter_chat_ui" - source: git - version: "4.2.0" flutter_lints: dependency: "direct dev" description: @@ -504,37 +508,85 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: fd5fc8966a8b34337ac182dc394f3506139b69d8138d4e9e174a13d6468adcd4 + url: "https://pub.dev" + source: hosted + version: "22.3.3" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "3b89a4bf095fa5f8a99beeabf982c5bd506c3ad25e7b9e61da945e994dc5b615" + url: "https://pub.dev" + source: hosted + version: "8.0.2" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd" + url: "https://pub.dev" + source: hosted + version: "12.2.0" + flutter_local_notifications_web: + dependency: transitive + description: + name: flutter_local_notifications_web + sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945" + url: "https://pub.dev" + source: hosted + version: "3.1.1" flutter_localizations: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "9bbe5472f66ae648d2ca2efbce97fcaa04e71261fabd3e062f26728222d7f767" + url: "https://pub.dev" + source: hosted + version: "8.3.2" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" url: "https://pub.dev" source: hosted - version: "2.0.33" + version: "2.0.35" flutter_riverpod: dependency: "direct main" description: name: flutter_riverpod - sha256: "38ec6c303e2c83ee84512f5fc2a82ae311531021938e63d7137eccc107bf3c02" + sha256: "2b7f9d2a3c730ac1a98221b420cef966b990fca7ab546ede324642ca57a533e3" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.4.3" flutter_svg: dependency: "direct main" description: name: flutter_svg - sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + sha256: "100c19dcc4ff60083e95a44a6f44f3a1bb887d353c5d86f6a9b688aa3cb46236" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.3.2" flutter_test: - dependency: transitive + dependency: "direct dev" description: flutter source: sdk version: "0.0.0" @@ -547,50 +599,27 @@ packages: dependency: "direct main" description: name: flutter_widget_from_html_core - sha256: "1120ee6ed3509ceff2d55aa6c6cbc7b6b1291434422de2411b5a59364dd6ff03" + sha256: "4eb6b5ece5fa5a2c6b35f621433aa36d5f115cc45e8638c7fac0d709561ea9f6" url: "https://pub.dev" source: hosted - version: "0.17.0" + version: "0.18.0-beta.1" fluttertagger: dependency: "direct main" description: - name: fluttertagger - sha256: "3df0132bdd431a7279da78ea70500ea1e767fa093f43f32785b757c10c6a0fcc" - url: "https://pub.dev" - source: hosted - version: "2.3.1" - flyer_chat_file_message: - dependency: "direct main" - description: - name: flyer_chat_file_message - sha256: "96c5c25908cd671dda1963ade03e188e6a14bba6b116e73fac329f1abefc9ad1" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - flyer_chat_image_message: - dependency: "direct main" - description: - name: flyer_chat_image_message - sha256: "04730c9373c9c7315ba0e1a360c67ac5f6c7ec8a700ffe2d2dc00e29b7f8ff90" - url: "https://pub.dev" - source: hosted - version: "2.3.0" - flyer_chat_system_message: - dependency: "direct main" - description: - name: flyer_chat_system_message - sha256: d254f85be55949f8eb1a4a9a9b1c5b54ffed0c9a39dfa7e4fa6a6358bdb5d45a - url: "https://pub.dev" - source: hosted - version: "2.2.0" + path: "." + ref: HEAD + resolved-ref: "78a5099499728917e7595f738f3b369b47804dc5" + url: "https://github.com/Henry-Hiles/fluttertagger" + source: git + version: "2.3.2" freezed: dependency: "direct dev" description: name: freezed - sha256: "03dd9b7423ff0e31b7e01b2204593e5e1ac5ee553b6ea9d8184dff4a26b9fb07" + sha256: cb910646cb09265d49077b63b600ef1f5a718c1d6031836432237ddeabae1b12 url: "https://pub.dev" source: hosted - version: "3.2.4" + version: "4.0.3" freezed_annotation: dependency: "direct main" description: @@ -599,30 +628,83 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" - frontend_server_client: + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + geoclue: dependency: transitive description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f url: "https://pub.dev" source: hosted - version: "4.0.0" - get_x_storage: + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "2b3bc0f7320d5a5fa02f8209ffcf1aaa7c2cabb7b2bedbc0cfeb5ff2778b8b09" + url: "https://pub.dev" + source: hosted + version: "14.1.2" + geolocator_android: dependency: transitive description: - name: get_x_storage - sha256: c9c65de2baa228783f46a55137538dc599a3c9b1834130cfd3b417ec3b643813 + name: geolocator_android + sha256: e67583809a1fab5f4a2255902978e2bc095ace9451fde0d9624e54facd0a5c22 url: "https://pub.dev" source: hosted - version: "0.0.8" + version: "5.2.1" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: a1e177f3081089724f2095856aeb96eb8d7d1c59e425aed8dd4de7aa2c11ab66 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: "3da7420f11c3496511a5bd3c18fd67b88e5659f12e46b7ce00a788f6996e850a" + url: "https://pub.dev" + source: hosted + version: "0.2.6" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: e012af6b7e2674e7500237ab11e44ac9119e87fd0105e7bb55cde1750e8e5d80 + url: "https://pub.dev" + source: hosted + version: "4.4.1" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" glob: dependency: transitive description: name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" graphs: dependency: transitive description: @@ -631,32 +713,40 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" hooks: dependency: "direct main" description: name: hooks - sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "2.2.0" hooks_riverpod: dependency: "direct main" description: name: hooks_riverpod - sha256: b880efcd17757af0aa242e5dceac2fb781a014c22a32435a5daa8f17e9d5d8a9 + sha256: d58e1e14aa112ca9c56d54effb7d0d717b2d3f96dad3cdd1fce16f8740450700 url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.4.3" html: - dependency: transitive + dependency: "direct main" description: name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" url: "https://pub.dev" source: hosted - version: "0.15.6" + version: "0.15.7" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -667,10 +757,10 @@ packages: dependency: transitive description: name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + sha256: "361668bdd71f0ae2dc6d17b75a593520f3c414af6534f9eed1aa72049dd71d18" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" http_parser: dependency: transitive description: @@ -679,38 +769,38 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - idb_shim: + icon_button_m3e: dependency: transitive description: - name: idb_shim - sha256: b26b2ad126be411d0072d1dfc4d97ebe02121a863e4eadc635b511b9bc138489 + name: icon_button_m3e + sha256: c4524d6141a468679821bbb635b833ac6831925d8a6ae4a4511430b0e4ab9c67 url: "https://pub.dev" source: hosted - version: "2.7.1+2" + version: "0.2.1" image: dependency: transitive description: name: image - sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a" url: "https://pub.dev" source: hosted - version: "4.7.2" + version: "4.12.0" image_picker: dependency: "direct main" description: name: image_picker - sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + sha256: e71b1bd9e40124041128d60fc3034c7b1598e1bba4e570bba0c70301c817013a url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.4" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" + sha256: "8a7823d5d3e884f7780829d85d92110d072bdcd9ed48e2be158d2abe91a912d4" url: "https://pub.dev" source: hosted - version: "0.8.13+10" + version: "0.8.13+25" image_picker_for_web: dependency: transitive description: @@ -723,10 +813,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" + sha256: b8f508306db2bf71141aba2de4ec13456338bf01e13f034bd2594f341a5f8eb2 url: "https://pub.dev" source: hosted - version: "0.8.13+3" + version: "0.8.13+14" image_picker_linux: dependency: transitive description: @@ -747,10 +837,10 @@ packages: dependency: transitive description: name: image_picker_platform_interface - sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + sha256: "2dee4b4e3be429db71472c9c559c981498070a61af1ba82e0e4986fc5dadc042" url: "https://pub.dev" source: hosted - version: "2.11.1" + version: "2.11.2" image_picker_windows: dependency: transitive description: @@ -759,46 +849,67 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" url: "https://pub.dev" source: hosted - version: "1.0.5" - js: + version: "1.1.0" + jni: dependency: transitive description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + name: jni + sha256: d8df6dab6847855512fe44dd9032b0bbe34dda7bf7597e41d30750879779813f url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "1.1.1" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" json_annotation: dependency: "direct main" description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.12.0" json_serializable: dependency: "direct dev" description: name: json_serializable - sha256: "6b253f7851cf1626a05c8b49c792e04a14897349798c03798137f2b5f7e0b5b1" + sha256: cc7822761b40be9237bcaae62b755ffe5453ceff96a0001f1f5f2439251c66f1 url: "https://pub.dev" source: hosted - version: "6.11.3" + version: "6.15.2" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "84b776b100a84a684a0aaa3fa4dc2c9b1931c658de533701ee4e09bfa991f8dc" + url: "https://pub.dev" + source: hosted + version: "0.10.1" leak_tracker: dependency: transitive description: @@ -823,14 +934,31 @@ 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: f2f42b06f84dbbd03fb7c3741f7fbacbe5459a4b + url: "https://github.com/appelladev/linkify" + source: git + version: "5.0.0" lints: dependency: transitive description: name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" + listen: + dependency: transitive + description: + name: listen + sha256: "47501a08016a43fcad79252439d723f50f14f88fa7bfd8a177e0a417e5c9e1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.1" logging: dependency: transitive description: @@ -839,62 +967,265 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + m3e_buttons: + dependency: "direct main" + description: + name: m3e_buttons + sha256: "25eac5e0afdb88673499c243a863fbf0bbb64c0073e9a8784c1937626a31e412" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + m3e_card_list: + dependency: "direct main" + description: + name: m3e_card_list + sha256: ed1cc150f051b8812ffd735915ae59fed23517ffeeed04c4b2b8ef6257cc18b7 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + m3e_design: + dependency: "direct main" + description: + path: "packages/m3e_design" + ref: HEAD + resolved-ref: "35ff1cea8e1a23523428710b27fc5d190ee81e96" + url: "https://github.com/Henry-Hiles/material_3_expressive" + source: git + version: "0.2.1" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.20" + material_adaptive_sheets: + dependency: "direct main" + description: + name: material_adaptive_sheets + sha256: "9299e8dfa7459583516520db1fe0edec83ab306679c3cfd3d0848f666bc956e5" + url: "https://pub.dev" + source: hosted + version: "1.0.0" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" + material_emoji_picker: + dependency: "direct main" + description: + name: material_emoji_picker + sha256: "7da8621cc827b9f0327f05a3cb22fe638945bd6a278c7bcf9112673a1c2bff90" + url: "https://pub.dev" + source: hosted + version: "2.0.1+1" + material_ui: + dependency: "direct main" + description: + name: material_ui + sha256: feb289df1e5f9ed9522f9fe3d909432a7f7bc76943f16abad09f80a16cd25196 + url: "https://pub.dev" + source: hosted + version: "1.9.0" + measure_size: + dependency: "direct main" + description: + name: measure_size + sha256: "4b2de7b29567501434902a2f4080cf12a8bc7038b2eb97dfae91b71791620b68" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted + version: "2.0.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.17.0" - mime: + version: "1.19.0" + mgrs_dart: dependency: transitive + description: + name: mgrs_dart + sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + mime: + dependency: "direct main" description: name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 url: "https://pub.dev" source: hosted - version: "2.0.0" - nested: + version: "2.1.0" + motor: dependency: transitive description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + name: motor + sha256: cbd49f21b00e568c2b1a55f134ed803614a107782f4fea7769693bca32940c58 url: "https://pub.dev" source: hosted - version: "1.0.0" - node_preamble: + version: "1.1.0" + multi_state_switch: + dependency: "direct main" + description: + name: multi_state_switch + sha256: "501890f46d33d6cba6a84eb8f1689dba180a264ce3373fa689f75f4887d822fd" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + native_toolchain_c: dependency: transitive description: - name: node_preamble - sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + name: native_toolchain_c + sha256: "90eaad8059ebc2004a63810a4696ea9acdb02473e47ed3b6b3bafb100ac77e31" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "0.19.5" + native_toolchain_cmake: + dependency: "direct overridden" + description: + path: "." + ref: HEAD + resolved-ref: d77984a36a16be22e911850de2cd2050610ff4ed + url: "https://github.com/rainyl/native_toolchain_cmake" + source: git + version: "0.3.2" + nativeapi: + dependency: transitive + description: + name: nativeapi + sha256: "87b0912bde3c9b7e0649c5e12047baf2bf179d3844a9b984313e897872120267" + url: "https://pub.dev" + source: hosted + version: "0.5.8" + nativeapi_flutter: + dependency: transitive + description: + name: nativeapi_flutter + sha256: b62a42b8a86538119ba67b8eab8328eb89d69ac4a2142d623d08ac163faa9cd8 + url: "https://pub.dev" + source: hosted + version: "0.5.8" + navigation_rail_m3e: + dependency: "direct main" + description: + path: "packages/navigation_rail_m3e" + ref: HEAD + resolved-ref: "35ff1cea8e1a23523428710b27fc5d190ee81e96" + url: "https://github.com/Henry-Hiles/material_3_expressive" + source: git + version: "0.3.5" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "5c80c2ad6e2c0397de5db38c6d653e69616a3397e13aa7633bf39eb668f2041b" + url: "https://pub.dev" + source: hosted + version: "9.6.2" package_config: dependency: transitive description: name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908" + url: "https://pub.dev" + source: hosted + version: "10.2.2" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + pasteboard: + dependency: "direct main" + description: + name: pasteboard + sha256: e0535da36124e6f2ca9d17424102ce5de34e72acc39f05a1d942b974b48e5eea + url: "https://pub.dev" + source: hosted + version: "0.6.0" path: dependency: "direct main" description: @@ -915,42 +1246,42 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" url: "https://pub.dev" source: hosted - version: "2.2.22" + version: "2.2.23" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -963,18 +1294,18 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.1.0" platform: dependency: transitive description: name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec url: "https://pub.dev" source: hosted - version: "3.1.6" + version: "3.2.0" plugin_platform_interface: dependency: transitive description: @@ -983,62 +1314,54 @@ 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: name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" url: "https://pub.dev" source: hosted - version: "1.5.2" + version: "1.5.3" posix: dependency: transitive description: name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + sha256: "613646d076944f3acc125830af935b48d4b8a57a388691547bbfd25b434f4d63" url: "https://pub.dev" source: hosted - version: "6.0.3" - provider: + version: "6.5.4" + process: dependency: transitive description: - name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" url: "https://pub.dev" source: hosted - version: "6.1.5+1" + version: "5.0.6" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859 + url: "https://pub.dev" + source: hosted + version: "3.0.0" pub_semver: dependency: transitive description: name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + sha256: "9ac81c01ffd6a9cf9b2713288834fbaaf6aebde05ffdf02d380e68b8e06f27d8" 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" + version: "1.7.0" quiver: dependency: transitive description: @@ -1047,118 +1370,70 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" riverpod: dependency: transitive description: name: riverpod - sha256: "16ff608d21e8ea64364f2b7c049c94a02ab81668f78845862b6e88b71dd4935a" + sha256: "484dfc873ea4c4f4240e5635444fa066f87b07862b7279ebd004a2b71fba4b7a" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.4.3" riverpod_analyzer_utils: dependency: transitive description: name: riverpod_analyzer_utils - sha256: "947b05d04c52a546a2ac6b19ef2a54b08520ff6bdf9f23d67957a4c8df1c3bc0" + sha256: bb13cb3985815840cc103eda1027158358c5fdc00f308fc6f76574d0594107af url: "https://pub.dev" source: hosted - version: "1.0.0-dev.8" + version: "1.0.0-dev.12" riverpod_lint: dependency: "direct dev" description: name: riverpod_lint - sha256: "4d2eb0d19bbe7e3323bd0ce4553b2e6170d161a13914bfdd85a3612329edcb43" + sha256: "215cb3cd54a97e09bc3512535fa4e8bedd3bf2a06dc2ae015ac8e0050fc71bd1" url: "https://pub.dev" source: hosted - version: "3.1.0" - rxdart: + version: "3.1.9" + safe_local_storage: dependency: transitive description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + name: safe_local_storage + sha256: "76288b689a3e1fddccb377177feed85d73fdfc64c9c3c52991e2d9aba35c4857" url: "https://pub.dev" source: hosted - version: "0.28.0" - screen_retriever: - dependency: transitive - description: - name: screen_retriever - sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_linux: - dependency: transitive - description: - name: screen_retriever_linux - sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_macos: - dependency: transitive - description: - name: screen_retriever_macos - sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_platform_interface: - dependency: transitive - description: - name: screen_retriever_platform_interface - sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 - url: "https://pub.dev" - source: hosted - version: "0.2.0" - screen_retriever_windows: - dependency: transitive - description: - name: screen_retriever_windows - sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - scrollview_observer: - dependency: transitive - description: - name: scrollview_observer - sha256: "6e40ced415145c449a691d892157a3b854b751f024aed20d9aebda04c21444a3" - url: "https://pub.dev" - source: hosted - version: "1.26.3" - sembast: - dependency: transitive - description: - name: sembast - sha256: "139cf71496105de32e7a08a4e3a1ead0f81c4a616ec9703ed07e8f0d10cdd505" - url: "https://pub.dev" - source: hosted - version: "3.8.6" + version: "2.0.7" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.6" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + sha256: "33b38b07d9ac7d16aff0426c61418752fd3e7a8c7dc8be786c875d6ce673b002" url: "https://pub.dev" source: hosted - version: "2.4.18" + version: "2.5.4" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + sha256: "4e396ac6f8751ab7d2d6e27f22f32c8350b07006028d534f5221503382f1fa69" url: "https://pub.dev" source: hosted - version: "2.5.6" + version: "2.6.1" shared_preferences_linux: dependency: transitive description: @@ -1171,10 +1446,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" shared_preferences_web: dependency: transitive description: @@ -1195,26 +1470,10 @@ packages: dependency: transitive description: name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + sha256: e75e9cf311d7fd712048038cc17c25199c38536c20330d53e7db058b543d5a48 url: "https://pub.dev" source: hosted - version: "1.4.2" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - shelf_static: - dependency: transitive - description: - name: shelf_static - sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 - url: "https://pub.dev" - source: hosted - version: "1.1.3" + version: "1.4.3" shelf_web_socket: dependency: transitive description: @@ -1223,59 +1482,51 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" source_gen: - dependency: "direct overridden" + dependency: transitive description: name: source_gen - sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75" + sha256: "833ea7d15b5be79949d3d6c077da0267a0bd93c16897b36a771395a91e63086e" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.3.1" source_helper: dependency: transitive description: name: source_helper - sha256: e82b1996c63da42aa3e6a34cc1ec17427728a1baf72ed017717a5669a7123f0d + sha256: "279126bce72de3f2b7d4756408a4157988cf6bb4815be551bf55851aa00e9984" url: "https://pub.dev" source: hosted - version: "1.3.9" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b - url: "https://pub.dev" - source: hosted - version: "2.1.2" - source_maps: - dependency: transitive - description: - name: source_maps - sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" - url: "https://pub.dev" - source: hosted - version: "0.10.13" + version: "1.4.0" source_span: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" stack_trace: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.dev" source: hosted - version: "1.12.1" + version: "1.12.2" state_notifier: dependency: transitive description: @@ -1296,10 +1547,10 @@ packages: dependency: transitive description: name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" string_scanner: dependency: transitive description: @@ -1308,14 +1559,30 @@ 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" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" synchronized: dependency: transitive description: name: synchronized - sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5 url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.5.0" term_glyph: dependency: transitive description: @@ -1324,38 +1591,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" - test: - dependency: transitive - description: - name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" - url: "https://pub.dev" - source: hosted - version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.7" - test_core: - dependency: transitive + version: "0.7.12" + timeago: + dependency: "direct main" description: - name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + name: timeago + sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e url: "https://pub.dev" source: hosted - version: "0.6.12" - thumbhash: - dependency: transitive + version: "3.7.1" + timezone: + dependency: "direct main" description: - name: thumbhash - sha256: "5f6d31c5279ca0b5caa81ec10aae8dcaab098d82cb699ea66ada4ed09c794a37" + name: timezone + sha256: "9f4e5a1a0e4fef7dd3424842c0575db01bfb7a11730e8d55753fcd5c5c98767b" url: "https://pub.dev" source: hosted - version: "0.1.0+1" + version: "0.11.2" typed_data: dependency: transitive description: @@ -1364,64 +1623,122 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - universal_html: + unicode: dependency: transitive description: - name: universal_html - sha256: c0bcae5c733c60f26c7dfc88b10b0fd27cbcc45cb7492311cdaa6067e21c9cd4 + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 url: "https://pub.dev" source: hosted - version: "2.3.0" - universal_io: + version: "1.1.9" + unifiedpush: + dependency: "direct main" + description: + name: unifiedpush + sha256: "8ed9767f750a1dc6159a77e2171641d0cb825dc87682d1ce1b8618689b79f58e" + url: "https://pub.dev" + source: hosted + version: "6.2.0" + unifiedpush_android: dependency: transitive description: - name: universal_io - sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + name: unifiedpush_android + sha256: a902e505e91c8e28b54b379bb27f255f66df409274a97af24d6719f12f0d13dc url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "3.5.0" + unifiedpush_linux: + dependency: "direct overridden" + description: + path: unifiedpush_linux + ref: "quad/nexus" + resolved-ref: "5b31fabcc260c2ff07c4499099ae20591e2c2cb9" + url: "https://codeberg.org/Henry-Hiles/flutter-connector" + source: git + version: "1.0.1" + unifiedpush_platform_interface: + dependency: "direct overridden" + description: + path: unifiedpush_platform_interface + ref: "quad/nexus" + resolved-ref: "5b31fabcc260c2ff07c4499099ae20591e2c2cb9" + url: "https://codeberg.org/Henry-Hiles/flutter-connector" + source: git + version: "4.0.0" + unifiedpush_storage_interface: + dependency: transitive + description: + name: unifiedpush_storage_interface + sha256: b8d423a4695efc616aa21d8ab48fb5ef99d6288c68b56282b8faac1579ceabd9 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + unifiedpush_storage_shared_preferences: + dependency: "direct main" + description: + name: unifiedpush_storage_shared_preferences + sha256: eda9c52bac0058f81e0d9c65e33eedc12c5901d2e68ad47fdf68175ddf00a3b4 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + 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: name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.3.3" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" url: "https://pub.dev" source: hosted - version: "6.3.28" + version: "6.3.33" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" url: "https://pub.dev" source: hosted - version: "6.3.6" + version: "6.4.2" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" url: "https://pub.dev" source: hosted - version: "3.2.5" + version: "3.2.6" url_launcher_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: url_launcher_platform_interface sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" @@ -1432,34 +1749,34 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" uuid: dependency: transitive description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.6.0" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + sha256: "82885f6ac633044e7a4e4d8d745724a67aa0dc50af5d8a81ec29379f63c29035" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.6" vector_graphics_codec: dependency: transitive description: @@ -1472,26 +1789,42 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "4c9d2e7121eea8b4f160e6e435900a0ae7214cfc97c2d6cf84c25532b789b50d" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.3.4" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: fa49e1674cb84acb900141714e6c6e1f9ae099827c24d141430ff6819db4e1bc url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.4" vm_service: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.4.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: "22b3e7e937721de70e63c85e7139f4ac781dc22863b9262431a53ac030eb074b" + url: "https://pub.dev" + source: hosted + version: "1.8.0" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "764c25504562abc8ac3406f5d175f126b276e6ba916c7c2675690b73368ca384" + url: "https://pub.dev" + source: hosted + version: "1.7.0" watcher: dependency: transitive description: @@ -1504,10 +1837,10 @@ packages: dependency: transitive description: name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" web_socket: dependency: transitive description: @@ -1524,41 +1857,66 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" - webkit_inspection_protocol: + webcrypto: + dependency: "direct overridden" + description: + path: "." + ref: HEAD + resolved-ref: "0e9924f1ff0f485af87da12e7d0f259e41f3cbc5" + url: "https://github.com/google/webcrypto.dart" + source: git + version: "0.6.2-wip" + webdriver: dependency: transitive description: - name: webkit_inspection_protocol - sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "3.2.0" + webpush_encryption: + dependency: transitive + description: + name: webpush_encryption + sha256: "5b83272b91acda6ae515fcd980c94f06bf413702282497c5a68f5dfc64fed27f" + url: "https://pub.dev" + source: hosted + version: "1.0.1" win32: dependency: transitive description: name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d url: "https://pub.dev" source: hosted - version: "5.15.0" + version: "6.4.0" window_manager: dependency: "direct main" description: name: window_manager - sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd" + sha256: bdd6b1a2c1ce50f3bba7ffbaa0d5dc5feb6b6a24fd20269da4de90244f77a471 url: "https://pub.dev" source: hosted - version: "0.5.1" - window_size: + version: "0.6.0" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + xdg_desktop_portal: dependency: "direct main" description: - path: "plugins/window_size" - ref: HEAD - resolved-ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601 - url: "https://github.com/google/flutter-desktop-embedding" + path: "." + ref: "quad/nexus" + resolved-ref: "964249996e536b3c5b10c2654d1dc69044693431" + url: "https://github.com/Henry-Hiles/xdg_desktop_portal.dart" source: git - version: "0.1.0" + version: "0.1.14" xdg_directories: - dependency: transitive + dependency: "direct main" description: name: xdg_directories sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" @@ -1569,26 +1927,26 @@ packages: dependency: transitive description: name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" url: "https://pub.dev" source: hosted - version: "6.6.1" + version: "7.0.1" yaml: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" yaml_edit: dependency: transitive description: name: yaml_edit - sha256: ec709065bb2c911b336853b67f3732dd13e0336bd065cc2f1061d7610ddf45e3 + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.2.4" sdks: - dart: ">=3.10.4 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.13.3 <4.0.0" + flutter: ">=3.47.0" diff --git a/pubspec.yaml b/pubspec.yaml index 3c0198d..cb0fef1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,84 +1,154 @@ name: nexus -description: "Yet another Matrix client" -version: 1.0.0 +description: "A simple and user-friendly Matrix client" +version: 1.1.0-alpha1 publish_to: none flutter: - assets: - - assets/ - uses-material-design: true + fonts: + - family: bundled-sans + fonts: + - asset: assets/fonts/Inter.ttf + - family: bundled-mono + fonts: + - asset: assets/fonts/AtkinsonHyperlegibleMono.ttf + assets: + - assets/bundled/ + uses-material-design: true environment: - sdk: "^3.9.2" + sdk: ^3.13.3 dependency_overrides: - analyzer: ^8.4.0 - source_gen: ^4.0.2 - flutter_hooks: ^0.21.2 + dynamic_color: 2.1.0 + code_assets: 2.1.0 # webcrypto requires ^1.0.0, but works with 2.x + webcrypto: + git: + url: https://github.com/google/webcrypto.dart # Uses build hooks and fixes macOS x86_64, unreleased + native_toolchain_cmake: + git: + url: https://github.com/rainyl/native_toolchain_cmake # Fixes native aarch64 linux builds, unreleased + m3e_design: + git: + url: https://github.com/Henry-Hiles/material_3_expressive + path: packages/m3e_design + fab_m3e: + git: + url: https://github.com/Henry-Hiles/material_3_expressive + path: packages/fab_m3e + unifiedpush_linux: + git: + url: https://codeberg.org/Henry-Hiles/flutter-connector + ref: quad/nexus + path: unifiedpush_linux + unifiedpush_platform_interface: + git: + url: https://codeberg.org/Henry-Hiles/flutter-connector + ref: quad/nexus + path: unifiedpush_platform_interface + linkify: + git: + url: https://github.com/appelladev/linkify + ref: fix/consecutive-periods-loose-url dependencies: - flutter: - sdk: flutter - flutter_localizations: - sdk: flutter - flutter_riverpod: ^3.0.3 - hooks_riverpod: ^3.0.3 - 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/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/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 - + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + flutter_riverpod: 3.4.3 + hooks_riverpod: 3.4.3 + intl: 0.20.3 + fast_immutable_collections: 11.2.1 + path_provider: 2.1.6 + url_launcher: 6.3.3 + freezed_annotation: 3.1.0 + image_picker: 1.2.4 + path: 1.9.1 + dynamic_color: 2.1.0 + collection: 1.19.1 + window_manager: 0.6.0 + color_hash: 1.0.1 + flutter_widget_from_html_core: 0.18.0-beta.1 + flutter_svg: 2.3.2 + json_annotation: 4.12.0 + shared_preferences: 2.5.6 + fluttertagger: + git: + url: https://github.com/Henry-Hiles/fluttertagger # Fixes the last tag search being dropped from formattedText + flutter_hooks: 0.21.3+1 + ffi: 2.2.0 + hooks: 2.2.0 + code_assets: 2.1.0 + ffigen: 23.0.0 + timeago: 3.7.1 + http: 1.6.0 + linkify: 5.0.0 + html: 0.15.7 + 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: 1.0.4 + m3e_design: 0.2.1 + navigation_rail_m3e: + git: + url: https://github.com/Henry-Hiles/material_3_expressive + path: packages/navigation_rail_m3e + m3e_card_list: 1.0.0 + xdg_directories: 1.1.0 + package_info_plus: 10.2.2 + app_links: 7.2.2 + file_selector: 1.1.0 + material_ui: 1.9.0 + multi_state_switch: 1.1.0 + unifiedpush: 6.2.0 + unifiedpush_storage_shared_preferences: 1.0.0 + flutter_local_notifications: 22.3.3 + timezone: 0.11.2 + material_emoji_picker: 2.0.1+1 + material_adaptive_sheets: 1.0.0 + xdg_desktop_portal: + git: + url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart + ref: quad/nexus + pasteboard: 0.6.0 + flutter_file_dialog: 3.3.3 + mime: 2.1.0 + avif_native: 0.2.0 + flutter_map: 8.3.2 + latlong2: 0.10.1 + geolocator: 14.1.2 dev_dependencies: - build_runner: ^2.4.11 - custom_lint: ^0.8.0 - flutter_lints: ^6.0.0 - freezed: ^3.2.3 - riverpod_lint: ^3.0.3 - flutter_launcher_icons: ^0.14.1 - json_serializable: ^6.11.1 + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + app_links_platform_interface: 2.0.4 + url_launcher_platform_interface: 2.3.2 + build_runner: 2.17.1 + flutter_lints: 6.0.0 + freezed: 4.0.3 + riverpod_lint: 3.1.9 + flutter_launcher_icons: 0.14.4 + json_serializable: 6.15.2 flutter_launcher_icons: - ios: true - android: true - image_path: assets/icon.png - adaptive_icon_background: "#000000" - adaptive_icon_foreground: assets/foreground.png - remove_alpha_ios: true - windows: - generate: true \ No newline at end of file + image_path: assets/icon.png + ios: true + image_path_ios: assets/mobile.png + android: true + adaptive_icon_background: assets/background.png + adaptive_icon_foreground: assets/foreground.png + adaptive_icon_monochrome: assets/monochrome.png + windows: + generate: true + macos: + generate: true + image_path: assets/mobile.png + +hooks: + user_defines: + avif_native: + features: decode diff --git a/scripts/generate.dart b/scripts/generate.dart index b240d98..d8a8cb1 100644 --- a/scripts/generate.dart +++ b/scripts/generate.dart @@ -1,45 +1,44 @@ import "dart:io"; + import "package:ffigen/ffigen.dart"; import "package:path/path.dart"; +import "package:nexus/helpers/extensions/get_xcode_sdk.dart"; void main(List args) async { - final repoDir = Directory.fromUri( - Platform.script.resolve("../src/gomuks/source"), - ); - if (await repoDir.exists()) await repoDir.delete(recursive: true); - await repoDir.create(recursive: true); + final repoDir = Directory.fromUri(Platform.script.resolve("../gomuks")); - 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..."); + print("Generating FFI bindings..."); final libclangPath = Platform.environment["LIBCLANG_PATH"]; FfiGenerator( - output: Output( - dartFile: Platform.script.resolve("../lib/src/third_party/gomuks.g.dart"), + output: .new( + dart: .new( + path: Platform.script.resolve("../lib/src/third_party/gomuks.g.dart"), + ), ), - headers: Headers( + visitors: [.new(func: (node) => node.isIncluded = true)], + input: .new( entryPoints: [File(join(repoDir.path, "pkg", "ffi", "gomuksffi.h")).uri], - compilerOptions: ["--no-warnings"], + compilerOptions: [ + "--no-warnings", + if (Platform.isMacOS) "-I${await getXCodeTool()}/usr/include", + ], ), - functions: Functions.includeAll, ).generate( libclangDylib: libclangPath == null ? null - : Uri.file(join(libclangPath, "libclang.so")), + : Uri.file( + join( + libclangPath, + "libclang.${(Platform.isLinux || Platform.isAndroid) + ? "so" + : Platform.isMacOS + ? "dylib" + : Platform.isWindows + ? "dll" + : throw UnsupportedError("Unsupported Platform")}", + ), + ), ); - print("Done!"); + print("FFI bindings generated!"); } diff --git a/scripts/generate.sh b/scripts/generate.sh deleted file mode 100755 index 6076ab8..0000000 --- a/scripts/generate.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/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 55fb066..8df32b4 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,24 +6,30 @@ #include "generated_plugin_registrant.h" -#include +#include +#include #include -#include +#include +#include +#include +#include #include -#include -#include void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); DynamicColorPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); - ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); + MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); + MediaKitVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); + PasteboardPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PasteboardPlugin")); 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 9333a2f..3435f5e 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,15 +3,19 @@ # list(APPEND FLUTTER_PLUGIN_LIST - dynamic_system_colors + app_links + dynamic_color file_selector_windows - screen_retriever_windows + geolocator_windows + media_kit_libs_windows_video + media_kit_video + pasteboard url_launcher_windows - window_manager - window_size ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/windows/installer.iss b/windows/installer.iss index c5004c3..65f1ce5 100644 --- a/windows/installer.iss +++ b/windows/installer.iss @@ -14,4 +14,10 @@ Source: "..\build\windows\x64\runner\Release\*"; DestDir: "{app}"; Flags: recurs [Icons] Name: "{group}\Nexus"; Filename: "{app}\nexus.exe" -Name: "{commondesktop}\Nexus"; Filename: "{app}\nexus.exe" \ No newline at end of file +Name: "{commondesktop}\Nexus"; Filename: "{app}\nexus.exe" + +[Registry] +Root: HKCU; Subkey: "Software\Classes\nexus.federated.nexus"; ValueType: string; ValueName: "URL Protocol"; ValueData: "" +Root: HKCU; Subkey: "Software\Classes\nexus.federated.nexus\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\nexus.exe"" ""%1""" +Root: HKCU; Subkey: "Software\Classes\matrix"; ValueType: string; ValueName: "URL Protocol"; ValueData: "" +Root: HKCU; Subkey: "Software\Classes\matrix\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\nexus.exe"" ""%1""" diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 453f9cf..590e663 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -1,12 +1,17 @@ #include #include #include - +#include "app_links/app_links_plugin_c_api.h" #include "flutter_window.h" #include "utils.h" int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t *command_line, _In_ int show_command) { + // Forward the link to an existing instance, if any, then exit. + // You may ignore the result if you need to create another window. + if (SendAppLinkToInstance()) { + return EXIT_SUCCESS; + } // Attach to console when present (e.g., 'flutter run') or create a // new console when running with a debugger. if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index e3c83c9..f8a91f7 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ