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..d52e3e6 --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,66 @@ +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 }} + + # Only checks it launches and stays open, since there's no one to try it + - name: Smoke test + run: | + open build/macos/Build/Products/Release/Nexus.app + sleep 20 + screencapture -x screenshot.png || true + if ! pgrep -if "Nexus.app/Contents/MacOS"; then + echo "Nexus exited during startup" + exit 1 + fi + + - name: Upload screenshot + if: always() + uses: actions/upload-artifact@v6 + with: + name: macos-screenshot + path: screenshot.png + if-no-files-found: ignore + + - 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 new file mode 100644 index 0000000..db339b9 --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,74 @@ +name: "Build EXE" + +on: + push: + branches: ["main"] + tags: ["*"] + workflow_dispatch: + +jobs: + build-exe: + runs-on: windows-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: Setup MSYS2 + uses: msys2/setup-msys2@v2 + with: + msystem: CLANG64 + install: >- + mingw-w64-x86_64-gcc + + - name: Go build + run: | + cd gomuks/pkg/ffi + go build -tags goolm,sqlite_fts5 -o ../../../libgomuks.dll -buildmode=c-shared + + - name: Build with Flutter + run: | + flutter pub get + dart scripts/generate.dart + flutter pub run build_runner build + flutter build windows --dart-define=GIT_COMMIT=${{ github.sha }} + + - 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: Copy MSVC runtime DLLs + shell: powershell + run: | + $release = "build/windows/x64/runner/Release" + + 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 dfaf03d..912ced3 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,9 @@ key.properties *.freezed.dart # 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..06dd51a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "gomuks"] + path = gomuks + url = https://github.com/gomuks/gomuks 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 30f4254..a5cac8a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,19 @@ { - "cSpell.words": ["Appbar", "Displayname", "prefs"] + "cSpell.words": [ + "Appbar", + "Displayname", + "fluttertagger", + "Gomuks", + "Homeserver", + "Linkified", + "localpart", + "msgtype", + "muks", + "prefs", + "recents", + "unban", + "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/LICENSE b/LICENSE new file mode 100644 index 0000000..496acdb --- /dev/null +++ b/LICENSE @@ -0,0 +1,675 @@ +# GNU GENERAL PUBLIC LICENSE + +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +## Preamble + +The GNU General Public License is a free, copyleft license for +software and other kinds of works. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom +to share and change all versions of a program--to make sure it remains +free software for all its users. We, the Free Software Foundation, use +the GNU General Public License for most of our software; it applies +also to any other work released this way by its authors. You can apply +it to your programs, too. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you +have certain responsibilities if you distribute copies of the +software, or if you modify it: responsibilities to respect the freedom +of others. + +For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + +Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + +Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the +manufacturer can do so. This is fundamentally incompatible with the +aim of protecting users' freedom to change the software. The +systematic pattern of such abuse occurs in the area of products for +individuals to use, which is precisely where it is most unacceptable. +Therefore, we have designed this version of the GPL to prohibit the +practice for those products. If such problems arise substantially in +other domains, we stand ready to extend this provision to those +domains in future versions of the GPL, as needed to protect the +freedom of users. + +Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish +to avoid the special danger that patents applied to a free program +could make it effectively proprietary. To prevent this, the GPL +assures that patents cannot be used to render the program non-free. + +The precise terms and conditions for copying, distribution and +modification follow. + +## TERMS AND CONDITIONS + +### 0. Definitions. + +"This License" refers to version 3 of the GNU General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds +of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of +an exact copy. The resulting work is called a "modified version" of +the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user +through a computer network, with no transfer of a copy, is not +conveying. + +An interactive user interface displays "Appropriate Legal Notices" to +the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +### 1. Source Code. + +The "source code" for a work means the preferred form of the work for +making modifications to it. "Object code" means any non-source form of +a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can +regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same +work. + +### 2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, +without conditions so long as your license otherwise remains in force. +You may convey covered works to others for the sole purpose of having +them make modifications exclusively for you, or provide you with +facilities for running those works, provided that you comply with the +terms of this License in conveying all material for which you do not +control copyright. Those thus making or running the covered works for +you must do so exclusively on your behalf, under your direction and +control, on terms that prohibit them from making any copies of your +copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes +it unnecessary. + +### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such +circumvention is effected by exercising rights under this License with +respect to the covered work, and you disclaim any intention to limit +operation or modification of the work as a means of enforcing, against +the work's users, your or third parties' legal rights to forbid +circumvention of technological measures. + +### 4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +### 5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these +conditions: + +- a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- b) The work must carry prominent notices stating that it is + released under this License and any conditions added under + section 7. This requirement modifies the requirement in section 4 + to "keep intact all notices". +- c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +### 6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of +sections 4 and 5, provided that you also convey the machine-readable +Corresponding Source under the terms of this License, in one of these +ways: + +- a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. +- c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- e) Convey the object code using peer-to-peer transmission, + provided you inform other peers where the object code and + Corresponding Source of the work are being offered to the general + public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, +family, or household purposes, or (2) anything designed or sold for +incorporation into a dwelling. In determining whether a product is a +consumer product, doubtful cases shall be resolved in favor of +coverage. For a particular product received by a particular user, +"normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected +to use, the product. A product is a consumer product regardless of +whether the product has substantial commercial, industrial or +non-consumer uses, unless such uses represent the only significant +mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to +install and execute modified versions of a covered work in that User +Product from a modified version of its Corresponding Source. The +information must suffice to ensure that the continued functioning of +the modified object code is in no case prevented or interfered with +solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or +updates for a work that has been modified or installed by the +recipient, or for the User Product in which it has been modified or +installed. Access to a network may be denied when the modification +itself materially and adversely affects the operation of the network +or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +### 7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + +- a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- c) Prohibiting misrepresentation of the origin of that material, + or requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- d) Limiting the use for publicity purposes of names of licensors + or authors of the material; or +- e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions + of it) with contractual assumptions of liability to the recipient, + for any liability that these contractual assumptions directly + impose on those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; the +above requirements apply either way. + +### 8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your license +from a particular copyright holder is reinstated (a) provisionally, +unless and until the copyright holder explicitly and finally +terminates your license, and (b) permanently, if the copyright holder +fails to notify you of the violation by some reasonable means prior to +60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +### 9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run +a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +### 10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +### 11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned +or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within the +scope of its coverage, prohibits the exercise of, or is conditioned on +the non-exercise of one or more of the rights that are specifically +granted under this License. You may not convey a covered work if you +are a party to an arrangement with a third party that is in the +business of distributing software, under which you make payment to the +third party based on the extent of your activity of conveying the +work, and under which the third party grants, to any of the parties +who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by +you (or copies made from those copies), or (b) primarily for and in +connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +### 12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under +this License and any other pertinent obligations, then as a +consequence you may not convey it at all. For example, if you agree to +terms that obligate you to collect a royalty for further conveying +from those to whom you convey the Program, the only way you could +satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +### 13. Use with the GNU Affero General Public License. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + +### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in +detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or +of any later version published by the Free Software Foundation. If the +Program does not specify a version number of the GNU General Public +License, you may choose any version ever published by the Free +Software Foundation. + +If the Program specifies that a proxy can decide which future versions +of the GNU General Public License can be used, that proxy's public +statement of acceptance of a version permanently authorizes you to +choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +### 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + +### 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR +LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +### 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +## How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these +terms. + +To do so, attach the following notices to the program. It is safest to +attach them to the start of each source file to most effectively state +the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper +mail. + +If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands \`show w' and \`show c' should show the +appropriate parts of the General Public License. Of course, your +program's commands might be different; for a GUI interface, you would +use an "about box". + +You should also get your employer (if you work as a programmer) or +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. For more information on this, and how to apply and follow +the GNU GPL, see . + +The GNU General Public License does not permit incorporating your +program into proprietary programs. If your program is a subroutine +library, you may consider it more useful to permit linking proprietary +applications with the library. If this is what you want to do, use the +GNU Lesser General Public License instead of this License. But first, +please read . diff --git a/README.md b/README.md index 670e20e..a5d7736 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,109 +10,150 @@ 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 -- [ ] Platform Support - - [x] Linux - - [x] Windows (untested, if you are interested in helping to test, open an issue) - - [ ] MacOS - - [ ] Android - - [ ] iOS - - [ ] Web (may not be possible) -- [x] Login - - [x] Username / password auth - - [ ] OAuth / OIDC -- [x] Rooms / Spaces - - [x] Displaying and choosing - - [x] Reading, showing unread - - [ ] Mark as read button on rooms and spaces - - [ ] Searching - - [ ] Creating (Rooms, Spaces, and DMs) - - [ ] Joining - - [ ] Using alias - - [ ] From space - - [ ] Exploring - - [x] Leaving - - [x] Subspaces -- [x] Messages - - [x] Sending - - [x] Plain text - - [x] HTML/Markdown - - [x] Replies - - [ ] Attachments - - [x] Mentions - - [x] Users - - [x] Rooms - - [ ] Custom emojis/stickers - - [ ] GIFs, maybe through Tenor or something - - [ ] Encrypted messages - - [x] Recieving - - [x] Plain text - - [x] HTML - - [x] Replies - - [x] Viewing - - [ ] Jump to original message - - [x] Edits - - [x] Attachments - - [ ] Downloading attachments - - [ ] Opening attachments in their own view - - [x] Mentions - - [x] Users - - [x] Rooms - - [ ] Plain text - - [x] Matrix URIs - - [x] Matrix.to links - - [x] Custom emojis/stickers - - [ ] Encrypted messages - - [x] History loading - - [x] Backwards - - [ ] Forwards - - [ ] Editing - - [x] Deleting -- [ ] Reactions: Waiting on https://github.com/flyerhq/flutter_chat_ui/pull/838 -- [ ] Pins - - [ ] Displaying - - [ ] Creating -- [ ] Threads -- [ ] Profile popouts -- [x] Copy link to [room, space] -- [x] Reporting -- [ ] Notifications using UnifiedPush -- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) -- [ ] Invites - - [ ] Viewing / accepting - - [ ] Spam filtering -- [ ] Settings - - [ ] Light/Dark mode - - [ ] 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). -## Development +### Nightly Builds -Fork and clone the project, then: +For the newest changes, grab one of the following nightly artifacts, built by CI from the latest commit on `main`. -- With Nix: Either use direnv, or `nix flake develop` -- Without Nix: Install Flutter, Rust, the libsecret dev package for your distro (must be in `PKG_CONFIG_PATH`), and sqlite (must be in `LD_LIBRARY_PATH`). +- [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.0-alpha5"; +``` + +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 + +- 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 + +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 + +Get dependencies: + +```sh +flutter pub get +``` + +Generate Gomuks bindings: + +```sh +dart scripts/generate.dart +``` + +> [!NOTE] +> If you are having issues with `stddef.h` not being found, try setting CPATH manually: +> +> ```sh +> export CPATH="$(clang -v 2>&1 | grep "Selected GCC installation" | rev | cut -d' ' -f1 | rev)/include" +> ``` Build generated files, and watch for new changes: ```sh -flutter pub run build_runner watch --delete-conflicting-outputs +flutter pub run build_runner watch ``` -Run `flutter run` to run the app. +Run the app: + +```sh +flutter run +``` + +Development instructions can be found in [DEVELOPMENT.md](./DEVELOPMENT.md). ## Community -Come chat in the [Federated Nexus Community](https://matrix.to/#/#space: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 c2aaaa0..00df0c4 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,11 +1,18 @@ analyzer: - errors: - invalid_annotation_target: 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..b471fb4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,11 +6,13 @@ + + + + + + + + + + + + + - + + 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/screenshotDark.png b/assets/screenshotDark.png index dec983f..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 003f6b4..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/build.yaml b/build.yaml new file mode 100644 index 0000000..5d6aeda --- /dev/null +++ b/build.yaml @@ -0,0 +1,6 @@ +targets: + $default: + builders: + json_serializable: + options: + field_rename: snake diff --git a/flake.lock b/flake.lock index b627cd3..066f38b 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1759362264, - "narHash": "sha256-wfG0S7pltlYyZTM+qqlhJ7GMw2fTF4mLKCIVhLii/4M=", + "lastModified": 1788450739, + "narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "758cf7296bee11f1706a574c77d072b8a7baa881", + "rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993", "type": "github" }, "original": { @@ -18,13 +18,81 @@ "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": 1759381078, - "narHash": "sha256-gTrEEp5gEspIcCOx9PD8kMaF1iEmfBcTbO0Jag2QhQs=", + "lastModified": 1773389992, + "narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c06b4ae3d6599a672a6210b7021d699c351eebda", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1788057806, + "narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1790689126, + "narHash": "sha256-ilerN1WLSvF+HMjziC/Wv99J5y02maDH+6hZPwsORKg=", "owner": "nixos", "repo": "nixpkgs", - "rev": "7df7ff7d8e00218376575f0acdcc5d66741351ee", + "rev": "b4fd65b198c599cbe814fcb9f42d25d021595ec9", "type": "github" }, "original": { @@ -34,25 +102,26 @@ "type": "github" } }, - "nixpkgs-lib": { - "locked": { - "lastModified": 1754788789, - "narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=", - "owner": "nix-community", - "repo": "nixpkgs.lib", - "rev": "a73b9c743612e4244d865a2fdee11865283c04e6", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "nixpkgs.lib", - "type": "github" - } - }, "root": { "inputs": { "flake-parts": "flake-parts", - "nixpkgs": "nixpkgs" + "nix2flatpak": "nix2flatpak", + "nixpkgs": "nixpkgs_2" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index 027db59..e4cae8a 100644 --- a/flake.nix +++ b/flake.nix @@ -2,8 +2,10 @@ description = "Nexus Flutter Flake"; inputs = { + self.submodules = true; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; + nix2flatpak.url = "github:neobrain/nix2flatpak"; }; outputs = @@ -14,58 +16,12 @@ ... }@inputs: flake-parts.lib.mkFlake { inherit inputs; } { + imports = [ ./linux/nix ]; systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ]; - - perSystem = - { - pkgs, - system, - ... - }: - - { - _module.args.pkgs = import nixpkgs { - inherit system; - config = { - android_sdk.accept_license = true; - allowUnfree = true; - }; - }; - - devShells.default = - let - # android = pkgs.callPackage ./nix/android.nix { }; - in - pkgs.mkShell { - packages = with pkgs; [ - # jdk17 - cargo - (flutter.override { extraPkgConfigPackages = [ pkgs.libsecret ]; }) - - # android.platform-tools - (pkgs.writeShellScriptBin "rustup" (builtins.readFile ./nix/fake-rustup.sh)) - ]; - - env = rec { - LD_LIBRARY_PATH = "${ - pkgs.lib.makeLibraryPath ([ - pkgs.sqlite - ]) - }:./build/linux/x64/debug/plugins/flutter_vodozemac"; - - # ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk"; - # ANDROID_SDK_ROOT = ANDROID_HOME; - # JAVA_HOME = pkgs.jdk17; - - # TOOLS = "${ANDROID_HOME}/build-tools/${"36.0.0"}"; - # GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${TOOLS}/aapt2"; - }; - }; - }; }; } diff --git a/gomuks b/gomuks new file mode 160000 index 0000000..53cae0e --- /dev/null +++ b/gomuks @@ -0,0 +1 @@ +Subproject commit 53cae0e0334aef5ac376ea84f19ba1cff8a92310 diff --git a/hook/build.dart b/hook/build.dart new file mode 100644 index 0000000..8726642 --- /dev/null +++ b/hook/build.dart @@ -0,0 +1,210 @@ +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 { + if (!input.config.buildCodeAssets) return; + final codeConfig = input.config.code; + final targetOS = codeConfig.targetOS; + final targetArch = codeConfig.targetArchitecture; + + 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"); + } + + var libFile = input.packageRoot.resolve(libFileName); + final gomuksBuildDir = input.packageRoot.resolve("gomuks/"); + + if (!(await File.fromUri(libFile).exists())) { + final buildDir = input.packageRoot.resolve("build/"); + libFile = buildDir.resolve("${targetArch.name}/$libFileName"); + + 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"; + print("Adding $libFileName as asset..."); + output + ..assets.code.add( + CodeAsset( + package: "nexus", + name: generatedFile, + linkMode: DynamicLoadingBundled(), + file: libFile, + ), + ) + ..dependencies.add(libFile) + ..dependencies.add(gomuksBuildDir); + print("Done!"); +}); 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..7680b42 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -3,7 +3,7 @@ CADisableMinimumFrameDurationOnPhone - + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -22,30 +22,40 @@ $(FLUTTER_BUILD_NAME) CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + nexus.federated.nexus + + + 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 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..c595565 --- /dev/null +++ b/lib/controllers/account_data.dart @@ -0,0 +1,22 @@ +import "dart:convert"; + +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 + AccountData build() => .new(); + + void update(IMap> newAccountData) => + state = .fromJson({ + ...json.decode(json.encode(state.toJson())), + ...newAccountData + .map((key, value) => MapEntry(key, value["content"])) + .unlock, + }); + + static final provider = NotifierProvider( + AccountDataController.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..2cabbae --- /dev/null +++ b/lib/controllers/attachment.dart @@ -0,0 +1,36 @@ +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:path/path.dart"; + +class AttachmentController(final String roomId) + 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[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); + } + + static final provider = NotifierProvider.family + .autoDispose( + 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/avatar_controller.dart b/lib/controllers/avatar_controller.dart deleted file mode 100644 index 1bb4c72..0000000 --- a/lib/controllers/avatar_controller.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:matrix/matrix.dart"; -import "package:nexus/controllers/client_controller.dart"; - -class AvatarController extends AsyncNotifier { - final String mxc; - AvatarController(this.mxc); - @override - Future build() async => Uri.parse(mxc).getThumbnailUri( - await ref.watch(ClientController.provider.future), - width: 24, - height: 24, - ); - - static final provider = AsyncNotifierProvider.family - .autoDispose(AvatarController.new); -} diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart new file mode 100644 index 0000000..569138f --- /dev/null +++ b/lib/controllers/client.dart @@ -0,0 +1,431 @@ +import "dart:async"; +import "dart:ffi"; +import "dart:io"; +import "dart:isolate"; +import "dart:math"; + +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/settings.dart"; +import "package:nexus/helpers/extensions/gomuks_buffer.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_space_hierarchy.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/upload_media.dart"; +import "package:nexus/models/room.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_provider/path_provider.dart"; + +class ClientController extends AsyncNotifier { + @override + Future build() async { + debugPrint("Setting Gomuks env..."); + if (Platform.isAndroid || Platform.isIOS) { + final env = { + "GOMUKS_ROOT": (await getApplicationSupportDirectory()).path, + "GOMUKS_CACHE_HOME": (await getApplicationCacheDirectory()).path, + }; + for (final MapEntry(:key, :value) in env.entries) { + final keyPtr = key.toNativeUtf8().cast(); + final valuePtr = value.toNativeUtf8().cast(); + try { + GomuksSetEnv(keyPtr, valuePtr); + } finally { + calloc + ..free(keyPtr) + ..free(valuePtr); + } + } + } + + debugPrint("Initializing Gomuks..."); + final handle = await Isolate.run(() { + final bufferPointer = GomuksConfig( + matrix: .new( + initialDeviceDisplayName: + "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", + ), + ).toJson().toGomuksBufferPtr(); + + try { + return GomuksInit(bufferPointer.ref); + } finally { + calloc + ..free(bufferPointer.ref.base) + ..free(bufferPointer); + } + }); + + if (!ref.mounted) { + GomuksDestroy(handle); + return Completer().future; + } + + ref.onDispose(() => GomuksDestroy(handle)); + + return handle; + } + + /// Calls gomuks with [data] as JSON, returning its decoded response. + Future callGomuksMethod( + Map data, + void Function( + int handle, + GomuksOwnedBuffer data, + Pointer> callback, + ) + call, + ) async { + final handle = await future; + 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) { + final commandPointer = command.toNativeUtf8().cast(); + try { + GomuksSubmitCommandAsync(handle, commandPointer, data, callback); + } finally { + // gomuks copies the command name before returning + calloc.free(commandPointer); + } + }); + + Future redactEvent(RedactEventRequest report) => + _sendCommand("redact_event", report.toJson()); + + Future sendMessage(SendMessageRequest request) async => + Event.fromJson(await _sendCommand("send_message", request.toJson())); + + Future sendEvent(SendEventRequest request) async { + final json = request.toJson(); + final content = request.content.toJson(); + + return .fromJson( + await _sendCommand("send_event", { + ...json, + "content": { + ...content, + "m.relates_to": { + ...((content["m.relates_to"] as Map?) ?? {}), + "event_id": request.relatesTo, + "rel_type": request.relationType, + }, + }, + }), + ); + } + + Future setState(SetStateRequest request) async => + await _sendCommand("set_state", request.toJson()); + + Future verify(String recoveryKey) async { + try { + await _sendCommand("verify", {"recovery_key": recoveryKey}); + return null; + } catch (error) { + return error.toString(); + } + } + + 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 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?> 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 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 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 => + .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) async { + if (room.timeline.isEmpty || room.metadata == null) return; + final eventRowId = room.timeline[room.timeline.keys.reduce(max)]; + final event = eventRowId == null ? null : room.events[eventRowId]; + + 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, + }); + } + + 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}); + + /// 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.new, + ); +} diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart deleted file mode 100644 index 1a88526..0000000 --- a/lib/controllers/client_controller.dart +++ /dev/null @@ -1,100 +0,0 @@ -import "dart:convert"; -import "dart:io"; -import "package:flutter/foundation.dart"; -import "package:nexus/controllers/database_controller.dart"; -import "package:flutter_vodozemac/flutter_vodozemac.dart"; -import "package:matrix/matrix.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/secure_storage_controller.dart"; -import "package:nexus/models/session_backup.dart"; - -class ClientController extends AsyncNotifier { - @override - bool updateShouldNotify( - AsyncValue previous, - AsyncValue next, - ) => previous.hasValue != next.hasValue; - static const sessionBackupKey = "sessionBackup"; - - @override - Future build() async { - final client = Client( - "nexus", - logLevel: kReleaseMode ? Level.warning : Level.verbose, - importantStateEvents: {"im.ponies.room_emotes"}, - supportedLoginTypes: {AuthenticationTypes.password}, - database: await MatrixSdkDatabase.init( - "nexus", - database: await ref.watch(DatabaseController.provider.future), - ), - nativeImplementations: NativeImplementationsIsolate( - compute, - vodozemacInit: init, - ), - ); - - final backupJson = await ref - .watch(SecureStorageController.provider.notifier) - .get(sessionBackupKey); - - if (backupJson != null) { - final backup = SessionBackup.fromJson(json.decode(backupJson)); - - await client.init( - waitForFirstSync: false, - newToken: backup.accessToken, - newHomeserver: backup.homeserver, - newUserID: backup.userID, - newDeviceID: backup.deviceID, - newDeviceName: backup.deviceName, - ); - } - - return client; - } - - Future setHomeserver(Uri homeserverUrl) async { - final client = await future; - try { - await client.checkHomeserver(homeserverUrl); - return true; - } catch (_) { - return false; - } - } - - Future login(String username, String password) async { - final client = await future; - try { - final deviceName = "Nexus Client login on ${Platform.localHostname}"; - final details = await MatrixApi(homeserver: client.homeserver).login( - LoginType.mLoginPassword, - initialDeviceDisplayName: deviceName, - identifier: AuthenticationUserIdentifier(user: username), - password: password, - ); - await ref - .watch(SecureStorageController.provider.notifier) - .set( - sessionBackupKey, - json.encode( - SessionBackup( - accessToken: details.accessToken, - homeserver: client.homeserver!, - userID: details.userId, - deviceID: details.deviceId, - deviceName: deviceName, - ).toJson(), - ), - ); - ref.invalidateSelf(asReload: true); - return true; - } catch (_) { - return false; - } - } - - 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.dart b/lib/controllers/client_state.dart new file mode 100644 index 0000000..1b77ecb --- /dev/null +++ b/lib/controllers/client_state.dart @@ -0,0 +1,13 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/models/client_state.dart"; + +class ClientStateController extends Notifier { + @override + Null build() => null; + + void set(ClientState newState) => state = newState; + + static final provider = NotifierProvider( + ClientStateController.new, + ); +} diff --git a/lib/controllers/database_controller.dart b/lib/controllers/database_controller.dart deleted file mode 100644 index 706560b..0000000 --- a/lib/controllers/database_controller.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:path/path.dart"; -import "package:path_provider/path_provider.dart"; -import "package:sqflite_common_ffi/sqflite_ffi.dart"; - -class DatabaseController extends AsyncNotifier { - @override - Future build() async { - databaseFactory = databaseFactoryFfi; - return databaseFactoryFfi.openDatabase( - join((await getApplicationSupportDirectory()).path, "database.db"), - ); - } - - static final provider = AsyncNotifierProvider( - DatabaseController.new, - ); -} diff --git a/lib/controllers/event.dart b/lib/controllers/event.dart new file mode 100644 index 0000000..a41d6b6 --- /dev/null +++ b/lib/controllers/event.dart @@ -0,0 +1,51 @@ +import "package:collection/collection.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"; +import "package:nexus/models/requests/get_event.dart"; + +class EventController(final GetEventRequest request) + extends AsyncNotifier { + @override + Future build() async { + final room = ref.watch( + RoomsController.provider.select((value) => value[request.roomId]), + ); + final event = room?.events.values.firstWhereOrNull( + (event) => event.eventId == request.eventId, + ); + + if (event != null) return event; + + 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; + } + + 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/events_controller.dart b/lib/controllers/events_controller.dart deleted file mode 100644 index 37b9ff2..0000000 --- a/lib/controllers/events_controller.dart +++ /dev/null @@ -1,30 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:matrix/matrix.dart"; -import "package:nexus/controllers/from_controller.dart"; - -class EventsController extends AsyncNotifier { - EventsController(this.room); - final Room room; - - @override - Future build({String? from}) async { - final response = await room.client.getRoomEvents( - room.id, - Direction.b, - from: from, - limit: 32, - ); - if (ref.mounted) { - ref.watch(FromController.provider(room).notifier).set(response.end); - } - return response; - } - - Future prev() async => - build(from: ref.read(FromController.provider(room))); - - static final provider = AsyncNotifierProvider.autoDispose - .family( - EventsController.new, - ); -} diff --git a/lib/controllers/from_controller.dart b/lib/controllers/from_controller.dart deleted file mode 100644 index 54c850a..0000000 --- a/lib/controllers/from_controller.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:matrix/matrix.dart"; - -class FromController extends Notifier { - FromController(_); - @override - String? build() => null; - - void set(String? value) => state = value; - - static final provider = - NotifierProvider.family( - FromController.new, - ); -} diff --git a/lib/controllers/gomuks_listener.dart b/lib/controllers/gomuks_listener.dart new file mode 100644 index 0000000..a97599b --- /dev/null +++ b/lib/controllers/gomuks_listener.dart @@ -0,0 +1,193 @@ +import "dart:async"; +import "dart:ffi"; +import "dart:io"; +import "dart:isolate"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/foundation.dart"; +import "package:nexus/controllers/account_data.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/event.dart"; +import "package:nexus/models/events_decrypted.dart"; +import "package:nexus/models/sync_data.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, + ) { + try { + final muksEventType = command.cast().toDartString(); + debugPrint("Handling $muksEventType..."); + final decodedMuksEvent = data.toJsonAndFree(); + + switch (muksEventType) { + case "client_state": + ref + .read(ClientStateController.provider.notifier) + .set(.fromJson(decodedMuksEvent)); + case "sync_status": + ref + .read(SyncStatusController.provider.notifier) + .set(.fromJson(decodedMuksEvent)); + case "init_complete": + ref.read(InitCompleteController.provider.notifier).complete(); + case "send_complete": + final event = Event.fromJson(decodedMuksEvent["event"]); + ref + .read(RoomsController.provider.notifier) + .update( + .new({ + event.roomId: .new(events: .new({event.rowId: event})), + }), + ); + case "typing": + ref + .read(TypingController.provider.notifier) + .set(.fromJson(decodedMuksEvent)); + case "events_decrypted": + final decrypted = EventsDecrypted.fromJson(decodedMuksEvent); + final metadata = ref + .read(RoomsController.provider)[decrypted.roomId] + ?.metadata; + + ref + .read(RoomsController.provider.notifier) + .update( + .new({ + decrypted.roomId: .new( + events: decrypted.events, + metadata: metadata?.copyWith( + previewEventRowID: + decrypted.previewEventRowID ?? + metadata.previewEventRowID, + sortingTimestamp: + decrypted.sortingTimestamp ?? + metadata.sortingTimestamp, + ), + ), + }), + ); + case "sync_complete": + final syncData = SyncData.fromJson(decodedMuksEvent); + final roomProvider = RoomsController.provider; + final accountDataProvider = AccountDataController.provider; + final invitesProvider = InvitesController.provider; + + if (syncData.clearState) { + ref.invalidate(roomProvider); + ref.invalidate(accountDataProvider); + ref.invalidate(invitesProvider); + ref.invalidate(TypingController.provider); + } + + ref + .read(roomProvider.notifier) + .update(syncData.rooms, syncData.leftRooms); + ref + .read(accountDataProvider.notifier) + .update(syncData.accountData); + ref + .read(invitesProvider.notifier) + .update( + syncData.invitedRooms, + joinedRooms: syncData.rooms.keys.toISet(), + leftRooms: syncData.leftRooms, + ); + + if (syncData.topLevelSpaces case final topLevelSpaces?) { + ref + .read(TopLevelSpacesController.provider.notifier) + .set(topLevelSpaces); + } + + if (syncData.spaceEdges case final spaceEdges?) { + ref + .read(SpaceEdgesController.provider.notifier) + .set(spaceEdges); + } + default: + debugPrint("Unhandled event: $muksEventType"); + } + debugPrint("Finished handling $muksEventType..."); + } 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; + + 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) { + if (paused) { + GomuksPauseSync(handle); + } else { + ref.invalidate(TypingController.provider); + GomuksResumeSync(handle); + } + }, 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/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.dart b/lib/controllers/init_complete.dart new file mode 100644 index 0000000..c011472 --- /dev/null +++ b/lib/controllers/init_complete.dart @@ -0,0 +1,11 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; + +class InitCompleteController extends Notifier { + @override + bool build() => false; + void complete() => state = true; + + static final provider = NotifierProvider( + InitCompleteController.new, + ); +} diff --git a/lib/controllers/invites.dart b/lib/controllers/invites.dart new file mode 100644 index 0000000..3effbf9 --- /dev/null +++ b/lib/controllers/invites.dart @@ -0,0 +1,122 @@ +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/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"; + +/// 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 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))); + + 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..33c76da --- /dev/null +++ b/lib/controllers/key.dart @@ -0,0 +1,28 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/shared_prefs.dart"; + +class KeyController(final String key) extends AsyncNotifier { + static const String spaceKey = "space"; + static const String roomKey = "room"; + static const String pushKeyKey = "pushKey"; + + @override + Future build() => + ref.watch(SharedPrefsController.provider).getString(key); + + Future set(String? value) async { + final prefs = ref.read(SharedPrefsController.provider); + state = .data(value); + + if (value == null) { + await prefs.remove(key); + } else { + await prefs.setString(key, 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/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 fae5433..0000000 --- a/lib/controllers/members_controller.dart +++ /dev/null @@ -1,22 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:matrix/matrix.dart"; - -class MembersController extends AsyncNotifier> { - final Room room; - MembersController(this.room); - - @override - Future> build() async => IList( - (await room.client.getMembersByRoom( - room.id, - notMembership: Membership.leave, - )) ?? - [], - ); - - static final provider = - AsyncNotifierProvider.family, 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/multi_provider.dart b/lib/controllers/multi_provider.dart new file mode 100644 index 0000000..ab37d07 --- /dev/null +++ b/lib/controllers/multi_provider.dart @@ -0,0 +1,20 @@ +import "dart:async"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +class MultiProviderController(final IList providers) + extends AsyncNotifier { + @override + Future build() => .wait( + providers.map((provider) => ref.watch(provider.future)), + eagerError: true, + ); + + static final provider = + AsyncNotifierProvider.family< + MultiProviderController, + void, + IList + >(MultiProviderController.new); +} 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/notification.dart b/lib/controllers/notification.dart new file mode 100644 index 0000000..47ecdf0 --- /dev/null +++ b/lib/controllers/notification.dart @@ -0,0 +1,281 @@ +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/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/controllers/notification_request.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 == null) return; + + if (int.tryParse(details.payload!) case final rowId?) { + navigatorKey.currentState?.popUntil((route) => route.isFirst); + ref + .read(SidebarPageController.provider.notifier) + .set(.notifications); + ref + .read(NotificationRequestController.provider.notifier) + .request(rowId); + } + }, + ); + } + + if (Platform.isLinux) { + const notificationChannel = MethodChannel("nexus/notifications"); + + notificationChannel.setMethodCallHandler((call) async { + if (call.method != "notificationClicked") { + return; + } + + if (int.tryParse(call.arguments) case int rowId) { + navigatorKey.currentState?.popUntil((route) => route.isFirst); + ref.read(SidebarPageController.provider.notifier).set(.notifications); + ref + .read(NotificationRequestController.provider.notifier) + .request(rowId); + } + }); + + ref.onDispose(() => notificationChannel.setMethodCallHandler(null)); + } + + return notifications; + } + + 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. + Future send({ + required int id, + required String title, + 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(HtmlEscapeMode.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( + presentSound: sound, + threadIdentifier: threadId, + ); + + await controller.show( + id: id, + title: title, + body: prefixedBody, + payload: payload, + notificationDetails: .new( + android: message == null + ? .new( + "general", + "General", + silent: !sound, + when: timestamp?.millisecondsSinceEpoch, + largeIcon: icon == null + ? null + : FilePathAndroidBitmap(icon.path), + styleInformation: BigTextStyleInformation(body), + ) + : .new( + "messages", + "Messages", + 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..d0d7bb5 --- /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)) + .selectAsync((data) => data?.rowId), + ), + ), + )).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/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..b3584c8 --- /dev/null +++ b/lib/controllers/reactions.dart @@ -0,0 +1,53 @@ +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/configs/reactions.dart"; +import "package:nexus/models/content/reaction.dart"; + +class ReactionsController(final ReactionsConfig config) + extends AsyncNotifier>> { + @override + Future>> build() async { + final eventInfo = ref.watch( + RoomsController.provider.select((value) { + final event = value[config.roomId]?.events[config.eventRowId]; + return event == null ? null : (event.eventId, event.reactions); + }), + ); + + final reactionEvents = eventInfo?.$2.isNotEmpty == true + ? await ref + .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?)) { + return acc.update( + key, + (list) => list.add(event.sender), + ifAbsent: () => .new([event.sender]), + ); + } + + return acc; + }) ?? + .new(); + } + + static final provider = + AsyncNotifierProvider.family< + ReactionsController, + IMap>, + ReactionsConfig + >(ReactionsController.new); +} diff --git a/lib/controllers/read_receipts.dart b/lib/controllers/read_receipts.dart new file mode 100644 index 0000000..15f76aa --- /dev/null +++ b/lib/controllers/read_receipts.dart @@ -0,0 +1,90 @@ +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, receipts: room.receipts), + 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(); + + return room.receipts.values.flattened + .where( + (receipt) => + receipt.userId != userId && + // Receipts in threads are for the thread, not the timeline + receipt.thread == "main", + ) + .map( + (receipt) => switch (indexes[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..8dd3e59 --- /dev/null +++ b/lib/controllers/recent_emoji.dart @@ -0,0 +1,57 @@ +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) => emoji.contains(recent.emoji))); + } + + 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..b8b688d --- /dev/null +++ b/lib/controllers/room_chat.dart @@ -0,0 +1,338 @@ +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/rooms.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/reaction.dart"; +import "package:nexus/models/direction.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/requests/redact_event.dart"; +import "package:nexus/models/relation_type.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}); + +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); + } + + final timeline = room.timeline + .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) + .map((e) => e.value) + .toIList() + .addAll(room.clientSticky) + .nonNulls + .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, + ); + } else { + final context = await client.getEventContext( + .new( + roomId: info.roomId, + eventId: room.events[info.contextualEvent!]!.eventId, + ), + ); + + // `before` is in reverse chronological order + final events = context.before.reversed + .toIList() + .add(context.event) + .addAll(context.after); + ref + .read(RoomsController.provider.notifier) + .storeEvents(events.addAll(context.relatedEvents)); + return .new( + timeline: .new(events.map((element) => element.rowId)), + hasMoreBackward: true, + hasMoreForward: true, + historicalData: .new(start: context.start, end: context.end), + ); + } + } + + 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; + } + + state = .loading(); + + final client = ref.read(ClientController.provider.notifier); + + 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( + IMap({ + info.roomId: Room( + events: IMap.fromIterable( + response.events.addAll(response.relatedEvents), + keyMapper: (event) => event.rowId, + valueMapper: (event) => event, + ), + hasMore: response.hasMore, + receipts: response.receipts, + timeline: IMap.fromIterable( + response.events, + keyMapper: (event) => event.timelineRowId, + valueMapper: (event) => event.rowId, + ), + ), + }), + ); + } else { + final paginationResponse = await client.paginateManual( + .new( + roomId: info.roomId, + direction: direction, + since: direction == .forward + ? chat!.historicalData!.end + : chat!.historicalData!.start, + ), + ); + + ref + .read(RoomsController.provider.notifier) + .storeEvents( + paginationResponse.events.addAll(paginationResponse.relatedEvents), + ); + + final eventRowIds = + (direction == .forward + ? paginationResponse.events + : paginationResponse.events.reversed) + .map((element) => element.rowId) + .toIList(); + + 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, + ), + ), + ); + } + } + + Future send( + String text, { + bool shouldMention = true, + required IList tags, + required RelationType relationType, + int? relatedEventRowId, + }) async { + Content? baseContent; + final relatedEvent = relatedEventRowId == null + ? null + : ref.read( + RoomsController.provider.select( + (rooms) => rooms[info.roomId]?.events[relatedEventRowId], + ), + ); + + if (relationType == .edit && relatedEventRowId != null) { + baseContent = relatedEvent?.content; + } else { + final provider = AttachmentController.provider(info.roomId); + baseContent = ref.read(provider)?.$2; + ref.invalidate(provider); + } + + var taggedMessage = text; + + 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.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: relatedEvent == null + ? null + : .new(eventId: relatedEvent.eventId, relationType: relationType), + ), + ); + + 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), + ); + } + } + + Future sendReaction(String reaction, Event event) async { + final client = ref.read(ClientController.provider.notifier); + + await client.sendEvent( + .new( + roomId: info.roomId, + type: EventType.reaction.type, + content: ReactionContent(key: reaction), + synchronous: true, + disableEncryption: true, + relatesTo: event.eventId, + relationType: "m.annotation", + ), + ); + } + + 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 1a1be39..0000000 --- a/lib/controllers/room_chat_controller.dart +++ /dev/null @@ -1,145 +0,0 @@ -import "package:collection/collection.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:matrix/matrix.dart"; -import "package:nexus/controllers/avatar_controller.dart"; -import "package:nexus/controllers/events_controller.dart"; -import "package:nexus/helpers/extensions/event_to_message.dart"; -import "package:nexus/helpers/extensions/list_to_messages.dart"; -import "package:fluttertagger/fluttertagger.dart" as tagger; -import "package:nexus/models/relation_type.dart"; - -class RoomChatController extends AsyncNotifier { - final Room room; - RoomChatController(this.room); - - @override - Future build() async { - final response = await ref.watch(EventsController.provider(room).future); - - ref.onDispose( - room.client.onTimelineEvent.stream.listen((event) async { - if (event.roomId != room.id) return; - - if (event.type == EventTypes.Redaction) { - final controller = await future; - final message = controller.messages.firstWhereOrNull( - (message) => message.id == event.redacts, - ); - if (message == null) return; - - await controller.removeMessage(message); - } else { - final message = await event.toMessage(includeEdits: true); - if (event.relationshipType == RelationshipTypes.edit) { - final controller = await future; - final oldMessage = controller.messages.firstWhereOrNull( - (element) => element.id == event.relationshipEventId, - ); - if (oldMessage == null || message == null) return; - return await updateMessage( - oldMessage, - message.copyWith(id: oldMessage.id), - ); - } - if (message != null) { - return await insertMessage(message); - } - } - }).cancel, - ); - - return InMemoryChatController( - messages: await response.chunk.toMessages(room), - ); - } - - 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 room.redactEvent(message.id, reason: reason); - } - - Future loadOlder() async { - final controller = await future; - final response = await ref - .watch(EventsController.provider(room).notifier) - .prev(); - - final messages = await response.chunk.toMessages(room); - - await controller.insertAllMessages(messages, index: 0); - ref.notifyListeners(); - } - - Future markRead() async { - if (!room.hasNewMessages) return; - final controller = await future; - final id = controller.messages.last.id; - - await room.setReadMarker(id, mRead: id); - } - - Future updateMessage(Message message, Message newMessage) async => - (await future).updateMessage(message, newMessage); - - Future send( - String message, { - 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)!, - ); - } - - await room.sendTextEvent( - taggedMessage, - editEventId: relationType == RelationType.edit ? relation?.id : null, - inReplyTo: (relationType == RelationType.reply && relation != null) - ? await room.getEventById(relation.id) - : null, - ); - } - - Future resolveUser(String id) async { - final user = await room.client.getUserProfile(id); - return chat.User( - id: id, - name: user.displayname, - imageSource: user.avatarUrl == null - ? null - : (await ref.watch( - AvatarController.provider(user.avatarUrl!.toString()).future, - )).toString(), - ); - } - - static final provider = AsyncNotifierProvider.family - .autoDispose( - RoomChatController.new, - ); -} diff --git a/lib/controllers/room_power_levels.dart b/lib/controllers/room_power_levels.dart new file mode 100644 index 0000000..e33c436 --- /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/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 = + (int.tryParse(create?.roomVersion ?? "") ?? 0) >= 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_settings_sections.dart b/lib/controllers/room_settings_sections.dart new file mode 100644 index 0000000..583f0f9 --- /dev/null +++ b/lib/controllers/room_settings_sections.dart @@ -0,0 +1,826 @@ +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/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_power_levels.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/space_edges.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.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/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/topic.dart"; +import "package:nexus/models/join_rule.dart"; +import "package:nexus/models/setting.dart"; +import "package:nexus/models/settings_category.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/divider_text.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/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"; + +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 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 roomVersion = switch (stateContent(.create)) { + CreateContent(:final roomVersion) => int.tryParse(roomVersion), + _ => null, + }; + bool versionAtLeast(int version) => + roomVersion == null || roomVersion >= version; + final joinRuleOptions = [ + JoinRule.public, + JoinRule.invite, + if (versionAtLeast(7)) JoinRule.knock, + if (versionAtLeast(8)) JoinRule.restricted, + if (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); + // Encrypted rooms send messages as m.room.encrypted, so that's what limits them + final messageType = stateContent(.encryption) is EncryptionContent + ? EventType.encrypted + : EventType.message; + + Future setState(EventType type, Content content) async { + final eventId = await client.setState( + .new(roomId: roomId, type: type.type, stateKey: "", content: content), + ); + if (eventId case final eventId?) { + await roomsController.awaitState(roomId, type.type, eventId); + } + } + + 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 = [ + messageType, + EventType.sticker, + EventType.reaction, + EventType.pinnedEvents, + ].map((type) => 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, + ), + ); + + /// A setting for the level needed to send [type], which otherwise needs [fallback]. + Setting eventSetting( + EventType type, { + required String title, + required String description, + required IconData icon, + required int fallback, + }) => powerLevelSetting( + title: title, + description: description, + icon: icon, + value: powerLevels.events[type.type] ?? fallback, + update: (level) => powerLevels.copyWith( + events: powerLevels.events.add(type.type, level), + ), + ); + + 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: "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) => switch (rule) { + .public => "Public", + .invite => "Invite only", + .knock => "Knock", + .restricted => "Restricted", + .knockRestricted => "Restricted or knock", + .private => "Private", + }, + 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", + }, + onChanged: canSetHistoryVisibility + ? (visibility) => setState( + .historyVisibility, + HistoryVisibilityContent( + historyVisibility: visibility, + ), + ).onError(showError) + : null, + ), + ), + ]), + ), + .new( + title: "Permissions", + icon: Icons.admin_panel_settings, + settings: .new([ + powerLevelSetting( + title: "Default Power Level", + description: "The power level of users who aren't given one.", + icon: Icons.person, + value: powerLevels.usersDefault, + update: (level) => powerLevels.copyWith(usersDefault: level), + ), + 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( + "Regular Events", + "Power levels needed to send messages and other timeline events.", + Icons.chat, + ), + eventSetting( + messageType, + title: "Send Messages", + description: "The power level needed to send messages.", + icon: Icons.message, + fallback: powerLevels.eventsDefault, + ), + eventSetting( + .sticker, + title: "Send Stickers", + description: "The power level needed to send stickers.", + icon: Icons.sticky_note_2, + fallback: powerLevels.eventsDefault, + ), + eventSetting( + .reaction, + title: "Send Reactions", + description: "The power level needed to react to messages.", + icon: Icons.add_reaction, + fallback: powerLevels.eventsDefault, + ), + powerLevelSetting( + title: "Default for Events", + description: + "The power level needed to send events without their own.", + icon: Icons.more_horiz, + value: powerLevels.eventsDefault, + update: (level) => powerLevels.copyWith(eventsDefault: level), + ), + header( + "State Events", + "Power levels needed to manage members and change the room's state.", + Icons.settings, + ), + eventSetting( + .pinnedEvents, + title: "Pin Messages", + description: "The power level needed to pin messages.", + icon: Icons.push_pin, + fallback: powerLevels.stateDefault, + ), + powerLevelSetting( + title: "Invite Users", + description: "The power level needed to invite users.", + icon: Icons.person_add, + value: powerLevels.invite, + update: (level) => powerLevels.copyWith(invite: level), + ), + powerLevelSetting( + title: "Kick Users", + description: "The power level needed to kick users.", + icon: Icons.sports_martial_arts, + value: powerLevels.kick, + update: (level) => powerLevels.copyWith(kick: level), + ), + powerLevelSetting( + title: "Ban Users", + description: "The power level needed to ban and unban users.", + icon: Icons.gavel, + value: powerLevels.ban, + update: (level) => powerLevels.copyWith(ban: level), + ), + powerLevelSetting( + title: "Delete Others' Messages", + description: "The power level needed to remove others' messages.", + icon: Icons.delete, + value: powerLevels.redact, + update: (level) => powerLevels.copyWith(redact: level), + ), + powerLevelSetting( + title: "Default for State Events", + description: "The power level needed to send state events without their own.", + icon: Icons.tune, + value: powerLevels.stateDefault, + update: (level) => powerLevels.copyWith(stateDefault: level), + ), + 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), + ), + ), + ), + ), + ]), + ), + ]), + }); + } + + 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..5370bcf --- /dev/null +++ b/lib/controllers/rooms.dart @@ -0,0 +1,161 @@ +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/read_receipt.dart"; +import "package:nexus/models/room.dart"; + +class RoomsController extends Notifier> { + @override + IMap build() => .new(); + + void addState( + String roomId, + IList state, { + bool isMembers = false, + }) => update( + .new({ + roomId: Room( + events: .fromEntries(state.map((event) => .new(event.rowId, event))), + hasFetchedState: true, + hasFetchedMembers: isMembers, + state: .new( + state + .groupListsBy((event) => event.type) + .map( + (type, group) => .new( + type, + .fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))), + ), + ), + ), + ), + }), + .new(), + ); + + 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 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), + 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), + ), + ); + }); + + 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 = "", + }) { + bool arrived(IMap rooms) => + switch (rooms[roomId]?.state[type]?[stateKey]) { + final rowId? => rooms[roomId]?.events[rowId]?.eventId == eventId, + null => false, + }; + 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 864d656..0000000 --- a/lib/controllers/rooms_controller.dart +++ /dev/null @@ -1,23 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/helpers/extensions/get_full_room.dart"; -import "package:nexus/models/full_room.dart"; - -class RoomsController extends AsyncNotifier> { - @override - Future> build() async { - final client = await ref.watch(ClientController.provider.future); - - ref.onDispose( - client.onSync.stream.listen((_) => ref.invalidateSelf()).cancel, - ); - - return IList(await Future.wait(client.rooms.map((room) => room.fullRoom))); - } - - static final provider = - AsyncNotifierProvider>( - RoomsController.new, - ); -} diff --git a/lib/controllers/secure_storage_controller.dart b/lib/controllers/secure_storage_controller.dart deleted file mode 100644 index 8a579f5..0000000 --- a/lib/controllers/secure_storage_controller.dart +++ /dev/null @@ -1,26 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:simple_secure_storage/simple_secure_storage.dart"; - -class SecureStorageController extends AsyncNotifier { - @override - Future build() => SimpleSecureStorage.initialize(); - - Future get(String key) async { - await future; - return SimpleSecureStorage.read(key); - } - - Future set(String key, String value) async { - await future; - return SimpleSecureStorage.write(key, value); - } - - Future clear() async { - await future; - return SimpleSecureStorage.clear(); - } - - static final provider = AsyncNotifierProvider( - SecureStorageController.new, - ); -} diff --git a/lib/controllers/selected_room.dart b/lib/controllers/selected_room.dart new file mode 100644 index 0000000..f59232c --- /dev/null +++ b/lib/controllers/selected_room.dart @@ -0,0 +1,31 @@ +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(), + ), + ); + 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 cfeead6..0000000 --- a/lib/controllers/selected_room_controller.dart +++ /dev/null @@ -1,25 +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/full_room.dart"; - -class SelectedRoomController extends AsyncNotifier { - @override - Future build() async { - final space = await ref.watch(SelectedSpaceController.provider.future); - final selectedRoomId = ref.watch( - KeyController.provider(KeyController.roomKey), - ); - - return space.children.firstWhereOrNull( - (room) => room.roomData.id == selectedRoomId, - ) ?? - space.children.firstOrNull; - } - - static final provider = - AsyncNotifierProvider( - 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 75bf287..0000000 --- a/lib/controllers/selected_space_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/spaces_controller.dart"; -import "package:nexus/models/space.dart"; - -class SelectedSpaceController extends AsyncNotifier { - @override - Future build() async { - final spaces = await ref.watch( - SpacesController.provider.selectAsync((data) => data), - ); - final selectedSpaceId = ref.watch( - KeyController.provider(KeyController.spaceKey), - ); - - return spaces.firstWhereOrNull((space) => space.id == selectedSpaceId) ?? - spaces.first; - } - - static final provider = AsyncNotifierProvider( - 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..2cd804c --- /dev/null +++ b/lib/controllers/settings_sections.dart @@ -0,0 +1,494 @@ +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:nexus/controllers/account_data.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/notification.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/models/account_data.dart"; +import "package:nexus/models/client_state.dart"; +import "package:nexus/models/room_order.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/settings/dialog_list_tile.dart"; +import "package:nexus/widgets/user_bottom_sheet.dart"; +import "package:nexus/widgets/reset_encryption_sheet.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; + +class SettingsSectionsController + extends AsyncNotifier>> { + @override + Future>> build() async { + final settings = await ref.watch(SettingsController.provider.future); + + /// 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), + ); + + 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: "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: "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: "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, + ), + ]), + ), + .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, + ), + ]), + ), + .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, + ), + .new( + title: "Log Out", + description: + "Log out of your account, returning you to the login page.", + builder: (title, description, icon) => Builder( + builder: (context) { + final colorScheme = Theme.of(context).colorScheme; + return M3EButton.icon( + onPressed: () async { + final container = ProviderScope.containerOf(context); + + final route = ModalRoute.of(context)!; + Navigator.of(context).popUntil((r) => r.isFirst); + await route.completed; + + await container + .read(UnifiedPushController.provider.notifier) + .deregister() + .onError(showError); + container.invalidate(InitCompleteController.provider); + await container + .read(ClientController.provider.notifier) + .logout() + .onError(showError); + }, + label: Text(title), + icon: Icon(icon), + tooltip: description, + decoration: .styleFrom( + backgroundColor: colorScheme.errorContainer, + foregroundColor: colorScheme.onErrorContainer, + ), + ); + }, + ), + icon: Icons.logout, + ), + ]), + ), + ]), + }); + } + + 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..5598691 --- /dev/null +++ b/lib/controllers/sidebar_page.dart @@ -0,0 +1,13 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/models/sidebar_page.dart"; + +class SidebarPageController extends Notifier { + @override + SidebarPage build() => .rooms; + + void set(SidebarPage page) => state = page; + + static final provider = NotifierProvider( + SidebarPageController.new, + ); +} diff --git a/lib/controllers/space_edges.dart b/lib/controllers/space_edges.dart new file mode 100644 index 0000000..81347c5 --- /dev/null +++ b/lib/controllers/space_edges.dart @@ -0,0 +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"; + +class SpaceEdgesController extends Notifier>> { + @override + IMap> build() => .new(); + + void set(IMap> newEdges) => + state = state.addAll(newEdges); + + static final provider = + NotifierProvider>>( + SpaceEdgesController.new, + ); +} diff --git a/lib/controllers/space_hierarchy.dart b/lib/controllers/space_hierarchy.dart new file mode 100644 index 0000000..8a5aa9d --- /dev/null +++ b/lib/controllers/space_hierarchy.dart @@ -0,0 +1,34 @@ +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, + ), + ); + } + + 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..8af045c --- /dev/null +++ b/lib/controllers/spaces.dart @@ -0,0 +1,176 @@ +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()), + ); + + Set collectDescendants(String startId) { + final visited = {}; + final stack = [startId]; + + while (stack.isNotEmpty) { + final current = stack.removeLast(); + final children = childrenById[current] ?? const []; + + for (final child in children) { + if (visited.add(child)) { + stack.add(child); + } + } + } + + return visited; + } + + Space buildSpace(String spaceId) { + final space = rooms[spaceId]; + final directChildrenIds = childrenById[spaceId] ?? const []; + + final directRooms = []; + final subSpaces = []; + + for (final childId in directChildrenIds) { + final room = rooms[childId]; + if (room == null) continue; + + if (childrenById.containsKey(childId)) { + final descendants = collectDescendants(childId); + + subSpaces.add( + .new( + room: room, + children: .new(descendants.map((id) => rooms[id]).nonNulls), + ), + ); + } else { + directRooms.add(room); + } + } + + return .new( + id: spaceId, + room: space, + title: space?.metadata?.name ?? "Unnamed Space", + children: .new(directRooms), + subSpaces: .new(subSpaces), + ); + } + + final spaces = topLevelSpaceIds + .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 3501de6..0000000 --- a/lib/controllers/spaces_controller.dart +++ /dev/null @@ -1,77 +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/helpers/extensions/get_full_room.dart"; -import "package:nexus/helpers/extensions/room_to_children.dart"; -import "package:nexus/models/space.dart"; - -class SpacesController extends AsyncNotifier> { - @override - Future> build() async { - final client = await ref.watch(ClientController.provider.future); - - ref.onDispose( - client.onSync.stream.listen((_) => ref.invalidateSelf()).cancel, - ); - - final topLevel = IList( - await Future.wait( - client.rooms - .where((room) => !room.isDirectChat) - .where( - (room) => client.rooms - .where((room) => room.isSpace) - .every( - (match) => match.spaceChildren.every( - (child) => child.roomId != room.id, - ), - ), - ) - .map((room) => room.fullRoom), - ), - ); - - final topLevelSpaces = topLevel.where((r) => r.roomData.isSpace).toIList(); - final topLevelRooms = topLevel.where((r) => !r.roomData.isSpace).toIList(); - - return IList([ - Space( - client: client, - title: "Home", - id: "home", - children: topLevelRooms, - icon: Icons.home, - ), - Space( - client: client, - title: "Direct Messages", - id: "dms", - children: IList( - await Future.wait( - client.rooms - .where((room) => room.isDirectChat) - .map((room) => room.fullRoom), - ), - ), - icon: Icons.person, - ), - ...(await Future.wait( - topLevelSpaces.map( - (space) async => Space( - client: client, - title: space.title, - avatar: space.avatar, - id: space.roomData.id, - roomData: space.roomData, - children: IList(await space.roomData.getAllChildren(client)), - ), - ), - )), - ]); - } - - static final provider = AsyncNotifierProvider>( - 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.dart b/lib/controllers/sync_status.dart new file mode 100644 index 0000000..256c8e2 --- /dev/null +++ b/lib/controllers/sync_status.dart @@ -0,0 +1,19 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/sync_status.dart"; + +class SyncStatusController extends Notifier { + @override + Null build() => null; + + void set(SyncStatus newStatus) { + if (newStatus.type == .permanentlyFailed) { + showError(newStatus.error ?? "Syncing failed"); + } + state = newStatus; + } + + static final provider = NotifierProvider( + SyncStatusController.new, + ); +} diff --git a/lib/controllers/thumbnail_controller.dart b/lib/controllers/thumbnail_controller.dart deleted file mode 100644 index 4500523..0000000 --- a/lib/controllers/thumbnail_controller.dart +++ /dev/null @@ -1,22 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:matrix/matrix.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/models/image_data.dart"; - -class ThumbnailController extends AsyncNotifier { - ThumbnailController(this.data); - final ImageData data; - - @override - Future build({String? from}) async { - final client = await ref.watch(ClientController.provider.future); - final uri = await Uri.tryParse(data.uri)?.getDownloadUri(client); - - return uri.toString(); - } - - static final provider = AsyncNotifierProvider.family - .autoDispose( - ThumbnailController.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/top_level_spaces.dart b/lib/controllers/top_level_spaces.dart new file mode 100644 index 0000000..321e29d --- /dev/null +++ b/lib/controllers/top_level_spaces.dart @@ -0,0 +1,14 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +class TopLevelSpacesController extends Notifier> { + @override + IList build() => .new(); + + void set(IList newSpaces) => state = newSpaces; + + static final provider = + NotifierProvider>( + TopLevelSpacesController.new, + ); +} diff --git a/lib/controllers/typing.dart b/lib/controllers/typing.dart new file mode 100644 index 0000000..d1f3d28 --- /dev/null +++ b/lib/controllers/typing.dart @@ -0,0 +1,15 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.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); + + 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..40c4666 --- /dev/null +++ b/lib/controllers/unified_push.dart @@ -0,0 +1,260 @@ +import "dart:async"; +import "dart:convert"; +import "dart:io"; + +import "package:flutter/foundation.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:intl/intl.dart"; +import "package:nexus/controllers/selected_room.dart"; +import "package:nexus/controllers/notification.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; + + @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 ref + .read(ClientController.provider.notifier) + .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 notification = await ref + .read(ClientController.provider.notifier) + .handlePush(json.decode(String.fromCharCodes(message.content))); + + if (notification == null || + (!isInBackground && + (!Platform.isAndroid && + (WindowManager.instance.getCurrent()?.isFocused ?? true)) && + ref.read(SelectedRoomController.provider) == + notification.roomId)) { + if (isInBackground) exit(0); + return; + } + + final client = ref.read(ClientController.provider.notifier); + + 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 + id: + (Platform.isAndroid + ? notification.roomId + : notification.eventId) + .hashCode & + 0x7fffffff, + threadId: notification.roomId, + title: notification.roomName, + body: notification.text, + payload: notification.eventRowId.toString(), + 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; + + try { + if (await deregisterIfDisabled()) return null; + + return await register(); + } catch (error, stackTrace) { + showError(error, stackTrace); + 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(); + } + + state = .loading(); + try { + final deviceId = await ref + .read(GomuksListenerController.provider.notifier) + .deviceId(); + + await Future(() async { + final capabilities = await ref + .read(ClientController.provider.notifier) + .getCapabilities(); + + 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); + + if (key != null) { + await ref + .read(ClientController.provider.notifier) + .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); + 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..03ce3b0 --- /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/client.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 + .read(ClientController.provider.notifier) + .getCapabilities(); + + 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..39db682 --- /dev/null +++ b/lib/controllers/via.dart @@ -0,0 +1,60 @@ +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/models/content/content.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/room.dart"; + +class ViaController(final Room room) extends Notifier { + @override + String build() { + final servers = {}; + + void addUserId(String? userId) { + final server = userId?.split(":").lastOrNull; + if (server != null) { + servers.add(server); + } + } + + addUserId(ref.watch(ClientStateController.provider)?.userId); + + final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""]; + final powerLevels = powerLevelsEventId == null + ? null + : room.events[powerLevelsEventId]; + + if (powerLevels?.content case PowerLevelsContent(:final users)) { + for (final userId in users.keys) { + addUserId(userId); + if (servers.length >= 5) break; + } + } + + final members = room.state[EventType.membership.type]?.values.toIList(); + for (var i = 0; servers.length < 5; i++) { + final membershipEventId = members?.getOrNull(i); + final member = membershipEventId == null + ? null + : room.events[membershipEventId]; + + if (member?.content case MembershipContent(:final status)) { + if (status == .join) { + addUserId(member?.stateKey); + } + } + + if (members?.getOrNull(i) == null) break; + } + + return servers.isEmpty + ? "" + : "?${servers.map((server) => "via=$server").join("&")}"; + } + + static final provider = NotifierProvider.family( + ViaController.new, + ); +} diff --git a/lib/helpers/extensions/better_when.dart b/lib/helpers/extensions/better_when.dart index beaae85..e054d02 100644 --- a/lib/helpers/extensions/better_when.dart +++ b/lib/helpers/extensions/better_when.dart @@ -8,10 +8,12 @@ extension BetterWhen on AsyncValue { 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..72ca2d2 --- /dev/null +++ b/lib/helpers/extensions/build_event_options.dart @@ -0,0 +1,286 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/sheet_layout.dart"; +import "package:flutter/services.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/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/helpers/extensions/is_desktop.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.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_picker.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, + }) { + final theme = Theme.of(context); + final danger = theme.colorScheme.error; + final isDesktop = context.isDesktop; + + final notifier = RoomChatController.provider(( + roomId: roomId, + contextualEvent: null, + )).notifier; + + final isPinned = ref + .read(PinnedIdsController.provider(roomId)) + .contains(eventId); + + 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)"), + ), + ], + ); + }, + ), + ); + } + + 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: [ + ...{ + ...ref + .read(RecentEmojiController.provider) + .map((entry) => entry.emoji), + "👍", + "🤣", + "😭", + "🤔", + "❤️", + } + .take(isDesktop ? 3 : 5) + .map( + (emoji) => IconButton( + icon: 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( + allowFreeText: true, + onSelection: (emoji) { + Navigator.of(context).pop(); + ref + .read(notifier) + .sendReaction(emoji, this) + .onError(showError); + }, + ), + ); + }, + ), + ], + ), + ), + ), + + if (ref.read( + PowerLevelController.provider( + .new(eventType: .message, roomId: roomId), + ), + )) + PopupMenuItem( + onTap: () => onRelation(rowId, .reply), + child: const ListTile( + leading: Icon(Icons.reply), + title: Text("Reply"), + ), + ), + + if (content is MessageContent && sender == userId) + 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)); + + 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), + ), + ) && + !eventId.isFake) + 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)), + ), + ), + + if (eventId.isFake) + PopupMenuItem( + onTap: () => ref.read(notifier).hideClientSticky(rowId), + child: ListTile( + leading: Icon(Icons.delete, color: danger), + title: Text("Hide", 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/color_hex.dart b/lib/helpers/extensions/color_hex.dart deleted file mode 100644 index 3f04629..0000000 --- a/lib/helpers/extensions/color_hex.dart +++ /dev/null @@ -1,8 +0,0 @@ -import "package:flutter/widgets.dart"; - -extension ColorHex on Color { - String get hex { - final rgb = toARGB32() & 0x00FFFFFF; - return "#${rgb.toRadixString(16).padLeft(6, "0")}"; - } -} 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/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/event_to_message.dart b/lib/helpers/extensions/event_to_message.dart deleted file mode 100644 index c003180..0000000 --- a/lib/helpers/extensions/event_to_message.dart +++ /dev/null @@ -1,134 +0,0 @@ -import "package:collection/collection.dart"; -import "package:flutter/foundation.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:matrix/matrix.dart"; - -extension EventToMessage on Event { - Future toMessage({ - bool mustBeText = false, - bool includeEdits = false, - }) async { - final replyId = inReplyToEventId(); - - final newEvent = (unsigned?["m.relations"] as Map?)?["m.replace"]; - final event = newEvent == null ? this : Event.fromJson(newEvent, room); - - final replyEvent = replyId == null - ? null - : await room.getEventById(replyId); - - final sender = - await event.fetchSenderUser() ?? event.senderFromMemoryOrFallback; - final newContent = event.content["m.new_content"] as Map?; - final metadata = { - "formatted": - newContent?["formatted_body"] ?? - newContent?["body"] ?? - event.content["formatted_body"] ?? - event.content["body"] ?? - "", - "reply": await replyEvent?.toMessage(mustBeText: true), - "body": newContent?["body"] ?? event.content["body"], - "eventType": event.type, - "avatarUrl": sender.avatarUrl.toString(), - "displayName": sender.displayName ?? sender.id, - "txnId": transactionId, - }; - - final editedAt = event.relationshipType == RelationshipTypes.edit - ? event.originServerTs - : null; - - if ((redacted && !mustBeText) || - (!includeEdits && (relationshipType == RelationshipTypes.edit))) { - return null; - } - - // TODO: Use server-generated preview if enabled when https://github.com/famedly/matrix-dart-sdk/issues/2195 is fixed. - - // final match = Uri.tryParse( - // RegExp(regexLink, caseSensitive: false).firstMatch(body)?.group(0) ?? "", - // ); - - // final preview = match == null - // ? null - // : await room.client.getUrlPreview(match); - - final asText = - Message.text( - metadata: metadata, - id: eventId, - authorId: senderId, - text: redacted ? "This message has been deleted..." : event.body, - replyToMessageId: replyId, - deliveredAt: originServerTs, - editedAt: editedAt, - ) - as TextMessage; - - if (mustBeText) return asText; - - return switch (type) { - EventTypes.Encrypted => asText.copyWith( - text: "Unable to decrypt message.", - metadata: {...metadata, "formatted": "Unable to decrypt message."}, - ), - (EventTypes.Sticker || EventTypes.Message) => switch (messageType) { - (MessageTypes.Sticker || MessageTypes.Image) => Message.image( - metadata: metadata, - id: eventId, - authorId: senderId, - text: event.text, - source: (await getAttachmentUri()).toString(), - replyToMessageId: replyId, - deliveredAt: originServerTs, - ), - MessageTypes.Audio => Message.audio( - metadata: metadata, - id: eventId, - authorId: senderId, - text: event.text, - replyToMessageId: replyId, - source: (await event.getAttachmentUri()).toString(), - deliveredAt: originServerTs, - // TODO: See if we can figure out duration - duration: Duration(hours: 1), - ), - MessageTypes.File => Message.file( - name: event.content["filename"].toString(), - metadata: metadata, - id: eventId, - authorId: senderId, - source: (await event.getAttachmentUri()).toString(), - replyToMessageId: replyId, - deliveredAt: originServerTs, - ), - _ => asText, - }, - EventTypes.RoomMember => Message.system( - metadata: metadata, - id: eventId, - authorId: senderId, - text: - "${event.asUser.displayName ?? event.asUser.id} ${switch (Membership.values.firstWhereOrNull((membership) => membership.name == event.content["membership"])) { - Membership.invite => "was invited to", - Membership.join => "joined", - Membership.leave => "left", - Membership.knock => "asked to join", - Membership.ban => "was banned from", - _ => "did something relating to", - }} the room.", - ), - EventTypes.Redaction => null, - _ => - kDebugMode - ? Message.unsupported( - metadata: metadata, - id: eventId, - authorId: senderId, - replyToMessageId: replyId, - ) - : null, - }; - } -} 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_full_room.dart b/lib/helpers/extensions/get_full_room.dart deleted file mode 100644 index bbd0bc5..0000000 --- a/lib/helpers/extensions/get_full_room.dart +++ /dev/null @@ -1,13 +0,0 @@ -import "package:matrix/matrix.dart"; -import "package:nexus/models/full_room.dart"; - -extension GetFullRoom on Room { - Future get fullRoom async { - await loadHeroUsers(); - return FullRoom( - roomData: this, - title: getLocalizedDisplayname(), - avatar: await avatar?.getThumbnailUri(client, width: 24, height: 24), - ); - } -} diff --git a/lib/helpers/extensions/get_headers.dart b/lib/helpers/extensions/get_headers.dart deleted file mode 100644 index b8b1fde..0000000 --- a/lib/helpers/extensions/get_headers.dart +++ /dev/null @@ -1,5 +0,0 @@ -import "package:matrix/matrix.dart"; - -extension GetHeaders on Client { - Map get headers => {"authorization": "Bearer $accessToken"}; -} 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..71e9808 --- /dev/null +++ b/lib/helpers/extensions/get_localpart.dart @@ -0,0 +1,11 @@ +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); + + 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/global_rect.dart b/lib/helpers/extensions/global_rect.dart new file mode 100644 index 0000000..91c8305 --- /dev/null +++ b/lib/helpers/extensions/global_rect.dart @@ -0,0 +1,15 @@ +import "package:material_ui/material_ui.dart"; + +extension GlobalRect on BuildContext { + /// Where the widget this context belongs to is on screen, e.g. to open a + /// menu or popover next to it. + Rect get globalRect { + final overlay = Overlay.of(this).context.findRenderObject() as RenderBox; + + return switch (findRenderObject()) { + final RenderBox box => + box.localToGlobal(.zero, ancestor: overlay) & box.size, + _ => Rect.zero, + }; + } +} diff --git a/lib/helpers/extensions/gomuks_buffer.dart b/lib/helpers/extensions/gomuks_buffer.dart new file mode 100644 index 0000000..57425be --- /dev/null +++ b/lib/helpers/extensions/gomuks_buffer.dart @@ -0,0 +1,39 @@ +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 toBytesAndFree() { + try { + if (base == nullptr || length <= 0) return .new(0); + return .fromList(base.asTypedList(length)); + } finally { + GomuksFreeBuffer(this); + } + } + + dynamic toJsonAndFree() => jsonDecode(utf8.decode(toBytesAndFree())); +} + +extension JsonToGomuksBuffer on Map { + GomuksOwnedBuffer toGomuksOwnedBuffer() { + final bytes = utf8.encode(json.encode(this)); + final buffer = GomuksAllocBuffer(bytes.length); + buffer.base.asTypedList(bytes.length).setAll(0, bytes); + return buffer; + } + + Pointer toGomuksBufferPtr() { + final bytes = utf8.encode(json.encode(this)); + + final dataPtr = calloc(bytes.length); + dataPtr.asTypedList(bytes.length).setAll(0, bytes); + + return calloc() + ..ref.base = dataPtr + ..ref.length = bytes.length; + } +} diff --git a/lib/helpers/extensions/is_desktop.dart b/lib/helpers/extensions/is_desktop.dart new file mode 100644 index 0000000..d4161dd --- /dev/null +++ b/lib/helpers/extensions/is_desktop.dart @@ -0,0 +1,7 @@ +import "package:material_ui/material_ui.dart"; + +extension IsDesktop on BuildContext { + /// Whether the window is wide enough for the desktop layout, with the + /// sidebar always visible, menus instead of bottom sheets, etc. + bool get isDesktop => MediaQuery.sizeOf(this).width > 650; +} 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..a78810b --- /dev/null +++ b/lib/helpers/extensions/is_shown_as_message.dart @@ -0,0 +1,15 @@ +import "package:nexus/models/content/encrypted.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/event.dart"; + +extension IsShownAsMessage on Event { + /// Whether this is shown in the timeline as a message, with MessageRenderer. + bool get isShownAsMessage => + redactedBy == null && + relationType != "m.replace" && + switch (content) { + MessageContent() || EncryptedContent() || StickerContent() => true, + _ => false, + }; +} diff --git a/lib/helpers/extensions/join_room.dart b/lib/helpers/extensions/join_room.dart new file mode 100644 index 0000000..df939c1 --- /dev/null +++ b/lib/helpers/extensions/join_room.dart @@ -0,0 +1,68 @@ +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 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 { + 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/list_to_messages.dart b/lib/helpers/extensions/list_to_messages.dart deleted file mode 100644 index c14618b..0000000 --- a/lib/helpers/extensions/list_to_messages.dart +++ /dev/null @@ -1,9 +0,0 @@ -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:matrix/matrix.dart"; -import "package:nexus/helpers/extensions/event_to_message.dart"; - -extension ListToMessages on List { - Future> toMessages(Room room) async => (await Future.wait( - map((event) => Event.fromMatrixEvent(event, room).toMessage()), - )).nonNulls.toList().reversed.toList(); -} diff --git a/lib/helpers/extensions/room_destinations.dart b/lib/helpers/extensions/room_destinations.dart new file mode 100644 index 0000000..59bb3d2 --- /dev/null +++ b/lib/helpers/extensions/room_destinations.dart @@ -0,0 +1,23 @@ +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/avatar_or_hash.dart"; + +extension RoomDestinations on IList { + List toDestinations({required bool isDm}) => + map( + (room) => NavigationRailM3EDestination( + label: room.metadata?.name ?? "Unnamed Room", + badgeCount: switch (room.metadata?.unreadNotifications) { + 0 || null => room.metadata?.unreadMessages == 0 ? null : 0, + int unread => unread, + }, + icon: AvatarOrHash( + room.metadata?.avatar, + room.metadata?.name ?? "Unnamed Room", + fallback: isDm ? null : const Icon(Icons.numbers), + ), + ), + ).toList(); +} diff --git a/lib/helpers/extensions/room_to_children.dart b/lib/helpers/extensions/room_to_children.dart deleted file mode 100644 index afdc99e..0000000 --- a/lib/helpers/extensions/room_to_children.dart +++ /dev/null @@ -1,27 +0,0 @@ -import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:matrix/matrix.dart"; -import "package:nexus/helpers/extensions/get_full_room.dart"; -import "package:nexus/models/full_room.dart"; - -extension RoomToChildren on Room { - Future> getAllChildren(Client client) async { - final direct = await Future.wait( - spaceChildren - .map( - (child) => client.rooms - .firstWhereOrNull((r) => r.id == child.roomId) - ?.fullRoom, - ) - .nonNulls, - ); - - return (await Future.wait( - direct.map( - (child) async => child.roomData.isSpace - ? await child.roomData.getAllChildren(client) - : [child], - ), - )).expand((list) => list).toIList(); - } -} 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 e238cf9..97d239d 100644 --- a/lib/helpers/extensions/scheme_to_theme.dart +++ b/lib/helpers/extensions/scheme_to_theme.dart @@ -1,14 +1,45 @@ -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, - ), - inputDecorationTheme: const InputDecorationTheme( - border: OutlineInputBorder(), - ), - ); + 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; + return .from(colorScheme: this).copyWith( + cardTheme: .new(color: primaryContainer), + badgeTheme: .new(backgroundColor: primary, textColor: onPrimary), + popupMenuTheme: .new( + shape: RoundedRectangleBorder(borderRadius: .circular(16)), + color: surfaceContainerHigh, + ), + 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/show_about_sheet.dart b/lib/helpers/extensions/show_about_sheet.dart new file mode 100644 index 0000000..ee49283 --- /dev/null +++ b/lib/helpers/extensions/show_about_sheet.dart @@ -0,0 +1,90 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/widgets/sheet_layout.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: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_adaptive_menu.dart b/lib/helpers/extensions/show_adaptive_menu.dart new file mode 100644 index 0000000..2fe21f5 --- /dev/null +++ b/lib/helpers/extensions/show_adaptive_menu.dart @@ -0,0 +1,52 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/global_rect.dart"; +import "package:nexus/helpers/extensions/is_desktop.dart"; + +extension ShowAdaptiveMenu on BuildContext { + /// Shows [items] in a popup menu on desktop, or a bottom sheet on mobile. + /// + /// On desktop, the menu opens at [globalPosition] if given (e.g. where the + /// user right clicked), or over the widget this context belongs to. + Future showAdaptiveMenu( + List items, { + Offset? globalPosition, + }) async { + if (!isDesktop) { + await showModalBottomSheet( + context: this, + isScrollControlled: true, + showDragHandle: true, + builder: (context) => SafeArea( + child: SingleChildScrollView( + padding: const .only(bottom: 8), + // PopupMenuItems must be inside something with the menu role + child: Semantics( + role: .menu, + explicitChildNodes: true, + child: ListTileTheme.merge( + contentPadding: const .symmetric(horizontal: 24), + minTileHeight: 48, + titleTextStyle: Theme.of(context).textTheme.titleMedium, + child: Column(mainAxisSize: .min, children: items), + ), + ), + ), + ), + ); + return; + } + + final overlay = Overlay.of(this).context.findRenderObject() as RenderBox; + final anchor = switch (globalPosition) { + final position? => position & Size.zero, + null => globalRect, + }; + + await showMenu( + context: this, + constraints: .loose(.infinite), + position: .fromRect(anchor, Offset.zero & overlay.size), + items: items, + ); + } +} diff --git a/lib/helpers/extensions/show_adaptive_popover.dart b/lib/helpers/extensions/show_adaptive_popover.dart new file mode 100644 index 0000000..8a100e8 --- /dev/null +++ b/lib/helpers/extensions/show_adaptive_popover.dart @@ -0,0 +1,35 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/global_rect.dart"; +import "package:nexus/helpers/extensions/is_desktop.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/widgets/popover_layout.dart"; + +extension ShowAdaptivePopover on BuildContext { + /// Shows the widget from [builder] in a popover next to the widget this + /// context belongs to on desktop, or a bottom sheet on mobile. Use the + /// context passed to [builder] to close it. + Future showAdaptivePopover(WidgetBuilder builder) { + if (!isDesktop) return showAdaptiveSheet(builder); + + final anchor = globalRect; + + return showDialog( + context: this, + barrierColor: Colors.transparent, + useSafeArea: false, + builder: (context) => CustomSingleChildLayout( + delegate: PopoverLayout(anchor), + child: ConstrainedBox( + constraints: const .new(maxWidth: 420, maxHeight: 600), + child: Material( + elevation: 8, + clipBehavior: .antiAlias, + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: .circular(16), + child: Builder(builder: builder), + ), + ), + ), + ); + } +} diff --git a/lib/helpers/extensions/show_adaptive_sheet.dart b/lib/helpers/extensions/show_adaptive_sheet.dart new file mode 100644 index 0000000..1e916a2 --- /dev/null +++ b/lib/helpers/extensions/show_adaptive_sheet.dart @@ -0,0 +1,24 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/is_desktop.dart"; + +extension ShowAdaptiveSheet on BuildContext { + /// Shows the widget from [builder] in a dialog on desktop, or a bottom sheet + /// on mobile. Use the context passed to [builder] to close it. + Future showAdaptiveSheet(WidgetBuilder builder) => isDesktop + ? showDialog( + context: this, + builder: (context) => Dialog( + clipBehavior: .antiAlias, + child: ConstrainedBox( + constraints: const .new(maxWidth: 450, maxHeight: 600), + child: Builder(builder: builder), + ), + ), + ) + : showModalBottomSheet( + context: this, + isScrollControlled: true, + constraints: .new(maxHeight: MediaQuery.sizeOf(this).height - 80), + builder: (context) => SafeArea(child: builder(context)), + ); +} 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_leave_room_sheet.dart b/lib/helpers/extensions/show_leave_room_sheet.dart new file mode 100644 index 0000000..7dd8d3a --- /dev/null +++ b/lib/helpers/extensions/show_leave_room_sheet.dart @@ -0,0 +1,38 @@ +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_adaptive_sheet.dart"; +import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; +import "package:nexus/widgets/sheet_layout.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..a56417e --- /dev/null +++ b/lib/helpers/extensions/show_user_popover.dart @@ -0,0 +1,8 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/show_adaptive_popover.dart"; +import "package:nexus/widgets/user_bottom_sheet.dart"; + +extension ShowUserPopover on BuildContext { + void showUserPopover(String userId, {String? roomId}) => + showAdaptivePopover((context) => UserBottomSheet(userId, roomId: roomId)); +} diff --git a/lib/helpers/extensions/size_to_string.dart b/lib/helpers/extensions/size_to_string.dart new file mode 100644 index 0000000..a9db345 --- /dev/null +++ b/lib/helpers/extensions/size_to_string.dart @@ -0,0 +1,15 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +extension SizeToString on int { + String get sizeAsString { + const suffixes = IListConst(["B", "KB", "MB", "GB", "TB", "PB"]); + + var i = 0; + var size = toDouble(); + while (size > 1024 && i < suffixes.length - 1) { + size /= 1024; + i++; + } + return "${size.toStringAsFixed(2)} ${suffixes[i]}"; + } +} diff --git a/lib/helpers/extensions/string_to_color.dart b/lib/helpers/extensions/string_to_color.dart new file mode 100644 index 0000000..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/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_scroll.dart b/lib/helpers/hooks/chat_scroll.dart new file mode 100644 index 0000000..50d9a32 --- /dev/null +++ b/lib/helpers/hooks/chat_scroll.dart @@ -0,0 +1,225 @@ +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 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 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); + + if (anchorIndex == -1) return (history: const .empty(), live: items); + + return ( + history: items.take(anchorIndex).toIList().reversed.toIList(), + live: items.skip(anchorIndex).toIList(), + ); + }, [controllerData, anchorId.value]); + + final lastRowId = split.live.lastOrNull; + final previousLastRowId = useRef(null); + + 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; + + scrollController.jumpTo(scrollController.position.minScrollExtent); + }); + + return null; + }, [lastRowId]); + + useEffect( + () { + const loadThreshold = 500.0; + + Future checkPosition() async { + if (!scrollController.hasClients) return; + + final position = scrollController.position; + + final isAtBottom = position.extentBefore <= 0; + if (isAtBottom != atBottom.value) atBottom.value = isAtBottom; + + // 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); + } else if (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, + ], + ); + + return .new( + historyRows: split.history, + liveRows: split.live, + centerKey: centerKey, + scrollController: scrollController, + atBottom: atBottom.value, + keyFor: keyFor, + jumpToEvent: (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; + }, + 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/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/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 8cf4365..5cd2129 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,21 +1,33 @@ +import "dart:io"; + +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/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/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/settings_page.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/appbar.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; import "package:window_manager/window_manager.dart"; -import "package:flutter/material.dart"; -import "package:dynamic_system_colors/dynamic_system_colors.dart"; -import "package:window_size/window_size.dart"; +import "package:material_ui/material_ui.dart"; final GlobalKey navigatorKey = GlobalKey(); +late final bool isInBackground; final class Logger extends ProviderObserver { @override @@ -28,13 +40,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("UTF-16")) 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) { @@ -49,85 +61,154 @@ 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), - ); + LicenseRegistry.addLicense(() => .fromIterable(fontLicenses)); FlutterError.onError = (FlutterErrorDetails details) => showError(details.exception.toString(), details.stack); - setWindowMinSize(const Size.square(500)); + isInBackground = + Platform.environment["FLUTTER_HEADLESS"] != null || + args.contains("--unifiedpush-bg"); - runApp( - ProviderScope( - observers: [ - // Change false to true if you want debug information on provider reloads - // ignore: dead_code - if (false && kDebugMode) Logger(), - ], - child: const App(), - ), - ); + if (isInBackground) { + await ProviderContainer() + .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(300).toNative(); + } + + runApp( + ProviderScope( + 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 ConsumerWidget { - const App({super.key}); - +class const App({super.key}) extends StatelessWidget { @override - Widget build(BuildContext context, WidgetRef ref) => 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: Builder( - builder: (context) => ref - .watch(SharedPrefsController.provider) - .betterWhen( - data: (_) => ref - .watch(ClientController.provider) - .betterWhen( - data: (client) => - client.accessToken == null ? LoginPage() : ChatPage(), - loading: () => Scaffold( - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 16, - children: [ - Text( - "Syncing...", - style: Theme.of(context).textTheme.headlineMedium, - ), - Loading(), - ], - ), - ), - appBar: Appbar( - actions: [ - IconButton( - onPressed: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => SettingsPage()), - ), - icon: Icon(Icons.settings), - ), - ], + Widget build(BuildContext context) => DynamicColorBuilder( + 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: () => ThemeMode.system, + ), + home: child, + ); + }, + child: DefaultTextHeightBehavior( + textHeightBehavior: .new(leadingDistribution: .even), + child: Scaffold( + body: Consumer( + builder: (context, ref, _) => switch (ref.watch( + MultiProviderController.provider( + .new([ + GomuksListenerController.provider, + NotificationController.provider, + UnifiedPushController.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), ), - ), - ), + ]), + ), + body: Loading(), + ), + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + provider: ClientController.provider, + ), + }, + ), + ), ), ), ); diff --git a/lib/models/account_data.dart b/lib/models/account_data.dart new file mode 100644 index 0000000..0ab5990 --- /dev/null +++ b/lib/models/account_data.dart @@ -0,0 +1,69 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "account_data.freezed.dart"; +part "account_data.g.dart"; + +@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(), +}) with _$AccountData { + static const invitePermissionConfigKey = "m.invite_permission_config"; + static const directKey = "m.direct"; + static const recentEmojiKey = "m.recent_emoji"; + + 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 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/capabilities.dart b/lib/models/capabilities.dart new file mode 100644 index 0000000..6830170 --- /dev/null +++ b/lib/models/capabilities.dart @@ -0,0 +1,25 @@ +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, +}) 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); +} diff --git a/lib/models/client_state.dart b/lib/models/client_state.dart new file mode 100644 index 0000000..38f1e5b --- /dev/null +++ b/lib/models/client_state.dart @@ -0,0 +1,34 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "client_state.freezed.dart"; +part "client_state.g.dart"; + +@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/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/reactions.dart b/lib/models/configs/reactions.dart new file mode 100644 index 0000000..b9de793 --- /dev/null +++ b/lib/models/configs/reactions.dart @@ -0,0 +1,16 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "reactions.freezed.dart"; +part "reactions.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ReactionsConfig({ + required final String roomId, + required final int eventRowId, +}) with _$ReactionsConfig { + Map toJson() => _$ReactionsConfigToJson(this); + + factory ReactionsConfig.fromJson(Map json) => + _$ReactionsConfigFromJson(json); +} 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..25167e9 --- /dev/null +++ b/lib/models/content/avatar.dart @@ -0,0 +1,14 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/info/image.dart"; +part "avatar.freezed.dart"; +part "avatar.g.dart"; + +@freezed +sealed class AvatarContent extends Content with _$AvatarContent { + AvatarContent._(); + factory AvatarContent({ImageInfo? info, Uri? url}) = _AvatarContent; + + factory AvatarContent.fromJson(Map json) => + _$AvatarContentFromJson(json); +} diff --git a/lib/models/content/canonical_alias.dart b/lib/models/content/canonical_alias.dart new file mode 100644 index 0000000..6fb2829 --- /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 +sealed class CanonicalAliasContent extends Content + with _$CanonicalAliasContent { + CanonicalAliasContent._(); + factory CanonicalAliasContent({ + @JsonKey(includeIfNull: false) String? alias, + @Default(ISet.empty()) ISet altAliases, + }) = _CanonicalAliasContent; + + factory CanonicalAliasContent.fromJson(Map json) => + _$CanonicalAliasContentFromJson(json); +} diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart new file mode 100644 index 0000000..37a87b7 --- /dev/null +++ b/lib/models/content/content.dart @@ -0,0 +1,64 @@ +import "package:collection/collection.dart"; +import "package:nexus/models/content/avatar.dart"; +import "package:nexus/models/content/canonical_alias.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/encryption.dart"; +import "package:nexus/models/content/join_rules.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/name.dart"; +import "package:nexus/models/content/pinned_events.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/content/reaction.dart"; +import "package:nexus/models/content/encrypted.dart"; +import "package:nexus/models/content/redaction.dart"; +import "package:nexus/models/content/server_acl.dart"; +import "package:nexus/models/content/topic.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/content/history_visibility.dart"; + +class 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), + reaction("m.reaction", ReactionContent.fromJson), + pinnedEvents("m.room.pinned_events", PinnedEventsContent.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..b937a94 --- /dev/null +++ b/lib/models/content/create.dart @@ -0,0 +1,40 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "create.freezed.dart"; +part "create.g.dart"; + +@freezed +sealed class CreateContent extends Content with _$CreateContent { + CreateContent._(); + factory CreateContent({ + @JsonKey(name: "additional_creators") + @Default(IList.empty()) + IList additionalCreatorIds, + + PreviousRoom? predecessor, + + @JsonKey(name: "m.federate") @Default(true) bool federated, + + @Default("1") String roomVersion, + @JsonKey(unknownEnumValue: RoomType.room) RoomType? type, + }) = _CreateContent; + + factory CreateContent.fromJson(Map json) => + _$CreateContentFromJson(json); +} + +enum RoomType { + room, + @JsonValue("m.space") + space, +} + +@Freezed(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..8658a71 --- /dev/null +++ b/lib/models/content/encrypted.dart @@ -0,0 +1,13 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "encrypted.freezed.dart"; +part "encrypted.g.dart"; + +@freezed +sealed class EncryptedContent extends Content with _$EncryptedContent { + EncryptedContent._(); + factory EncryptedContent() = _EncryptedContent; + + factory EncryptedContent.fromJson(Map json) => + _$EncryptedContentFromJson(json); +} diff --git a/lib/models/content/encryption.dart b/lib/models/content/encryption.dart new file mode 100644 index 0000000..52c7b34 --- /dev/null +++ b/lib/models/content/encryption.dart @@ -0,0 +1,23 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "encryption.freezed.dart"; +part "encryption.g.dart"; + +@freezed +sealed class EncryptionContent extends Content with _$EncryptionContent { + EncryptionContent._(); + factory EncryptionContent({ + required String algorithm, + + @JsonKey(name: "rotation_period_ms") + @Default(604800000) + int rotationPeriodMS, + + @JsonKey(name: "rotation_period_msgs") + @Default(100) + int rotationPeriodMessages, + }) = _EncryptionContent; + + factory EncryptionContent.fromJson(Map json) => + _$EncryptionContentFromJson(json); +} diff --git a/lib/models/content/history_visibility.dart b/lib/models/content/history_visibility.dart new file mode 100644 index 0000000..3cfc011 --- /dev/null +++ b/lib/models/content/history_visibility.dart @@ -0,0 +1,19 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "history_visibility.freezed.dart"; +part "history_visibility.g.dart"; + +@freezed +sealed class HistoryVisibilityContent extends Content + with _$HistoryVisibilityContent { + HistoryVisibilityContent._(); + factory HistoryVisibilityContent({ + required HistoryVisibility historyVisibility, + }) = _HistoryVisibilityContent; + + factory HistoryVisibilityContent.fromJson(Map json) => + _$HistoryVisibilityContentFromJson(json); +} + +@JsonEnum(fieldRename: FieldRename.snake) +enum HistoryVisibility { invited, joined, shared, worldReadable } diff --git a/lib/models/content/join_rules.dart b/lib/models/content/join_rules.dart new file mode 100644 index 0000000..948a762 --- /dev/null +++ b/lib/models/content/join_rules.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/content.dart"; +import "package:nexus/models/join_rule.dart"; +part "join_rules.freezed.dart"; +part "join_rules.g.dart"; + +@freezed +sealed class JoinRulesContent extends Content with _$JoinRulesContent { + JoinRulesContent._(); + factory JoinRulesContent({ + required JoinRule joinRule, + @Default(ISet.empty()) ISet allow, + }) = _JoinRulesContent; + + 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..b1a5508 --- /dev/null +++ b/lib/models/content/membership.dart @@ -0,0 +1,28 @@ +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 +sealed class MembershipContent extends Content with _$MembershipContent { + MembershipContent._(); + + static String? displaynameFromJson(String? displayName) => + displayName?.isEmpty == true ? null : displayName; + + factory MembershipContent({ + @JsonKey( + name: "displayname", + fromJson: MembershipContent.displaynameFromJson, + ) + required String? displayName, + @JsonKey(name: "membership") required MembershipStatus status, + Uri? avatarUrl, + String? reason, + @Default(false) bool isDirect, + }) = _MembershipContent; + + factory MembershipContent.fromJson(Map json) => + _$MembershipContentFromJson(json); +} diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart new file mode 100644 index 0000000..36bd725 --- /dev/null +++ b/lib/models/content/message.dart @@ -0,0 +1,97 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/info/audio.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/info/file.dart"; +import "package:nexus/models/info/image.dart"; +import "package:nexus/models/info/video.dart"; +part "message.freezed.dart"; +part "message.g.dart"; + +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, + MessageFormat? format, + String? formattedBody, + }) = TextMessageContent; + + @FreezedUnionValue("m.notice") + factory MessageContent.notice({ + required String body, + MessageFormat? format, + String? formattedBody, + }) = NoticeMessageContent; + + @FreezedUnionValue("m.emote") + factory MessageContent.emote({ + required String body, + MessageFormat? format, + String? formattedBody, + }) = EmoteMessageContent; + + @FreezedUnionValue("m.image") + factory MessageContent.image({ + required String body, + MessageFormat? format, + String? formattedBody, + EncryptedFile? file, + String? filename, + ImageInfo? info, + @JsonKey(readValue: MessageContent.mediaUrlFromJson) required Uri url, + }) = ImageMessageContent; + + @FreezedUnionValue("m.file") + factory MessageContent.file({ + required String body, + 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, + 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, + 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}) = + 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..c451d64 --- /dev/null +++ b/lib/models/content/name.dart @@ -0,0 +1,13 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "name.freezed.dart"; +part "name.g.dart"; + +@freezed +sealed class NameContent extends Content with _$NameContent { + NameContent._(); + factory NameContent({required String name}) = _NameContent; + + factory NameContent.fromJson(Map json) => + _$NameContentFromJson(json); +} diff --git a/lib/models/content/pinned_events.dart b/lib/models/content/pinned_events.dart new file mode 100644 index 0000000..9dac5de --- /dev/null +++ b/lib/models/content/pinned_events.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/content/content.dart"; +part "pinned_events.freezed.dart"; +part "pinned_events.g.dart"; + +@freezed +sealed class PinnedEventsContent extends Content with _$PinnedEventsContent { + PinnedEventsContent._(); + factory PinnedEventsContent({ + @Default(IList.empty()) @JsonKey(name: "pinned") IList pinnedEvents, + }) = _PinnedEventsContent; + + factory PinnedEventsContent.fromJson(Map json) => + _$PinnedEventsContentFromJson(json); +} diff --git a/lib/models/content/power_levels.dart b/lib/models/content/power_levels.dart new file mode 100644 index 0000000..afa0e23 --- /dev/null +++ b/lib/models/content/power_levels.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 "power_levels.freezed.dart"; +part "power_levels.g.dart"; + +@freezed +sealed class PowerLevelsContent extends Content with _$PowerLevelsContent { + PowerLevelsContent._(); + factory PowerLevelsContent({ + @Default(IMap.empty()) IMap events, + @Default(IMap.empty()) IMap users, + @JsonKey(includeIfNull: false) Notifications? notifications, + @Default(50) int ban, + @Default(0) int eventsDefault, + @Default(0) int invite, + @Default(50) int kick, + @Default(50) int redact, + @Default(50) int stateDefault, + @Default(0) int usersDefault, + }) = _PowerLevelsContent; + + factory PowerLevelsContent.fromJson(Map json) => + _$PowerLevelsContentFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const Notifications({ + final int room = 50, + final IMap other = const .empty(), +}) 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..93c563d --- /dev/null +++ b/lib/models/content/reaction.dart @@ -0,0 +1,23 @@ +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) +sealed class ReactionContent extends Content with _$ReactionContent { + ReactionContent._(); + static String? keyJsonFromJson(Map json, String key) => + json["m.relates_to"]?["key"]; + + factory ReactionContent({ + @JsonKey(readValue: ReactionContent.keyJsonFromJson) String? key, + }) = _ReactionContent; + + @override + Map toJson() => { + "m.relates_to": {"key": key}, + }; + + 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..2ed6436 --- /dev/null +++ b/lib/models/content/redaction.dart @@ -0,0 +1,14 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "redaction.freezed.dart"; +part "redaction.g.dart"; + +@freezed +sealed class RedactionContent extends Content with _$RedactionContent { + RedactionContent._(); + factory RedactionContent({String? reason, String? redacts}) = + _RedactionContent; + + factory RedactionContent.fromJson(Map json) => + _$RedactionContentFromJson(json); +} diff --git a/lib/models/content/server_acl.dart b/lib/models/content/server_acl.dart new file mode 100644 index 0000000..3873bf3 --- /dev/null +++ b/lib/models/content/server_acl.dart @@ -0,0 +1,18 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +part "server_acl.freezed.dart"; +part "server_acl.g.dart"; + +@freezed +sealed class ServerACLContent extends Content with _$ServerACLContent { + ServerACLContent._(); + factory ServerACLContent({ + @Default(IList.empty()) IList allow, + @Default(IList.empty()) IList deny, + @Default(true) allowIpLiterals, + }) = _ServerACLContent; + + factory ServerACLContent.fromJson(Map json) => + _$ServerACLContentFromJson(json); +} diff --git a/lib/models/content/sticker.dart b/lib/models/content/sticker.dart new file mode 100644 index 0000000..c6fa893 --- /dev/null +++ b/lib/models/content/sticker.dart @@ -0,0 +1,18 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/info/image.dart"; +part "sticker.freezed.dart"; +part "sticker.g.dart"; + +@freezed +sealed class StickerContent extends Content with _$StickerContent { + StickerContent._(); + factory StickerContent({ + required String body, + required ImageInfo info, + required Uri url, + }) = _StickerContent; + + factory StickerContent.fromJson(Map json) => + _$StickerContentFromJson(json); +} diff --git a/lib/models/content/topic.dart b/lib/models/content/topic.dart new file mode 100644 index 0000000..c8256e3 --- /dev/null +++ b/lib/models/content/topic.dart @@ -0,0 +1,41 @@ +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 +sealed class TopicContent extends Content with _$TopicContent { + TopicContent._(); + factory TopicContent({ + required String topic, + @JsonKey(name: "m.topic") TopicContentBlock? content, + }) = _TopicContent; + + factory TopicContent.fromJson(Map json) => + _$TopicContentFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const TopicContentBlock({ + final IList representations = const IList.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/emoji.dart b/lib/models/emoji.dart new file mode 100644 index 0000000..75faf08 --- /dev/null +++ b/lib/models/emoji.dart @@ -0,0 +1,25 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:flutter/widgets.dart"; +part "emoji.freezed.dart"; +part "emoji.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class Emoji({ + @JsonKey( + fromJson: Emoji.widgetFromJson, + readValue: Emoji.readWidgetValueFromJson, + ) + required final Widget widget, + @JsonKey(name: "emoji") required final String value, + required final IList aliases, + required final String description, + required final IList tags, +}) with _$Emoji { + static Widget widgetFromJson(String emoji) => Text(emoji); + + static String readWidgetValueFromJson(Map json, _) => + json["emoji"]; + factory Emoji.fromJson(Map json) => _$EmojiFromJson(json); +} diff --git a/lib/models/emoji_category.dart b/lib/models/emoji_category.dart new file mode 100644 index 0000000..cc39bc3 --- /dev/null +++ b/lib/models/emoji_category.dart @@ -0,0 +1,12 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/emoji.dart"; +import "package:flutter/widgets.dart"; +part "emoji_category.freezed.dart"; + +@Freezed(toJson: false, fromJson: false) +class EmojiCategory({ + required final Widget icon, + required final String name, + required final IList emojis, +}) with _$EmojiCategory; diff --git a/lib/models/epoch_date_time_converter.dart b/lib/models/epoch_date_time_converter.dart new file mode 100644 index 0000000..c26d020 --- /dev/null +++ b/lib/models/epoch_date_time_converter.dart @@ -0,0 +1,11 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +class EpochDateTimeConverter implements JsonConverter { + const EpochDateTimeConverter(); + + @override + DateTime fromJson(int json) => DateTime.fromMillisecondsSinceEpoch(json); + + @override + int toJson(DateTime object) => object.millisecondsSinceEpoch; +} diff --git a/lib/models/event.dart b/lib/models/event.dart new file mode 100644 index 0000000..93352ca --- /dev/null +++ b/lib/models/event.dart @@ -0,0 +1,107 @@ +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(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 UnreadType? unreadType, + final Profile? pmp, + required final Content content, + required final Content? previousContent, +}) with _$Event { + Map toJson() => _$EventToJson(this); + + 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 { + return json["m.relates_to"]?["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(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); +} + +@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); + static const notify = UnreadType(2); + static const highlight = UnreadType(4); + static const sound = UnreadType(8); + + 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/full_room.dart b/lib/models/full_room.dart deleted file mode 100644 index ee61da6..0000000 --- a/lib/models/full_room.dart +++ /dev/null @@ -1,13 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:matrix/matrix.dart"; -part "full_room.freezed.dart"; - -@freezed -abstract class FullRoom with _$FullRoom { - const FullRoom._(); - const factory FullRoom({ - required Room roomData, - required String title, - required Uri? avatar, - }) = _FullRoom; -} 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_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_data.dart b/lib/models/image_data.dart deleted file mode 100644 index e5bc57e..0000000 --- a/lib/models/image_data.dart +++ /dev/null @@ -1,11 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "image_data.freezed.dart"; - -@freezed -abstract class ImageData with _$ImageData { - const factory ImageData({ - required String uri, - required int? height, - required int? width, - }) = _ImageData; -} 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..44fb9bb --- /dev/null +++ b/lib/models/join_rule.dart @@ -0,0 +1,4 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +@JsonEnum(fieldRename: .snake) +enum JoinRule { public, knock, invite, private, restricted, knockRestricted } diff --git a/lib/models/lazy_load_summary.dart b/lib/models/lazy_load_summary.dart new file mode 100644 index 0000000..fba1c98 --- /dev/null +++ b/lib/models/lazy_load_summary.dart @@ -0,0 +1,19 @@ +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(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/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..7eb5f72 --- /dev/null +++ b/lib/models/membership_status.dart @@ -0,0 +1 @@ +enum MembershipStatus { leave, invite, ban, join, knock } 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/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/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 new file mode 100644 index 0000000..b7c6bf8 --- /dev/null +++ b/lib/models/paginate.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/event.dart"; +import "package:nexus/models/read_receipt.dart"; + +part "paginate.freezed.dart"; +part "paginate.g.dart"; + +@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/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/read_receipt.dart b/lib/models/read_receipt.dart new file mode 100644 index 0000000..4d62635 --- /dev/null +++ b/lib/models/read_receipt.dart @@ -0,0 +1,25 @@ +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(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_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_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_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/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/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/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/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/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..acad57f --- /dev/null +++ b/lib/models/requests/send_event.dart @@ -0,0 +1,22 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.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, + final String? relatesTo, + final String? relationType, + final bool synchronous = false, + final bool disableEncryption = false, +}) with _$SendEventRequest { + Map toJson() => _$SendEventRequestToJson(this); + + 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..10a9330 --- /dev/null +++ b/lib/models/requests/send_message.dart @@ -0,0 +1,56 @@ +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, +}) with _$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/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 new file mode 100644 index 0000000..f02c6ac --- /dev/null +++ b/lib/models/room.dart @@ -0,0 +1,67 @@ +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"; +import "package:nexus/models/room_metadata.dart"; + +part "room.freezed.dart"; +part "room.g.dart"; + +@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(), + + 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); +} 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_metadata.dart b/lib/models/room_metadata.dart new file mode 100644 index 0000000..7621650 --- /dev/null +++ b/lib/models/room_metadata.dart @@ -0,0 +1,32 @@ +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(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, + + 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_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..028d161 --- /dev/null +++ b/lib/models/room_power_levels.dart @@ -0,0 +1,11 @@ +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(); +} diff --git a/lib/models/room_summary.dart b/lib/models/room_summary.dart new file mode 100644 index 0000000..05eaf43 --- /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, + 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/session_backup.dart b/lib/models/session_backup.dart deleted file mode 100644 index 0245c7e..0000000 --- a/lib/models/session_backup.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "session_backup.freezed.dart"; -part "session_backup.g.dart"; - -@freezed -abstract class SessionBackup with _$SessionBackup { - const factory SessionBackup({ - required String accessToken, - required Uri homeserver, - required String userID, - required String deviceID, - required String deviceName, - }) = _SessionBackup; - - factory SessionBackup.fromJson(Map json) => - _$SessionBackupFromJson(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..7ea61d9 --- /dev/null +++ b/lib/models/settings.dart @@ -0,0 +1,30 @@ +import "package:material_ui/material_ui.dart"; +import "package:freezed_annotation/freezed_annotation.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 RoomOrder roomOrder = .unreadFirst, + 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, +}) 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 d64dcc9..97efe7a 100644 --- a/lib/models/space.dart +++ b/lib/models/space.dart @@ -1,20 +1,17 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/widgets.dart"; import "package:freezed_annotation/freezed_annotation.dart"; -import "package:matrix/matrix.dart"; -import "package:nexus/models/full_room.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/models/subspace.dart"; + part "space.freezed.dart"; @freezed -abstract class Space with _$Space { - const Space._(); - const factory Space({ - required String title, - required String id, - required IList children, - required Client client, - Room? roomData, - Uri? avatar, - IconData? icon, - }) = _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 new file mode 100644 index 0000000..ea74130 --- /dev/null +++ b/lib/models/space_edge.dart @@ -0,0 +1,16 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "space_edge.freezed.dart"; +part "space_edge.g.dart"; + +@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 new file mode 100644 index 0000000..4639956 --- /dev/null +++ b/lib/models/sync_data.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/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(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 new file mode 100644 index 0000000..237cf3e --- /dev/null +++ b/lib/models/sync_status.dart @@ -0,0 +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(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.kebab) +enum SyncStatusType { ok, waiting, erroring, permanentlyFailed } 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 c60cc45..0000000 --- a/lib/pages/chat_page.dart +++ /dev/null @@ -1,32 +0,0 @@ -import "package:flutter/material.dart"; -import "package:nexus/widgets/chat_page/room_chat.dart"; -import "package:nexus/widgets/chat_page/sidebar.dart"; - -class ChatPage extends StatelessWidget { - const ChatPage({super.key}); - - @override - Widget build(BuildContext context) => 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 4631325..0000000 --- a/lib/pages/login_page.dart +++ /dev/null @@ -1,205 +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/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 isLoading = useState(false); - final allowLogin = useState(false); - - final launch = ref.watch(LaunchHelper.provider).launchUrl; - - Future setHomeserver(Uri? homeserver) async { - isLoading.value = true; - final succeeded = homeserver == null - ? false - : await ref - .watch(ClientController.provider.notifier) - .setHomeserver( - homeserver.hasScheme - ? homeserver - : Uri.https(homeserver.path), - ); - - if (succeeded) { - allowLogin.value = true; - } else if (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( - 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: "envs.net", - description: - "envs.net is a minimalist, non-commercial shared linux system and will always be free to use.", - url: Uri.https("envs.net"), - iconUrl: "https://envs.net/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( - 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 (allowLogin.value) ...[ - 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 ref - .watch(ClientController.provider.notifier) - .login(username.text, password.text); - - 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 b348aac..0000000 --- a/lib/pages/settings_page.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/secure_storage_controller.dart"; - -class SettingsPage extends ConsumerWidget { - const SettingsPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Scaffold( - appBar: AppBar(title: Text("Settings")), - body: ElevatedButton( - onPressed: ref.watch(SecureStorageController.provider.notifier).clear, - child: Text("Log out"), - ), - ); - } -} diff --git a/lib/widgets/adaptive_menu_button.dart b/lib/widgets/adaptive_menu_button.dart new file mode 100644 index 0000000..1928358 --- /dev/null +++ b/lib/widgets/adaptive_menu_button.dart @@ -0,0 +1,21 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/show_adaptive_menu.dart"; + +/// Like [PopupMenuButton], but shows a bottom sheet on mobile. +class const AdaptiveMenuButton({ + required final Widget icon, + required final List Function(BuildContext context) + itemBuilder, + final String? tooltip, + final bool enabled = true, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) => IconButton( + icon: icon, + tooltip: tooltip, + onPressed: enabled + ? () => context.showAdaptiveMenu(itemBuilder(context)) + : null, + ); +} diff --git a/lib/widgets/appbar.dart b/lib/widgets/appbar.dart index 3ecaa1d..123ae79 100644 --- a/lib/widgets/appbar.dart +++ b/lib/widgets/appbar.dart @@ -1,35 +1,90 @@ import "dart:io"; -import "package:flutter/material.dart"; -class Appbar extends StatelessWidget implements PreferredSizeWidget { - final Widget? leading; - final Widget? title; - final Color? backgroundColor; - final double? scrolledUnderElevation; - final List actions; - const Appbar({ - super.key, - this.title, - this.backgroundColor, - this.scrolledUnderElevation, - this.leading, - this.actions = const [], - }); +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 - Size get preferredSize => AppBar().preferredSize; + Widget build(BuildContext context, WidgetRef ref) { + final isDesktop = !(Platform.isAndroid || Platform.isIOS); - @override - AppBar build(BuildContext context) => AppBar( - leading: leading, - backgroundColor: backgroundColor, - scrolledUnderElevation: scrolledUnderElevation, - actionsPadding: EdgeInsets.symmetric(horizontal: 8), - title: title, - actions: [ - ...actions, - if (!(Platform.isAndroid || Platform.isIOS)) - IconButton(onPressed: () => exit(0), icon: Icon(Icons.close)), - ], - ); + 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), + ), + 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 index 41bd002..bdaf657 100644 --- a/lib/widgets/avatar_or_hash.dart +++ b/lib/widgets/avatar_or_hash.dart @@ -1,51 +1,39 @@ import "package:color_hash/color_hash.dart"; -import "package:flutter/material.dart"; - -class AvatarOrHash extends StatelessWidget { - final Uri? avatar; - final String title; - final Widget? fallback; - final bool hasBadge; - final double height; - final Map headers; - const AvatarOrHash( - this.avatar, - this.title, { - this.fallback, - this.hasBadge = false, - this.height = 24, - required this.headers, - super.key, - }); +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) { + Widget build(BuildContext context, WidgetRef ref) { final box = ColoredBox( color: ColorHash(title).color, - child: Center(child: Text(title[0])), + child: Center(child: Icon(Icons.person, size: dimension / 2)), ); + return SizedBox( - width: height, - height: height, + width: dimension, + height: dimension, child: Center( - child: Badge( - isLabelVisible: hasBadge, - smallSize: 8, - backgroundColor: Theme.of(context).colorScheme.onPrimaryContainer, - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(4)), - child: SizedBox( - width: height, - height: height, - child: avatar == null - ? fallback ?? box - : Image.network( - avatar.toString(), - headers: headers, - fit: BoxFit.contain, - errorBuilder: (_, _, _) => box, - ), - ), + 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/avatar_ring.dart b/lib/widgets/avatar_ring.dart new file mode 100644 index 0000000..f410978 --- /dev/null +++ b/lib/widgets/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/bouncing_dots.dart b/lib/widgets/bouncing_dots.dart new file mode 100644 index 0000000..37f655c --- /dev/null +++ b/lib/widgets/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/chat_page/chat_box.dart b/lib/widgets/chat_page/chat_box.dart deleted file mode 100644 index 016e3a7..0000000 --- a/lib/widgets/chat_page/chat_box.dart +++ /dev/null @@ -1,157 +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:matrix/matrix.dart"; -import "package:nexus/controllers/room_chat_controller.dart"; -import "package:nexus/models/relation_type.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 query = useState(""); - - if (relationType == RelationType.edit && - relatedMessage is TextMessage && - controller.value.text.isEmpty) { - final text = (relatedMessage as TextMessage).text; - controller.value.text = relatedMessage?.replyToMessageId == null - ? text - : text.split("\n\n").sublist(1).join("\n\n"); - } - - void send() { - ref - .watch(RoomChatController.provider(room).notifier) - .send( - controller.value.formattedText, - relation: relatedMessage, - relationType: relationType, - tags: controller.value.tags, - ); - onDismiss(); - controller.value.text = ""; - } - - final node = useFocusNode( - onKeyEvent: (_, event) { - if (event is KeyDownEvent && !Platform.isAndroid && !Platform.isIOS) { - if (event.logicalKey == LogicalKeyboardKey.enter && - !HardwareKeyboard.instance.isShiftPressed) { - send(); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.escape) { - onDismiss(); - return KeyEventResult.handled; - } - } - - return KeyEventResult.ignored; - }, - )..requestFocus(); - - final style = TextStyle( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ); - - return Positioned( - bottom: 0, - left: 0, - right: 0, - child: Padding( - padding: EdgeInsetsGeometry.all(12), - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), - child: Column( - children: [ - RelationPreview( - relatedMessage: relatedMessage, - relationType: relationType, - onDismiss: onDismiss, - room: room, - ), - Container( - color: theme.colorScheme.surfaceContainerHighest, - padding: EdgeInsets.symmetric(horizontal: 8), - child: Row( - spacing: 8, - children: [ - PopupMenuButton( - itemBuilder: (context) => [], - icon: Icon(Icons.add), - enabled: room.canSendDefaultMessages, - ), - 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, - ":": style, - }, - builder: (context, key) => TextFormField( - enabled: room.canSendDefaultMessages, - maxLines: 12, - minLines: 1, - decoration: InputDecoration( - hintText: 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: room.canSendDefaultMessages ? send : null, - icon: Icon(Icons.send), - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/widgets/chat_page/html/html.dart b/lib/widgets/chat_page/html/html.dart deleted file mode 100644 index ce5318a..0000000 --- a/lib/widgets/chat_page/html/html.dart +++ /dev/null @@ -1,156 +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:matrix/matrix.dart"; -import "package:nexus/controllers/thumbnail_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/helpers/launch_helper.dart"; -import "package:nexus/models/image_data.dart"; -import "package:nexus/widgets/chat_page/html/mention_chip.dart"; -import "package:nexus/widgets/chat_page/html/spoiler_text.dart"; -import "package:nexus/widgets/chat_page/html/code_block.dart"; -import "package:nexus/widgets/chat_page/html/quoted.dart"; -import "package:nexus/widgets/error_dialog.dart"; - -class Html extends ConsumerWidget { - final String html; - final Client client; - const Html(this.html, {required this.client, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( - html, - 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" - ? CodeBlock( - element.text, - lang: element.className.replaceAll("language-", ""), - ) - : null, - - "blockquote" => Quoted(Html(element.innerHtml, client: client)), - - "a" => - element.attributes["href"]?.parseIdentifierIntoParts() == null - ? null - : InlineCustomWidget(child: MentionChip(element.text)), - - "img" => - element.attributes["src"] == null - ? null - : Consumer( - builder: (_, ref, _) => ref - .watch( - ThumbnailController.provider( - ImageData( - uri: element.attributes["src"]!, - height: height, - width: width, - ), - ), - ) - .when( - data: (uri) { - if (uri == null) return SizedBox.shrink(); - - return InlineCustomWidget( - child: Image.network( - uri, - headers: client.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(), - ), - ); - }, - error: ErrorDialog.new, - loading: () => InlineCustomWidget( - child: SizedBox( - width: width?.toDouble(), - height: height.toDouble(), - 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), - - "edited" => MapEntry("display", "block"), - - _ => 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 f8fdab1..0000000 --- a/lib/widgets/chat_page/html/mention_chip.dart +++ /dev/null @@ -1,23 +0,0 @@ -import "package:flutter/material.dart"; -import "package:matrix/matrix.dart"; - -class MentionChip extends StatelessWidget { - final String label; - const MentionChip(this.label, {super.key}); - - @override - Widget build(BuildContext context) => ActionChip( - label: Text( - label.parseIdentifierIntoParts()?.primaryIdentifier ?? label, - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onPrimary, - ), - ), - backgroundColor: Theme.of(context).colorScheme.primary, - onPressed: () { - // TODO: Open room or join room dialog, or user popover - showAboutDialog(context: context); - }, - ); -} 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/member_list.dart b/lib/widgets/chat_page/member_list.dart deleted file mode 100644 index 3e6e82e..0000000 --- a/lib/widgets/chat_page/member_list.dart +++ /dev/null @@ -1,64 +0,0 @@ -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:matrix/matrix.dart"; -import "package:nexus/controllers/avatar_controller.dart"; -import "package:nexus/controllers/members_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/get_headers.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) => Drawer( - shape: Border(), - child: ref - .watch(MembersController.provider(room)) - .betterWhen( - data: (members) => ListView( - children: [ - AppBar( - scrolledUnderElevation: 0, - leading: Icon(Icons.people), - title: Text("Members"), - actionsPadding: EdgeInsets.only(right: 4), - actions: [ - if (Scaffold.of(context).hasEndDrawer) - IconButton( - onPressed: Scaffold.of(context).closeEndDrawer, - icon: Icon(Icons.close), - ), - ], - ), - ...members - .where( - (membership) => - membership.content["membership"] == - Membership.join.name, - ) - .map( - (member) => ListTile( - leading: AvatarOrHash( - ref - .watch( - AvatarController.provider( - member.content["avatar_url"].toString(), - ), - ) - .whenOrNull(data: (data) => data), - member.content["displayname"].toString(), - headers: room.client.headers, - ), - title: Text( - member.content["displayname"].toString(), - 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 6558a9d..0000000 --- a/lib/widgets/chat_page/mention_overlay.dart +++ /dev/null @@ -1,130 +0,0 @@ -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:matrix/matrix.dart"; -import "package:nexus/controllers/avatar_controller.dart"; -import "package:nexus/controllers/members_controller.dart"; -import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/get_headers.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) => Padding( - padding: EdgeInsets.all(8), - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), - child: Container( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - padding: EdgeInsets.all(8), - child: switch (triggerCharacter) { - "@" => - ref - .watch(MembersController.provider(room)) - .betterWhen( - data: (members) => ListView( - children: - (query.isEmpty - ? members - : members.where( - (member) => - member.senderId.toLowerCase().contains( - query.toLowerCase(), - ) || - (member.content["displayname"] - as String?) - ?.toLowerCase() - .contains( - query.toLowerCase(), - ) == - true, - )) - .map( - (member) => ListTile( - leading: AvatarOrHash( - ref - .watch( - AvatarController.provider( - member.content["avatar_url"] - .toString(), - ), - ) - .whenOrNull(data: (data) => data), - member.content["displayname"].toString(), - headers: room.client.headers, - ), - title: Text( - member.content["displayname"] as String? ?? - member.senderId, - ), - onTap: () => addTag( - id: member.senderId, - name: member.senderId - .substring(1) - .split(":") - .first, - ), - ), - ) - .toList(), - ), - ), - "#" => - ref - .watch(RoomsController.provider) - .betterWhen( - data: (rooms) => ListView( - children: - (query.isEmpty - ? rooms - : rooms.where( - (room) => room.title.toLowerCase().contains( - query.toLowerCase(), - ), - )) - .map( - (room) => ListTile( - leading: AvatarOrHash( - room.avatar, - room.title, - fallback: Icon(Icons.numbers), - headers: room.roomData.client.headers, - ), - title: Text(room.title), - subtitle: room.roomData.topic.isEmpty - ? null - : Text(room.roomData.topic), - onTap: () => addTag( - id: "[#${room.roomData.getLocalizedDisplayname()}](https://matrix.to/#/${room.roomData.id})", - name: - (room.roomData.canonicalAlias.isEmpty - ? room.roomData.id - : room.roomData.canonicalAlias) - .substring(1) - .split(":") - .first, - ), - ), - ) - .toList(), - ), - ), - _ => Loading(), - }, - ), - ), - ); -} diff --git a/lib/widgets/chat_page/relation_preview.dart b/lib/widgets/chat_page/relation_preview.dart deleted file mode 100644 index 07bac4e..0000000 --- a/lib/widgets/chat_page/relation_preview.dart +++ /dev/null @@ -1,79 +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:matrix/matrix.dart"; -import "package:nexus/controllers/avatar_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.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 Room room; - const RelationPreview({ - required this.relatedMessage, - required this.relationType, - required this.onDismiss, - required this.room, - 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( - ref - .watch( - AvatarController.provider( - relatedMessage!.metadata!["avatarUrl"], - ), - ) - .whenOrNull(data: (data) => data), - relatedMessage!.metadata!["displayName"].toString(), - headers: room.client.headers, - height: 16, - ), - Text( - relatedMessage!.metadata?["displayName"] ?? - relatedMessage!.authorId, - style: theme.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Expanded( - child: Text( - (relatedMessage is TextMessage) - ? (relatedMessage as TextMessage).text - : relatedMessage?.metadata?["body"] ?? - relatedMessage?.metadata?["eventType"], - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelMedium, - maxLines: 1, - ), - ), - IconButton( - onPressed: onDismiss, - icon: Icon(Icons.close), - iconSize: 20, - ), - ], - ), - ); - } -} diff --git a/lib/widgets/chat_page/room_appbar.dart b/lib/widgets/chat_page/room_appbar.dart deleted file mode 100644 index 17696dd..0000000 --- a/lib/widgets/chat_page/room_appbar.dart +++ /dev/null @@ -1,60 +0,0 @@ -import "package:flutter/material.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/models/full_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 FullRoom 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.avatar, - room.title, - height: 24, - fallback: Icon(Icons.numbers), - headers: room.roomData.client.headers, - ) - : DrawerButton(onPressed: () => onOpenDrawer(context)), - scrolledUnderElevation: 0, - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(room.title, overflow: TextOverflow.ellipsis), - if (room.roomData.topic.isNotEmpty) - Text( - room.roomData.topic, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - actions: [ - IconButton(onPressed: () {}, icon: Icon(Icons.push_pin)), - IconButton( - onPressed: () => onOpenMemberList(context), - icon: Icon(Icons.people), - ), - RoomMenu(room.roomData), - ], - ); -} diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart deleted file mode 100644 index 0190e64..0000000 --- a/lib/widgets/chat_page/room_chat.dart +++ /dev/null @@ -1,391 +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:flutter_link_previewer/flutter_link_previewer.dart"; -import "package:flyer_chat_file_message/flyer_chat_file_message.dart"; -import "package:flyer_chat_image_message/flyer_chat_image_message.dart"; -import "package:flyer_chat_system_message/flyer_chat_system_message.dart"; -import "package:flyer_chat_text_message/flyer_chat_text_message.dart"; -import "package:hooks_riverpod/hooks_riverpod.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/get_headers.dart"; -import "package:nexus/helpers/extensions/show_context_menu.dart"; -import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/chat_page/chat_box.dart"; -import "package:nexus/widgets/chat_page/html/html.dart"; -import "package:nexus/widgets/chat_page/member_list.dart"; -import "package:nexus/widgets/chat_page/room_appbar.dart"; -import "package:nexus/widgets/chat_page/top_widget.dart"; -import "package:nexus/widgets/form_text_input.dart"; -import "package:nexus/widgets/loading.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 replyToMessage = useState(null); - final memberListOpened = useState(showMembersByDefault); - final relationType = useState(RelationType.reply); - final theme = Theme.of(context); - final danger = theme.colorScheme.error; - - return ref - .watch(SelectedRoomController.provider) - .betterWhen( - data: (room) { - if (room == null) { - return Center( - child: Text( - "Nothing to see here...", - style: theme.textTheme.headlineMedium, - ), - ); - } - final controllerProvider = RoomChatController.provider( - room.roomData, - ); - final notifier = ref.watch(controllerProvider.notifier); - - List getMessageOptions(Message message) => [ - PopupMenuItem( - onTap: () { - replyToMessage.value = message; - relationType.value = RelationType.reply; - }, - child: ListTile( - leading: Icon(Icons.reply), - title: Text("Reply"), - ), - ), - if (message.authorId == room.roomData.client.userID) - PopupMenuItem( - onTap: () { - replyToMessage.value = message; - relationType.value = RelationType.edit; - }, - child: ListTile( - leading: Icon(Icons.edit), - title: Text("Edit"), - ), - ), - if (message.authorId == room.roomData.client.userID || - room.roomData.canRedact) - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (_) { - final deleteReasonController = - useTextEditingController(); - return AlertDialog( - title: Text("Delete Message"), - content: Column( - mainAxisSize: MainAxisSize.min, - 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) => AlertDialog( - title: Text("Report"), - content: Text( - "Report this message to your server administrators, who can take action like banning that user or blocking that server from federating.", - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () { - room.roomData.client.reportEvent( - room.roomData.id, - message.id, - ); - Navigator.of(context).pop(); - }, - child: Text("Report"), - ), - ], - ), - ), - child: ListTile( - leading: Icon(Icons.report, color: danger), - title: Text("Report", style: TextStyle(color: danger)), - ), - ), - ]; - - 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: room.roomData.client.userID!, - theme: ChatTheme.fromThemeData(theme) - .copyWith( - colors: ChatColors.fromThemeData(theme) - .copyWith( - primary: theme - .colorScheme - .primaryContainer, - onPrimary: theme - .colorScheme - .onPrimaryContainer, - ), - ), - onMessageSecondaryTap: - ( - context, - message, { - required details, - required index, - }) => 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: notifier.loadOlder, - onStartReached: notifier.markRead, - bottomPadding: 72, - ), - composerBuilder: (_) => ChatBox( - relationType: relationType.value, - relatedMessage: replyToMessage.value, - onDismiss: () => - replyToMessage.value = null, - room: room.roomData, - ), - textMessageBuilder: - ( - context, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => FlyerChatTextMessage( - customWidget: Html( - (message.metadata?["formatted"] - as String) - .replaceAllMapped( - RegExp( - regexLink, - caseSensitive: false, - ), - (m) => - "${m.group(0)!}", - ) - .replaceAll("\n", "
") + - ((message.editedAt != null) - ? "(edited)" - : ""), - client: room.roomData.client, - ), - topWidget: TopWidget( - message, - headers: - room.roomData.client.headers, - groupStatus: groupStatus, - ), - message: message, - showTime: true, - index: index, - ), - linkPreviewBuilder: - (_, message, isSentByMe) => LinkPreview( - text: message.text, - backgroundColor: isSentByMe - ? theme.colorScheme.inversePrimary - : theme - .colorScheme - .surfaceContainerLow, - insidePadding: EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - linkPreviewData: - message.linkPreviewData, - onLinkPreviewDataFetched: - (linkPreviewData) => - notifier.updateMessage( - message, - message.copyWith( - linkPreviewData: - linkPreviewData, - ), - ), - ), - imageMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => FlyerChatImageMessage( - topWidget: TopWidget( - message, - headers: - room.roomData.client.headers, - groupStatus: groupStatus, - alwaysShow: true, - ), - errorBuilder: - (context, error, stackTrace) => - Center( - child: Text( - "Image Failed to Load", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.error, - ), - ), - ), - message: message, - index: index, - headers: room.roomData.client.headers, - ), - fileMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => InkWell( - onTap: () => showAboutDialog( - context: context, - ), // TODO: Download - child: FlyerChatFileMessage( - topWidget: TopWidget( - message, - headers: - room.roomData.client.headers, - groupStatus: groupStatus, - ), - message: message, - index: index, - ), - ), - 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.roomData), - ], - ), - - endDrawer: showMembersByDefault - ? null - : MemberList(room.roomData), - ); - }, - ); - } -} diff --git a/lib/widgets/chat_page/room_menu.dart b/lib/widgets/chat_page/room_menu.dart deleted file mode 100644 index c5fa322..0000000 --- a/lib/widgets/chat_page/room_menu.dart +++ /dev/null @@ -1,105 +0,0 @@ -import "package:clipboard/clipboard.dart"; -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:matrix/matrix.dart"; -import "package:nexus/widgets/form_text_input.dart"; - -class RoomMenu extends StatelessWidget { - final Room room; - const RoomMenu(this.room, {super.key}); - - @override - Widget build(BuildContext context) { - final danger = Theme.of(context).colorScheme.error; - - return PopupMenuButton( - itemBuilder: (_) => [ - PopupMenuItem( - onTap: () async { - final link = await room.matrixToInviteLink(); - await FlutterClipboard.copy(link.toString()); - }, - child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), - ), - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text("Leave Room"), - content: Text( - "Are you sure you want to leave \"${room.getLocalizedDisplayname()}\"?", - ), - 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..."))); - await room.leave(); - 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, - 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 deletion (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 d4bd89d..0000000 --- a/lib/widgets/chat_page/sidebar.dart +++ /dev/null @@ -1,178 +0,0 @@ -import "package:collection/collection.dart"; -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/selected_space_controller.dart"; -import "package:nexus/controllers/spaces_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/get_headers.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"; - -class Sidebar extends HookConsumerWidget { - const Sidebar({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final selectedSpaceProvider = KeyController.provider( - KeyController.spaceKey, - ); - final selectedSpace = ref.watch(selectedSpaceProvider); - final selectedSpaceNotifier = ref.watch(selectedSpaceProvider.notifier); - - final selectedRoomController = KeyController.provider( - KeyController.roomKey, - ); - final selectedRoom = ref.watch(selectedRoomController); - final selectedRoomNotifier = ref.watch(selectedRoomController.notifier); - - return Drawer( - shape: Border(), - child: Row( - children: [ - ref - .watch(SpacesController.provider) - .when( - loading: SizedBox.shrink, - error: (error, stack) { - debugPrintStack(label: error.toString(), stackTrace: stack); - throw error; - }, - data: (spaces) { - final indexOfSelected = spaces.indexWhere( - (space) => space.id == selectedSpace, - ); - final selectedIndex = indexOfSelected == -1 - ? 0 - : indexOfSelected; - - return NavigationRail( - scrollable: true, - onDestinationSelected: (value) { - selectedSpaceNotifier.set(spaces[value].id); - selectedRoomNotifier.set( - spaces[value].children.firstOrNull?.roomData.id, - ); - }, - destinations: spaces - .map( - (space) => NavigationRailDestination( - icon: AvatarOrHash( - space.avatar, - fallback: space.icon == null - ? null - : Icon(space.icon), - space.title, - headers: space.client.headers, - hasBadge: - space.children.firstWhereOrNull( - (room) => room.roomData.hasNewMessages, - ) != - null, - ), - 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: [ - IconButton( - onPressed: () => Navigator.of(context).push( - // TODO: join or create room/space - MaterialPageRoute(builder: (_) => SettingsPage()), - ), - icon: Icon(Icons.add), - ), - IconButton( - onPressed: () => Navigator.of(context).push( - // TODO: explore public rooms/spaces - MaterialPageRoute(builder: (_) => SettingsPage()), - ), - icon: Icon(Icons.explore), - ), - IconButton( - onPressed: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => SettingsPage()), - ), - icon: Icon(Icons.settings), - ), - ], - ), - ), - ); - }, - ), - Expanded( - child: ref - .watch(SelectedSpaceController.provider) - .betterWhen( - data: (space) { - final indexOfSelected = space.children.indexWhere( - (room) => room.roomData.id == selectedRoom, - ); - final selectedIndex = indexOfSelected == -1 - ? space.children.isEmpty - ? null - : 0 - : indexOfSelected; - - return Scaffold( - backgroundColor: Colors.transparent, - appBar: AppBar( - leading: AvatarOrHash( - space.avatar, - fallback: space.icon == null - ? null - : Icon(space.icon), - space.title, - headers: space.client.headers, - ), - title: Text( - space.title, - overflow: TextOverflow.ellipsis, - ), - backgroundColor: Colors.transparent, - actions: [ - if (space.roomData != null) RoomMenu(space.roomData!), - ], - ), - body: NavigationRail( - scrollable: true, - backgroundColor: Colors.transparent, - extended: true, - selectedIndex: selectedIndex, - destinations: space.children - .map( - (room) => NavigationRailDestination( - label: Text(room.title), - icon: AvatarOrHash( - hasBadge: room.roomData.hasNewMessages, - room.avatar, - room.title, - fallback: selectedSpace == "dms" - ? null - : Icon(Icons.numbers), - headers: space.client.headers, - ), - ), - ) - .toList(), - onDestinationSelected: (value) => selectedRoomNotifier - .set(space.children[value].roomData.id), - ), - ); - }, - ), - ), - ], - ), - ); - } -} diff --git a/lib/widgets/chat_page/top_widget.dart b/lib/widgets/chat_page/top_widget.dart deleted file mode 100644 index 733dcc7..0000000 --- a/lib/widgets/chat_page/top_widget.dart +++ /dev/null @@ -1,116 +0,0 @@ -import "dart:math"; -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_riverpod/flutter_riverpod.dart"; -import "package:nexus/widgets/chat_page/html/quoted.dart"; - -class TopWidget extends ConsumerWidget { - final Message message; - final bool alwaysShow; - final Map headers; - final MessageGroupStatus? groupStatus; - const TopWidget( - this.message, { - required this.headers, - required this.groupStatus, - this.alwaysShow = false, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Builder( - builder: (_) { - final replyMessage = message.metadata?["reply"] as TextMessage?; - - if (replyMessage == null) return SizedBox.shrink(); - final smallerText = message is TextMessage - ? replyMessage.text.substring( - 0, - min( - max( - max( - (message as TextMessage).text.length - 20, - message.metadata?["displayName"].length, - ), - 5, - ), - replyMessage.text.length, - ), - ) - : null; - final replyText = - (smallerText == null || - smallerText.length == replyMessage.text.length) - ? replyMessage.text - : "$smallerText..."; - - return Padding( - padding: EdgeInsets.only(bottom: 12), - child: InkWell( - // TODO: Scroll to original message - onTap: () => showAboutDialog(context: context), - child: Quoted( - Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - Avatar( - userId: replyMessage.authorId, - headers: headers, - size: 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, - ), - ), - ], - ), - ), - ), - ); - }, - ), - if (alwaysShow || - groupStatus?.isFirst != false || - message.metadata?["reply"] != null) - InkWell( - onTap: () => - showAboutDialog(context: context), // TODO: Show user profile - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - Avatar(userId: message.authorId, headers: headers), - Flexible( - child: Text( - message.metadata?["displayName"] ?? message.authorId, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ), - SizedBox(height: 4), - ], - ); -} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart new file mode 100644 index 0000000..2648347 --- /dev/null +++ b/lib/widgets/composer/composer.dart @@ -0,0 +1,448 @@ +import "dart:io"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/widgets/emoji_picker.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/widgets/adaptive_menu_button.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/models/content/message.dart"; +import "package:nexus/models/relation_type.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/main.dart"; + +class const Composer( + final String roomId, { + required final int? relatedEvent, + required final RelationType relationType, + required final VoidCallback onDismiss, + required final Future Function( + String text, { + required bool shouldMention, + required IList tags, + }) + onSend, + final FocusNode? node, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final controller = useMemoized(FlutterTaggerController.new); + final triggerCharacter = useState(""); + final shouldMention = useState(true); + final query = useState(""); + final relatedEvent = this.relatedEvent == null + ? null + : ref.watch( + RoomsController.provider.select((rooms) { + final event = rooms[roomId]?.events[this.relatedEvent!]; + if (event == null) return null; + + final edited = event.lastEditRowId == 0 + ? null + : rooms[roomId]?.events[event.lastEditRowId]; + + return edited == null + ? event + : event.copyWith( + content: edited.content, + localContent: edited.localContent, + ); + }), + ); + + final textBefore = useState(null); + + useEffect(() { + if (relationType == .edit && relatedEvent != null) { + textBefore.value ??= controller.text; + + final text = + relatedEvent.localContent?.editSource ?? + switch (relatedEvent.content) { + TextMessageContent(:final body) => body, + _ => "", + }; + + controller.value = .new( + text: text, + selection: .collapsed(offset: text.length), + ); + } else if (textBefore.value case final draft?) { + controller.value = .new( + text: draft, + selection: .collapsed(offset: draft.length), + ); + textBefore.value = null; + } + + return null; + }, [relationType, relatedEvent == null]); + + final sinceTypingSent = useMemoized(Stopwatch.new); + + useEffect(() { + final client = ref.read(ClientController.provider.notifier); + + void stopTyping() { + if (!sinceTypingSent.isRunning) return; + sinceTypingSent + ..stop() + ..reset(); + client.setTyping(roomId, .zero).ignore(); + } + + void onChanged() { + 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(); + } + + controller.addListener(onChanged); + return () { + controller.removeListener(onChanged); + stopTyping(); + }; + }, [roomId]); + + final attachment = ref.watch(AttachmentController.provider(roomId)); + + Future attach(Future pick) async { + if (await pick case final file?) { + await ref + .read(AttachmentController.provider(roomId).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(AttachmentController.provider(roomId).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(AttachmentController.provider(roomId).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; + } + + void send() { + if (controller.value.text.isEmpty && attachment == null || + attachment != null && attachment.$2 == null) { + return; + } + onSend( + controller.formattedText, + shouldMention: shouldMention.value, + tags: .new(controller.tags), + ); + + onDismiss(); + controller.text = ""; + } + + 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(AttachmentController.provider(roomId)), + icon: Icon(Icons.close), + ), + ), + ), + ClipRRect( + borderRadius: .all(.circular(12)), + child: Column( + children: [ + RelationPreview( + relatedEvent, + shouldMention: shouldMention.value, + toggleShouldMention: () => + shouldMention.value = !shouldMention.value, + relationType: relationType, + onDismiss: onDismiss, + ), + Container( + color: theme.colorScheme.surfaceContainerHighest, + child: Row( + mainAxisAlignment: .center, + children: + ref.watch( + PowerLevelController.provider( + .new(eventType: .message, roomId: roomId), + ), + ) + ? [ + IconButton( + onPressed: () => context.showAdaptiveSheet( + (context) => EmojiPicker( + onSelection: (value) { + Navigator.of(context).pop(); + controller.text += value; + node?.requestFocus(); + }, + ), + ), + icon: Icon(Icons.emoji_emotions), + ), + AdaptiveMenuButton( + tooltip: "Add media", + enabled: attachment == null, + 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), + ), + ), + ], + icon: Icon(Icons.add), + ), + SizedBox(width: 8), + Expanded( + child: FlutterTagger( + triggerStrategy: .eager, + 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: (_, event) { + if (event is KeyDownEvent && + event.logicalKey == + LogicalKeyboardKey.enter) { + final shiftPressed = HardwareKeyboard + .instance + .isShiftPressed; + + if (!shiftPressed) { + send(); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + }, + // Overrides the keyboard shortcut for pasting + child: Actions( + actions: { + PasteTextIntent: PasteFilesAction( + pasteFiles, + ), + }, + child: TextField( + maxLines: 12, + minLines: 1, + textCapitalization: .sentences, + 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/overlays/emoji_overlay.dart b/lib/widgets/composer/overlays/emoji_overlay.dart new file mode 100644 index 0000000..0e762c0 --- /dev/null +++ b/lib/widgets/composer/overlays/emoji_overlay.dart @@ -0,0 +1,47 @@ +import "package:collection/collection.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/helpers/extensions/better_when.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(EmojiController.provider) + .betterWhen( + data: (emojis) => ListView( + children: emojis + .map((element) => element.emojis) + .flattened + .where( + (emoji) => + emoji.aliases.join().contains(query) || + emoji.description.contains(query) || + emoji.tags.join().contains(query), + ) + .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: Uri.tryParse(emoji.value)?.scheme == "mxc" + ? "" // TODO: Handle custom emotes + : 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..b5af177 --- /dev/null +++ b/lib/widgets/composer/overlays/room_overlay.dart @@ -0,0 +1,58 @@ +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/widgets/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: + (query.isEmpty + ? rooms.values + : rooms.values.where( + (room) => (room.metadata?.name ?? room.metadata?.id ?? "") + .toLowerCase() + .contains(query.toLowerCase()), + )) + .map((room) { + final name = + room.metadata?.name ?? + room.metadata?.canonicalAlias ?? + room.metadata?.id ?? + "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)); + 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..7d973dc --- /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/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..fe4f70f --- /dev/null +++ b/lib/widgets/composer/overlays/user_overlay.dart @@ -0,0 +1,64 @@ +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/models/content/membership.dart"; +import "package:nexus/widgets/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: + (query.isEmpty + ? members + : members.where( + (member) => + member.stateKey?.toLowerCase().contains( + query.toLowerCase(), + ) == + true || + switch (member.content) { + MembershipContent(:final displayName) => + displayName?.toLowerCase().contains( + query.toLowerCase(), + ) == + true, + _ => false, + }, + )) + .map( + (member) => switch (member.content) { + MembershipContent(:final displayName, :final avatarUrl) => + ListTile( + leading: AvatarOrHash( + avatarUrl, + displayName ?? member.stateKey!.localpart, + ), + title: Text( + displayName ?? member.stateKey!.localpart, + ), + subtitle: Text(member.stateKey!), + onTap: () => addTag( + id: "[@$displayName](matrix:u/${member.stateKey!.substring(1)})", + name: member.stateKey!.localpart, + ), + ), + _ => SizedBox.shrink(), + }, + ) + .toList(), + ), + ); +} 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..fca9de6 --- /dev/null +++ b/lib/widgets/composer/relation_preview.dart @@ -0,0 +1,58 @@ +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/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!), + ), + ), + + if (relationType == .reply) + TextButton( + onPressed: toggleShouldMention, + child: Text( + shouldMention ? "@On" : "@Off", + style: TextStyle( + fontWeight: .w900, + color: shouldMention ? null : Theme.of(context).disabledColor, + ), + ), + ), + + IconButton( + tooltip: "Cancel ${relationType == .edit ? "edit" : "reply"}", + onPressed: onDismiss, + icon: const Icon(Icons.close), + iconSize: 20, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/composer/typing_indicator.dart b/lib/widgets/composer/typing_indicator.dart new file mode 100644 index 0000000..2a30c4b --- /dev/null +++ b/lib/widgets/composer/typing_indicator.dart @@ -0,0 +1,113 @@ +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/extensions/show_user_popover.dart"; +import "package:nexus/widgets/bouncing_dots.dart"; + +class const TypingIndicator(final String roomId, {super.key}) + extends ConsumerWidget { + @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; + final subjects = [ + ...typing + .take(named) + .map( + (typingUserId) => WidgetSpan( + alignment: .baseline, + baseline: .alphabetic, + child: Builder( + builder: (context) => InkWell( + onTap: () => + context.showUserPopover(typingUserId, roomId: roomId), + child: Text( + ref + .watch( + UserController.provider( + .new(roomId: roomId, userId: typingUserId), + ), + ) + .value + ?.displayName ?? + typingUserId.localpart, + style: theme.textTheme.bodyMedium?.copyWith( + 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/count_badge.dart b/lib/widgets/count_badge.dart new file mode 100644 index 0000000..e0912f8 --- /dev/null +++ b/lib/widgets/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/create_room_sheet.dart b/lib/widgets/create_room_sheet.dart new file mode 100644 index 0000000..423b909 --- /dev/null +++ b/lib/widgets/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:nexus/controllers/client_state.dart"; +import "package:nexus/helpers/extensions/create_room.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/widgets/sheet_layout.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/divider_text.dart b/lib/widgets/divider_text.dart index ca78844..c44ae11 100644 --- a/lib/widgets/divider_text.dart +++ b/lib/widgets/divider_text.dart @@ -1,29 +1,9 @@ -import "package:flutter/material.dart"; - -class DividerText extends StatelessWidget { - final String text; - - const DividerText(this.text, {super.key}); +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/divider_widget.dart"; +final class const DividerText(final String text, {super.key}) + extends StatelessWidget { @override - Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) => Row( - children: [ - SizedBox( - width: 16, - child: Divider(color: Theme.of(context).colorScheme.onSurface), - ), - ConstrainedBox( - constraints: BoxConstraints(maxWidth: constraints.maxWidth - 32), - child: Padding( - padding: const EdgeInsets.all(8), - child: Text(text, style: Theme.of(context).textTheme.labelLarge), - ), - ), - Expanded( - child: Divider(color: Theme.of(context).colorScheme.onSurface), - ), - ], - ), - ); + Widget build(BuildContext context) => + DividerWidget(Text(text, style: Theme.of(context).textTheme.labelLarge)); } diff --git a/lib/widgets/divider_widget.dart b/lib/widgets/divider_widget.dart new file mode 100644 index 0000000..5e4972c --- /dev/null +++ b/lib/widgets/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/editable_avatar.dart b/lib/widgets/editable_avatar.dart new file mode 100644 index 0000000..d8d387f --- /dev/null +++ b/lib/widgets/editable_avatar.dart @@ -0,0 +1,86 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/show_adaptive_menu.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/emoji_picker.dart b/lib/widgets/emoji_picker.dart new file mode 100644 index 0000000..7e49836 --- /dev/null +++ b/lib/widgets/emoji_picker.dart @@ -0,0 +1,56 @@ +import "dart:async"; + +import "package:collection/collection.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/recent_emoji.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/main.dart"; + +class const EmojiPicker({ + required final FutureOr Function(String value) 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(upstream.EmojiController.provider) + .betterWhen( + data: (categories) => upstream.EmojiPicker( + onSelection: (emoji, isFreeText) async { + await onSelection(emoji); + + if (!isFreeText) { + await ref + .read(RecentEmojiController.provider.notifier) + .add(emoji) + .onError(showError); + } + }, + allowFreeText: allowFreeText, + prependCategories: .new([ + .new( + icon: Icon(Icons.history), + name: "Recent", + emojis: .new( + recentEmoji + .map( + (recent) => categories + .map((element) => element.emojis) + .flattened + .firstWhereOrNull( + (element) => element.value == recent.emoji, + ), + ) + .nonNulls, + ), + ), + ]), + ), + ); + } +} diff --git a/lib/widgets/error_dialog.dart b/lib/widgets/error_dialog.dart index b016a8b..a12a3b4 100644 --- a/lib/widgets/error_dialog.dart +++ b/lib/widgets/error_dialog.dart @@ -1,19 +1,21 @@ -import "package:flutter/material.dart"; +import "package:material_ui/material_ui.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}); - +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\n\n$stackTrace"), + child: SelectableText( + "$error${stackTrace == null ? "" : "\n\n$stackTrace"}", + ), ), actions: [ if (provider != null) @@ -21,11 +23,12 @@ class ErrorDialog extends ConsumerWidget { onPressed: () => ref.invalidate(provider!), child: const Text("Try Again"), ), - TextButton( - onPressed: () => - Navigator.of(context).popUntil((route) => route.isFirst), - child: const Text("Go Back"), - ), + if (Navigator.of(context).canPop()) + TextButton( + onPressed: () => + Navigator.of(context).popUntil((route) => route.isFirst), + child: const Text("Go Back"), + ), ], ); } diff --git a/lib/widgets/event_preview.dart b/lib/widgets/event_preview.dart new file mode 100644 index 0000000..1b88fa8 --- /dev/null +++ b/lib/widgets/event_preview.dart @@ -0,0 +1,40 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/lazy_loading/message_avatar.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/renderers/event.dart"; + +class const EventPreview(final Event event, {super.key}) + extends StatelessWidget { + @override + Widget build(BuildContext context) => IgnorePointer( + child: Padding( + padding: .symmetric(vertical: 4), + child: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + if (event.content is MessageContent) MessageAvatar(event, height: 36), + + Flexible( + child: Column( + mainAxisAlignment: .center, + crossAxisAlignment: .start, + children: [ + if (event.content is MessageContent) + MessageDisplayname(event, shouldWrap: false), + EventRenderer( + event.rowId, + roomId: event.roomId, + textOnly: true, + maxLines: 1, + ), + ], + ), + ), + ], + ), + ), + ); +} diff --git a/lib/widgets/expandable_image.dart b/lib/widgets/expandable_image.dart new file mode 100644 index 0000000..f10221e --- /dev/null +++ b/lib/widgets/expandable_image.dart @@ -0,0 +1,73 @@ +import "package:material_ui/material_ui.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:m3e_buttons/m3e_buttons.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; +import "package:nexus/helpers/mxc_image.dart"; +import "package:nexus/models/requests/download_media.dart"; +import "package:nexus/widgets/error_dialog.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, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => InkWell( + onTap: request == null + ? null + : () => showDialog( + context: context, + builder: (_) => SafeArea( + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: Navigator.of(context).pop, + child: InteractiveViewer( + maxScale: 10, + child: Image( + errorBuilder: (_, error, stackTrace) => ErrorDialog( + "Loading failed for ${request?.mxc}\nError: $error", + stackTrace, + ), + image: MxcImage(ref, request!), + ), + ), + ), + ), + 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), + ), + ], + ), + ), + ), + ], + ), + ), + ), + child: child, + ); +} diff --git a/lib/widgets/file_card.dart b/lib/widgets/file_card.dart new file mode 100644 index 0000000..4c3ce43 --- /dev/null +++ b/lib/widgets/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/form_text_input.dart b/lib/widgets/form_text_input.dart deleted file mode 100644 index 492439b..0000000 --- a/lib/widgets/form_text_input.dart +++ /dev/null @@ -1,80 +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; - - const FormTextInput({ - super.key, - this.border, - this.controller, - 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( - controller: controller, - keyboardType: keyboardType, - readOnly: readOnly, - minLines: minLines, - maxLines: maxLines, - maxLength: maxLength, - inputFormatters: formatters, - textCapitalization: capitalize - ? TextCapitalization.sentences - : TextCapitalization.none, - initialValue: initialValue, - autocorrect: autocorrect, - obscureText: obscure, - onTap: onTap, - decoration: InputDecoration( - labelText: title, - border: border ?? (outlined ? null : const UnderlineInputBorder()), - suffixIcon: trailing, - ), - validator: (value) { - if ((value?.isEmpty ?? true) && required) { - return "This field is required"; - } - - for (final validator in extraValidators) { - final reason = validator(value!); - if (reason != null) return reason; - } - - return null; - }, - ); -} diff --git a/lib/widgets/highlight_wrapper.dart b/lib/widgets/highlight_wrapper.dart new file mode 100644 index 0000000..4612548 --- /dev/null +++ b/lib/widgets/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/chat_page/html/code_block.dart b/lib/widgets/html/code_block.dart similarity index 58% rename from lib/widgets/chat_page/html/code_block.dart rename to lib/widgets/html/code_block.dart index fe5b492..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,11 +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, - style: TextStyle(fontFamily: "monospace"), + minLines: 1, + maxLines: 99, + 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..5ea584f --- /dev/null +++ b/lib/widgets/html/html.dart @@ -0,0 +1,155 @@ +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: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/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), + ), + + "a" => + element.attributes["href"]?.linkInfo == null + ? null + : InlineCustomWidget( + child: MentionChip(element.attributes["href"]!, roomId), + ), + + "img" => + src == null + ? SizedBox.shrink() + : InlineCustomWidget( + alignment: PlaceholderAlignment.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..6ae2221 --- /dev/null +++ b/lib/widgets/html/mention_chip.dart @@ -0,0 +1,96 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/event.dart"; +import "package:nexus/controllers/room_summary.dart"; +import "package:nexus/controllers/user.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/content/membership.dart"; +import "package:nexus/models/room_summary.dart"; +import "package:nexus/widgets/room_summary_sheet.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!)), + ) + .whenOrNull(data: (data) => data) ?? + RoomSummary(roomId: mention), + + _ => null, + }; + + return mention == null + ? SizedBox.shrink() + : InkWell( + onTap: () async { + if (data is MembershipContent) { + context.showUserPopover(mention, roomId: roomId); + } else if (data case RoomSummary summary) { + final eventRowId = linkInfo?.eventId == null + ? null + : await ref + .read( + EventController.provider( + .new( + roomId: summary.roomId, + eventId: linkInfo!.eventId!, + ), + ).selectAsync((data) => data?.rowId), + ) + .onError((_, _) => null); + + if (!await ref.focusRoom(summary.roomId, eventRowId) && + context.mounted) { + context.showAdaptiveSheet( + (context) => Consumer( + builder: (context, ref, _) => RoomSummarySheet( + ref + .watch( + RoomSummaryController.provider( + .new(roomIdOrAlias: mention), + ), + ) + .whenOrNull(data: (data) => data) ?? + summary, + via: linkInfo?.via, + ), + ), + ); + } + } + }, + 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/invites_list.dart b/lib/widgets/invites_list.dart new file mode 100644 index 0000000..f1be8f5 --- /dev/null +++ b/lib/widgets/invites_list.dart @@ -0,0 +1,134 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/widgets/sheet_layout.dart"; +import "package:nexus/controllers/invites.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/nav_page.dart"; +import "package:nexus/models/room_summary.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/room_summary_sheet.dart"; + +class const InvitesList({super.key}) + extends HookConsumerWidget + 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 = useState(false); + + 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; + + decliningAll.value = true; + await ref + .read(InvitesController.provider.notifier) + .declineAll() + .onError(showError); + if (context.mounted) decliningAll.value = false; + } + + return Scaffold( + backgroundColor: Colors.transparent, + floatingActionButton: invites.isEmpty + ? null + : FloatingActionButton.extended( + onPressed: decliningAll.value ? null : declineAll, + icon: decliningAll.value + ? 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(), + ), + ); + } +} + +class const InviteCard(final RoomSummary invite, {super.key}) + extends StatelessWidget { + @override + Widget build(BuildContext context) { + final name = invite.name ?? invite.canonicalAlias ?? invite.roomId; + final inviterName = + invite.inviterProfile?.displayName ?? invite.invitedBy?.localpart; + + return Card( + clipBehavior: .antiAlias, + child: ListTile( + onTap: () => context.showAdaptiveSheet((_) => RoomSummarySheet(invite)), + leading: AvatarOrHash( + invite.avatarUrl, + name, + dimension: 40, + fallback: invite.isDirect + ? null + : Icon( + invite.roomType == RoomType.space + ? Icons.workspaces + : Icons.numbers, + ), + ), + 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/join_sheet.dart b/lib/widgets/join_sheet.dart new file mode 100644 index 0000000..b690e9c --- /dev/null +++ b/lib/widgets/join_sheet.dart @@ -0,0 +1,62 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/widgets/sheet_layout.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/room_summary.dart"; +import "package:nexus/helpers/extensions/get_link_info.dart"; +import "package:nexus/widgets/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/knock_sheet.dart b/lib/widgets/knock_sheet.dart new file mode 100644 index 0000000..9c6bdcb --- /dev/null +++ b/lib/widgets/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:nexus/helpers/extensions/join_room.dart"; +import "package:nexus/widgets/sheet_layout.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/lazy_loading/message_avatar.dart b/lib/widgets/lazy_loading/message_avatar.dart new file mode 100644 index 0000000..0279eca --- /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/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..e8e4791 --- /dev/null +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -0,0 +1,57 @@ +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/string_to_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) => 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: event.sender.colorHash, fontWeight: .bold), + maxLines: 1, + overflow: .ellipsis, + ), + if (event.pmp != null) + Text( + "(via ${event.sender})", + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: event.sender.colorHash, fontWeight: .bold), + maxLines: 1, + overflow: .ellipsis, + ), + ]) { + final children when shouldWrap => Wrap( + spacing: 4, + crossAxisAlignment: .center, + children: children, + ), + final children => Row( + spacing: 4, + mainAxisSize: .min, + children: [for (final child in children) Flexible(child: child)], + ), + }, + ), + }; +} diff --git a/lib/widgets/linkified_text.dart b/lib/widgets/linkified_text.dart new file mode 100644 index 0000000..1c12d22 --- /dev/null +++ b/lib/widgets/linkified_text.dart @@ -0,0 +1,26 @@ +import "package:material_ui/material_ui.dart"; +import "package:flutter_linkify/flutter_linkify.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/helpers/launch_helper.dart"; + +final class const LinkifiedText( + final String text, { + final int? maxLines, + final TextStyle? style, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => + MaterialUiCompatibilityBridge( + child: Linkify( + text: text, + maxLines: maxLines, + style: style, + options: .new(humanize: false), + onOpen: (link) => + ref.read(LaunchHelper.provider).launchUrl(.parse(link.url)), + linkStyle: .new(color: Theme.of(context).colorScheme.primary), + overflow: maxLines == null ? null : .ellipsis, + ), + ); +} diff --git a/lib/widgets/loading.dart b/lib/widgets/loading.dart index aadc43c..e92860e 100644 --- a/lib/widgets/loading.dart +++ b/lib/widgets/loading.dart @@ -1,13 +1,15 @@ -import "package:flutter/material.dart"; - -class Loading extends StatelessWidget { - const Loading({super.key}); +import "package:material_ui/material_ui.dart"; +class const Loading({super.key, final double? dimension}) + extends StatelessWidget { @override - Widget build(BuildContext context) => const Center( - child: Padding( - padding: EdgeInsets.all(16), - child: CircularProgressIndicator(), - ), - ); + Widget build(BuildContext context) => Center( + child: Padding( + padding: .all(16), + child: SizedBox.square( + dimension: dimension, + child: CircularProgressIndicator(), + ), + ), + ); } diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart new file mode 100644 index 0000000..731270d --- /dev/null +++ b/lib/widgets/member_list.dart @@ -0,0 +1,179 @@ +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/string_to_color.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/membership_status.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/divider_text.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/loading.dart"; + +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, + ) => + Builder( + builder: (context) => ListTile( + onTap: () => + context.showUserPopover( + members[index].stateKey!, + roomId: roomId, + ), + title: Text( + displayName ?? + members[index] + .stateKey! + .localpart, + overflow: .ellipsis, + style: .new( + color: members[index] + .stateKey! + .colorHash, + fontWeight: .bold, + ), + ), + subtitle: Text( + members[index].stateKey!, + overflow: .ellipsis, + ), + leading: 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/message_image.dart b/lib/widgets/message_image.dart new file mode 100644 index 0000000..969dcd3 --- /dev/null +++ b/lib/widgets/message_image.dart @@ -0,0 +1,59 @@ +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/expandable_image.dart"; +import "package:nexus/widgets/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, + 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: BoxFit.fitWidth, + loadingBuilder: (_, child, loadingProgress) => + loadingProgress == null + ? child + : switch (info?.blurHash) { + final blurHash? => + info?.width == null || info?.height == null + ? SizedBox( + width: 200, + height: 200, + child: BlurHash(hash: blurHash), + ) + : SizedBox( + width: info!.width!.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/mutual_rooms_sheet.dart b/lib/widgets/mutual_rooms_sheet.dart new file mode 100644 index 0000000..68b90e5 --- /dev/null +++ b/lib/widgets/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: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/avatar_or_hash.dart"; +import "package:nexus/widgets/sheet_layout.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/notifications_list.dart b/lib/widgets/notifications_list.dart new file mode 100644 index 0000000..0b0741f --- /dev/null +++ b/lib/widgets/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/error_dialog.dart"; +import "package:nexus/widgets/highlight_wrapper.dart"; +import "package:nexus/widgets/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 EdgeInsets.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/pages/chat.dart b/lib/widgets/pages/chat.dart new file mode 100644 index 0000000..9e18da7 --- /dev/null +++ b/lib/widgets/pages/chat.dart @@ -0,0 +1,85 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/init_complete.dart"; +import "package:nexus/controllers/selected_room.dart"; +import "package:nexus/controllers/notification_request.dart"; +import "package:nexus/helpers/extensions/is_desktop.dart"; +import "package:nexus/widgets/appbar.dart"; +import "package:nexus/widgets/sidebar.dart"; +import "package:nexus/widgets/sync_status_banner.dart"; +import "package:nexus/widgets/room_chat/room_chat.dart"; +import "package:nexus/widgets/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]); + + 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/select_server.dart b/lib/widgets/pages/select_server.dart new file mode 100644 index 0000000..66c40a5 --- /dev/null +++ b/lib/widgets/pages/select_server.dart @@ -0,0 +1,239 @@ +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/appbar.dart"; +import "package:nexus/widgets/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; + } + + 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) { + 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([ + 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..69513b8 --- /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/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..76bd94d --- /dev/null +++ b/lib/widgets/pages/verify.dart @@ -0,0 +1,114 @@ +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/models/client_state.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/pages/settings.dart"; +import "package:nexus/widgets/appbar.dart"; +import "package:nexus/helpers/required_validator_helper.dart"; +import "package:nexus/widgets/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 { + isLoading.value = true; + + try { + if (formKey.value.currentState?.validate() != true) { + return; + } + + inputError.value = await ref + .read(ClientController.provider.notifier) + .verify(passphraseController.text); + } finally { + isLoading.value = false; + } + } + + return Scaffold( + appBar: Appbar( + actions: .new([ + IconButton( + onPressed: () => + showDialog(context: context, builder: (_) => SettingsPage()), + icon: Icon(Icons.settings), + ), + ]), + ), + body: switch (verificationState) { + null || VerificationState(stateChecked: false) => const Loading(), + // Nothing to verify with yet, so go straight to setting it up. + 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: () => 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/pinned_events_drawer.dart b/lib/widgets/pinned_events_drawer.dart new file mode 100644 index 0000000..cff6ad6 --- /dev/null +++ b/lib/widgets/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/error_dialog.dart"; +import "package:nexus/widgets/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/players/audio.dart b/lib/widgets/players/audio.dart new file mode 100644 index 0000000..3e254b0 --- /dev/null +++ b/lib/widgets/players/audio.dart @@ -0,0 +1,66 @@ +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 SizedBox( + height: 60, + child: Card( + color: Theme.of(context).colorScheme.surfaceContainer, + child: Padding( + padding: .symmetric(horizontal: 8), + child: PlayerControls( + player, + loading: !loaded.value, + 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..6226260 --- /dev/null +++ b/lib/widgets/players/player_controls.dart @@ -0,0 +1,109 @@ +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"; + +/// 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 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 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}) => Slider( + 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: [ + slider(padding: const .symmetric(horizontal: 12)), + Row( + children: [ + playButton, + Expanded( + child: Text( + "${format(position)} / ${format(duration)}", + style: textStyle, + maxLines: 1, + overflow: .ellipsis, + ), + ), + ...actions, + ], + ), + ], + ) + : Row( + children: [ + playButton, + SizedBox(width: 8), + Text(format(position), style: textStyle), + Expanded(child: slider()), + Text(format(duration), style: textStyle), + ...actions, + ], + ), + ); + } +} diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart new file mode 100644 index 0000000..ef56024 --- /dev/null +++ b/lib/widgets/players/video.dart @@ -0,0 +1,136 @@ +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 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: (_) => + // Until media_kit moves to material_ui, this Material is needed. + Material( + type: .transparency, + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: .opaque, + onTap: loaded.value ? 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.value, + 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, + ), + ); + }, + ), + ]), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/popover_layout.dart b/lib/widgets/popover_layout.dart new file mode 100644 index 0000000..0ef8ebe --- /dev/null +++ b/lib/widgets/popover_layout.dart @@ -0,0 +1,33 @@ +import "dart:math"; + +import "package:material_ui/material_ui.dart"; + +/// Positions a popover below [anchor], or above it if there isn't room below, +/// keeping it fully on screen. +class const PopoverLayout(final Rect anchor) extends SingleChildLayoutDelegate { + static const margin = 8.0; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) => + constraints.loosen().deflate(const .all(margin)); + + @override + Offset getPositionForChild(Size size, Size childSize) { + final fitsBelow = anchor.bottom + childSize.height + margin <= size.height; + + return .new( + anchor.left.clamp( + margin, + max(margin, size.width - childSize.width - margin), + ), + (fitsBelow ? anchor.bottom : anchor.top - childSize.height).clamp( + margin, + max(margin, size.height - childSize.height - margin), + ), + ); + } + + @override + bool shouldRelayout(PopoverLayout oldDelegate) => + oldDelegate.anchor != anchor; +} diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reaction_row.dart new file mode 100644 index 0000000..0b90781 --- /dev/null +++ b/lib/widgets/reaction_row.dart @@ -0,0 +1,112 @@ +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:nexus/helpers/mxc_image.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/main.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; + +class 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]?.contains(clientState!.userId) ?? + false; + return Tooltip( + message: reactors?[reaction]?.join(", ") ?? "", + child: ChoiceChip( + showCheckmark: false, + selected: selected, + label: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + Flexible( + child: reaction.startsWith("mxc://") + ? Image( + height: 20, + image: MxcImage( + ref, + .new(mxc: Uri.parse(reaction)), + ), + errorBuilder: (context, _, _) => Icon( + Icons.broken_image, + color: Theme.of(context) + .colorScheme + .error, + ), + ) + : Text(reaction, overflow: .ellipsis), + ), + 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, + )).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/read_receipts_row.dart b/lib/widgets/read_receipts_row.dart new file mode 100644 index 0000000..e3f206f --- /dev/null +++ b/lib/widgets/read_receipts_row.dart @@ -0,0 +1,112 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.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_adaptive_sheet.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/avatar_ring.dart"; +import "package:nexus/widgets/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 + : 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_sheet.dart b/lib/widgets/read_receipts_sheet.dart new file mode 100644 index 0000000..aabdfb3 --- /dev/null +++ b/lib/widgets/read_receipts_sheet.dart @@ -0,0 +1,54 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.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/avatar_or_hash.dart"; +import "package:nexus/widgets/sheet_layout.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/recovery_key_sheet.dart b/lib/widgets/recovery_key_sheet.dart new file mode 100644 index 0000000..68ec682 --- /dev/null +++ b/lib/widgets/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: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"; +import "package:nexus/widgets/sheet_layout.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/renderers/event.dart b/lib/widgets/renderers/event.dart new file mode 100644 index 0000000..47750fa --- /dev/null +++ b/lib/widgets/renderers/event.dart @@ -0,0 +1,331 @@ +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:nexus/controllers/event.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/show_adaptive_menu.dart"; +import "package:nexus/helpers/hooks/chat_scroll.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/history_visibility.dart"; +import "package:nexus/models/content/join_rules.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/content/pinned_events.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/content/server_acl.dart"; +import "package:nexus/models/content/topic.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/renderers/message.dart"; +import "package:nexus/widgets/reaction_row.dart"; +import "package:nexus/widgets/read_receipts_row.dart"; +import "package:nexus/widgets/renderers/membership.dart"; +import "package:nexus/widgets/renderers/generic_event.dart"; +import "package:nexus/widgets/swipe_to_reply.dart"; +import "package:nexus/widgets/timestamp.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 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, + 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 (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 = (ref + .watch(SettingsController.provider) + .maybeWhen( + orElse: () => false, + data: (settings) => settings.hideMembershipEvents, + )); + + // Knocks and their outcomes stay visible, as moderators need to act on them + if (hideMembershipEvents && + event.content is MembershipContent && + ![event.content, event.previousContent].any( + (content) => content is MembershipContent && content.status == .knock, + )) { + return SizedBox.shrink(); + } + + final isGrouped = + (previousEvent?.isShownAsMessage ?? false) && + event.sender == previousEvent?.sender && + event.pmp?.id == previousEvent?.pmp?.id; + + GenericEventRenderer generic(IconData icon, String text) => + .new(icon, maxLines: maxLines, [ + WidgetSpan( + alignment: .middle, + child: MessageDisplayname(event, shouldWrap: maxLines == null), + ), + TextSpan(text: text), + ]); + + final child = event.isShownAsMessage + ? MessageRenderer( + event, + onTapReply: jumpToEvent == null + ? null + : () async { + final replyEvent = await ref.read( + EventController.provider( + .new(roomId: roomId, eventId: event.replyTo!), + ).future, + ); + if (replyEvent != null) { + await jumpToEvent!(replyEvent.rowId); + } + }, + isGrouped: isGrouped, + maxLines: maxLines, + textOnly: textOnly, + ) + : event.redactedBy != null || event.relationType == "m.replace" + ? null + : switch (event.content) { + Content(:final parseError?, :final stackTrace) => Row( + children: [ + 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() => generic(Icons.add, "created the room"), + PowerLevelsContent() => generic( + Icons.power, + "changed the room's power levels", + ), + JoinRulesContent() => generic( + Icons.rule, + "changed the room's join rules", + ), + TopicContent() => generic( + Icons.description, + "updated the room topic", + ), + 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)", + }}", + ), + PinnedEventsContent() => generic( + Icons.push_pin, + "pinned/unpinned some events", + ), + ServerACLContent() => generic( + Icons.list, + "updated the server ban list", + ), + CanonicalAliasContent(:final alias, :final altAliases) => generic( + Icons.numbers, + switch ([ + if (event.previousContent case CanonicalAliasContent( + alias: final prevAlias, + altAliases: final prevAltAliases, + )) ...[ + if (prevAlias != alias) + if (alias == null) + "removed the room's canonical alias" + else + "changed the room's canonical alias to $alias", + + if (prevAltAliases + .remove(alias ?? "") + .remove(prevAlias ?? "") != + altAliases.remove(alias ?? "").remove(prevAlias ?? "")) + "changed the room's aliases", + ] else ...[ + if (alias != null) "set the room's canonical alias", + if (altAliases.isNotEmpty) "set the room's aliases", + ], + ]) { + [] => "did something related to room aliases", + List prev => prev.join(" and "), + }, + ), + _ => null, + }; + + final contextMenuCallback = getEventOptions == null + ? null + : (PositionedGestureDetails details) => context.showAdaptiveMenu( + getEventOptions!(event).toList(), + globalPosition: details.globalPosition, + ); + + 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) + Timestamp(event.timestamp), + ], + ), + ), + ), + ), + ), + ), + ), + + ...[ + if (child is! MessageRenderer) ReactionRow(event), + + // 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.sendError != null && event.sendError != "not sent") + 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, + )).notifier, + ) + .hideClientSticky(event.rowId), + ), + ], + ), + ), + ].map((child) => Padding(padding: .only(left: 4), child: child)), + ] + else if (textOnly) + Text("Unknown event type", style: errorStyle), + ], + ); + } +} diff --git a/lib/widgets/renderers/generic_event.dart b/lib/widgets/renderers/generic_event.dart new file mode 100644 index 0000000..101913c --- /dev/null +++ b/lib/widgets/renderers/generic_event.dart @@ -0,0 +1,37 @@ +import "package:collection/collection.dart"; +import "package:material_ui/material_ui.dart"; + +class const GenericEventRenderer( + final IconData icon, + final List children, { + final int? maxLines, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) => Padding( + padding: .symmetric(vertical: 4), + child: Row( + spacing: 8, + mainAxisSize: .min, + children: [ + Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)), + Flexible( + child: Text.rich( + TextSpan( + children: children + .expandIndexed( + (index, child) => [ + if (index > 0) TextSpan(text: " "), + child, + ], + ) + .toList(), + ), + maxLines: maxLines ?? 999, + overflow: .ellipsis, + ), + ), + ], + ), + ); +} diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart new file mode 100644 index 0000000..b1811f0 --- /dev/null +++ b/lib/widgets/renderers/membership.dart @@ -0,0 +1,70 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/helpers/extensions/string_to_color.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/renderers/generic_event.dart"; + +class const MembershipRenderer( + final Event event, { + final int? maxLines, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + assert( + event.content is MembershipContent, + "Make sure to only pass membership events to MembershipRenderer", + ); + + final previous = switch (event.previousContent) { + MembershipContent(:final status) => status, + _ => null, + }; + + return switch (event.content) { + MembershipContent content => GenericEventRenderer( + Icons.people, + maxLines: maxLines, + [ + WidgetSpan( + alignment: .middle, + child: InkWell( + onTap: () => context.showUserPopover( + event.stateKey!, + roomId: event.roomId, + ), + child: Text( + overflow: .ellipsis, + content.displayName ?? event.stateKey!.localpart, + maxLines: 1, + style: .new(color: event.sender.colorHash, 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", + }} the room${event.sender == event.stateKey ? "" : " by"}", + ), + if (event.sender != event.stateKey) + WidgetSpan( + alignment: .middle, + child: MessageDisplayname(event, shouldWrap: maxLines == null), + ), + 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..598e9f9 --- /dev/null +++ b/lib/widgets/renderers/message.dart @@ -0,0 +1,277 @@ +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/models/content/encrypted.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/file_card.dart"; +import "package:nexus/widgets/html/html.dart"; +import "package:nexus/widgets/lazy_loading/message_avatar.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/linkified_text.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/message_image.dart"; +import "package:nexus/widgets/reaction_row.dart"; +import "package:nexus/widgets/timestamp.dart"; +import "package:nexus/widgets/url_preview.dart"; +import "package:nexus/widgets/event_preview.dart"; +import "package:nexus/widgets/players/video.dart"; +import "package:nexus/widgets/players/audio.dart"; + +class const MessageRenderer( + final Event event, { + final VoidCallback? onTapReply, + 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) { + 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), + ), + // TODO: Handle locations + // LocationMessageContent(:final body , :final geoUri) => + MessageContent content => switch (content) { + TextMessageContent(:final body, :final formattedBody, :final format) || + NoticeMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + EmoteMessageContent(:final body, :final formattedBody, :final format) || + ImageMessageContent(:final body, :final formattedBody, :final format) || + VideoMessageContent(:final body, :final formattedBody, :final format) || + AudioMessageContent(:final body, :final formattedBody, :final format) || + FileMessageContent( + :final body, + :final formattedBody, + :final format, + ) => Column( + crossAxisAlignment: .start, + children: [ + format == .html && !textOnly + ? Html( + roomId: event.roomId, + textStyle: textStyle, + formattedBody!.replaceAllMapped( + RegExp( + r"(]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)", + caseSensitive: false, + dotAll: true, + ), + (m) { + // If it's already an tag, leave it unchanged + if (m.group(1) != null) { + return m.group(1)!; + } + + // Otherwise, wrap the bare URL + final url = m.group(2)!; + return "$url"; + }, + ), + ) + : LinkifiedText( + body.isEmpty ? content.filename ?? "" : body, + 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), + ], + ], + ), + LocationMessageContent(:final String? body) || + 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( + height: 48, + child: switch (ref.watch( + EventController.provider( + .new( + roomId: event.roomId, + eventId: event.replyTo!, + ), + ), + )) { + AsyncData(:final value?) || + AsyncLoading( + :final value?, + ) => EventPreview(value), + AsyncError _ => Text( + "An error occurred while fetching the reply", + style: errorStyle, + ), + _ => Loading(dimension: 12), + }, + ), + ), + ), + ), + rendered, + if (!textOnly) ReactionRow(event), + ], + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/widgets/reset_encryption_sheet.dart b/lib/widgets/reset_encryption_sheet.dart new file mode 100644 index 0000000..8482a8f --- /dev/null +++ b/lib/widgets/reset_encryption_sheet.dart @@ -0,0 +1,162 @@ +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/client_state.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/client_state.dart"; +import "package:nexus/widgets/recovery_key_sheet.dart"; +import "package:nexus/widgets/sheet_layout.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/room_appbar.dart b/lib/widgets/room_appbar.dart new file mode 100644 index 0000000..248661e --- /dev/null +++ b/lib/widgets/room_appbar.dart @@ -0,0 +1,85 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/widgets/appbar.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/count_badge.dart"; +import "package:nexus/widgets/room_menu_button.dart"; +import "package:nexus/widgets/room_summary_sheet.dart"; +import "package:nexus/widgets/room_title.dart"; +import "package:nexus/widgets/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, + 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: onOpenSearch?.call, + icon: Icon(Icons.search), + tooltip: "Search this room", + ), + 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), + ], + ), + ); + } +} diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart new file mode 100644 index 0000000..c949344 --- /dev/null +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -0,0 +1,87 @@ +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/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 int? highlightedEvent, + 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, + super.key, +}) extends StatelessWidget { + Widget eventRow( + int eventRowId, + int? previousEventRowId, { + required Key key, + }) => HighlightWrapper( + EventRenderer( + eventRowId, + previousEventRowId: previousEventRowId, + roomId: chat.roomId, + jumpToEvent: jumpToEvent, + getEventOptions: getEventOptions, + onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId), + readReceiptsChat: chat, + ), + key: key, + isHighlighted: highlightedEvent == eventRowId, + ); + + @override + Widget build(BuildContext context) => CustomScrollView( + reverse: true, + center: scroll.centerKey, + keyboardDismissBehavior: .onDrag, + controller: scroll.scrollController, + slivers: [ + SliverToBoxAdapter( + child: Column( + children: [ + // Makes room for the typing indicator, with a small gap above it, + // so it doesn't cover messages + AnimatedContainer( + duration: Durations.medium2, + curve: Curves.easeInOutCubicEmphasized, + height: typingIndicatorHeight == 0 + ? 0 + : typingIndicatorHeight + 4, + ), + SizedBox(height: composerHeight), + ], + ), + ), + + 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/room_chat.dart b/lib/widgets/room_chat/room_chat.dart new file mode 100644 index 0000000..e7fd6e0 --- /dev/null +++ b/lib/widgets/room_chat/room_chat.dart @@ -0,0 +1,352 @@ +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:measure_size/measure_size.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/power_level.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/controllers/rooms.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/models/relation_type.dart"; +import "package:nexus/widgets/composer/composer.dart"; +import "package:nexus/widgets/composer/typing_indicator.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/pinned_events_drawer.dart"; +import "package:nexus/widgets/member_list.dart"; +import "package:nexus/widgets/room_appbar.dart"; +import "package:nexus/widgets/search_drawer.dart"; +import "package:nexus/widgets/sync_status_banner.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/room_chat/chat_timeline.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 relatedEvent = useState(null); + final relationType = useState(RelationType.reply); + final pendingJump = ref.read(JumpRequestController.provider); + final contextualEvent = useState( + pendingJump != null && pendingJump.roomId == this.roomId + ? pendingJump.rowId + : null, + ); + final highlightedEvent = useState(null); + + final composerSize = useState(64); + final typingIndicatorSize = useState(0); + + 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 jumpRowId = ref.watch( + JumpRequestController.provider.select( + (request) => request?.roomId == roomId ? request?.rowId : null, + ), + ); + + final ChatParams chat = ( + roomId: roomId, + contextualEvent: contextualEvent.value, + ); + 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); + } + }, + ); + + final lastComposerHeight = useRef(composerSize.value); + + useEffect(() { + final delta = composerSize.value - lastComposerHeight.value; + lastComposerHeight.value = composerSize.value; + + if (delta != 0) { + final wasAtBottom = scroll.atBottom; + + WidgetsBinding.instance.addPostFrameCallback((_) { + final controller = scroll.scrollController; + if (!controller.hasClients) return; + + final position = controller.position; + controller.jumpTo( + wasAtBottom ? position.minScrollExtent : position.pixels - delta, + ); + }); + } + + return null; + }, [composerSize.value]); + + final composerNode = useFocusNode( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && event.logicalKey == .escape) { + relatedEvent.value = null; + return .handled; + } + + return .ignored; + }, + ); + + Future jumpToEvent(int rowId) async { + highlightedEvent.value = rowId; + + await scroll.jumpToEvent(rowId); + await Future.delayed(Durations.extralong1, () { + if (highlightedEvent.value == rowId) { + highlightedEvent.value = null; + } + }); + } + + 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]); + + void onRelation(int rowId, RelationType type) { + relatedEvent.value = rowId; + relationType.value = type; + composerNode.requestFocus(); + } + + IList getEventOptions(Event event) => + event.buildEventOptions( + context: context, + ref: ref, + roomId: roomId, + userId: userId, + onRelation: onRelation, + ); + + final swipeToReply = ref.watch( + SettingsController.provider.select( + (settings) => settings.value?.swipeToReply ?? false, + ), + ); + final canReply = ref.watch( + PowerLevelController.provider(.new(eventType: .message, roomId: roomId)), + ); + + return Scaffold( + endDrawerEnableOpenDragGesture: false, + endDrawer: SearchDrawer( + roomId, + getEventOptions: getEventOptions, + jumpToEvent: jumpToEvent, + ), + body: Builder( + builder: (searchContext) => 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, + ), + body: Column( + children: [ + const SyncStatusBanner(), + Expanded( + child: Row( + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: .symmetric(horizontal: 4), + child: switch (controllerData) { + AsyncLoading(value: _?) || + AsyncData(value: _) => ChatTimeline( + scroll: scroll, + chat: chat, + jumpToEvent: jumpToEvent, + getEventOptions: getEventOptions, + onSwipe: swipeToReply && canReply + ? (rowId) => onRelation(rowId, .reply) + : null, + highlightedEvent: highlightedEvent.value, + 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: Composer( + roomId, + node: composerNode, + onSend: + ( + text, { + required shouldMention, + required tags, + }) => notifier + .send( + text, + tags: tags, + relationType: relationType.value, + shouldMention: shouldMention, + relatedEventRowId: + relatedEvent.value, + ) + .onError(showError), + relationType: relationType.value, + relatedEvent: relatedEvent.value, + onDismiss: () { + relatedEvent.value = null; + composerNode.requestFocus(); + }, + ), + ), + ), + ], + ), + ), + + if (memberListOpened && showMembersByDefault) + MemberList(roomId), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/room_list.dart b/lib/widgets/room_list.dart new file mode 100644 index 0000000..f8d366e --- /dev/null +++ b/lib/widgets/room_list.dart @@ -0,0 +1,355 @@ +import "dart:math"; + +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:m3e_design/m3e_design.dart"; +import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; +import "package:nexus/helpers/extensions/get_all_child_rooms.dart"; +import "package:nexus/helpers/extensions/room_destinations.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/widgets/adaptive_menu_button.dart"; +import "package:nexus/models/nav_page.dart"; +import "package:nexus/widgets/appbar.dart"; +import "package:nexus/widgets/room_title.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/create_room_sheet.dart"; +import "package:nexus/widgets/start_dm_sheet.dart"; +import "package:nexus/widgets/divider_widget.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/pages/settings.dart"; +import "package:nexus/widgets/join_sheet.dart"; +import "package:nexus/widgets/room_summary_sheet.dart"; +import "package:nexus/widgets/room_menu_button.dart"; +import "package:nexus/widgets/space_hierarchy_sheet.dart"; +import "package:nexus/controllers/key.dart"; +import "package:nexus/controllers/selected_room.dart"; +import "package:nexus/controllers/selected_space.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/spaces.dart"; + +// Needed for navigation_rail_m3e (#65). +import "package:flutter/material.dart" as old_mat; + +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()), + ], + ); + } +} + +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 MaterialUiCompatibilityBridge( + child: Builder( + builder: (context) => old_mat.Theme( + data: old_mat.Theme.of(context).copyWith( + extensions: [ + NavigationRailM3ETheme( + itemCollapsedHeight: 48, + itemVerticalGap: 0, + ), + ], + ), + child: 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 IconButton( + tooltip: "Explore other rooms (Not yet implemented)", + onPressed: null, + icon: Icon(Icons.explore), + ), + IconButton( + tooltip: "Open settings", + onPressed: () => showDialog( + context: context, + builder: (_) => SettingsPage(), + ), + icon: const Icon(Icons.settings), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +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; + + 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: MaterialUiCompatibilityBridge( + child: Builder( + builder: (context) { + final theme = old_mat.Theme.of(context); + return old_mat.Theme( + data: theme.copyWith( + extensions: [ + NavigationRailM3ETheme( + itemExpandedHeight: 48, + iconLabelGap: 16, + sectionHeaderSpacingTop: 0, + ), + // Otherwise the FAB label falls back to Roboto + M3ETheme.defaults(theme.colorScheme).copyWith( + typography: .defaultFor( + theme.brightness, + baseOverride: theme.textTheme, + ), + ), + ], + ), + child: 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, + selectedSpace.allChildRooms.indexWhere( + (room) => room.metadata?.id == selectedRoomId, + ), + ), + sections: [ + .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(selectedSpace.allChildRooms[value].metadata?.id); + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/widgets/room_menu_button.dart b/lib/widgets/room_menu_button.dart new file mode 100644 index 0000000..38ad847 --- /dev/null +++ b/lib/widgets/room_menu_button.dart @@ -0,0 +1,156 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_leave_room_sheet.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/widgets/sheet_layout.dart"; +import "package:flutter/services.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/room_settings_sections.dart"; +import "package:nexus/widgets/adaptive_menu_button.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(), + 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: (_) => [ + 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 vias = ref.read(ViaController.provider(room!)); + + await Clipboard.setData( + .new( + text: "matrix:roomid/${room!.metadata?.id.substring(1)}$vias", + ), + ); + }, + child: ListTile( + leading: Icon(Icons.link), + title: Text("Copy Link"), + ), + ), + PopupMenuItem( + onTap: () => 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: TextStyle(color: danger)), + ), + ), + ], + ], + ); + } +} diff --git a/lib/widgets/room_summary_sheet.dart b/lib/widgets/room_summary_sheet.dart new file mode 100644 index 0000000..dd7bc9b --- /dev/null +++ b/lib/widgets/room_summary_sheet.dart @@ -0,0 +1,203 @@ +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:nexus/widgets/sheet_layout.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/show_adaptive_sheet.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/knock_sheet.dart"; +import "package:nexus/widgets/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(Icons.numbers), + ), + ), + Expanded( + child: Text( + summary.name ?? summary.canonicalAlias ?? "Unnamed Room", + 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(); + + 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); + + context + .joinRoom( + .new(roomIdOrAlias: summary.roomId, fromInvite: true), + name: name, + ) + .then((roomId) { + if (roomId != null) { + container + .read(InvitesController.provider.notifier) + .remove(summary.roomId); + } + }); + 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), + ), + // Invite state never includes the member count + if (summary.joinedMembers != null || !isInvite) + ListTile( + title: Text( + "${summary.joinedMembers ?? "Unknown number of"} members", + ), + leading: Icon(Icons.people), + ), + ListTile( + title: Text( + summary.canonicalAlias == null ? "Room ID" : "Canonical Alias", + ), + subtitle: SelectableText( + summary.canonicalAlias ?? summary.roomId, + ), + leading: Icon(Icons.numbers), + ), + if (summary.topic != null) + ListTile( + title: Text("Topic"), + subtitle: LinkifiedText(summary.topic!), + 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/room_title.dart b/lib/widgets/room_title.dart new file mode 100644 index 0000000..94eacc8 --- /dev/null +++ b/lib/widgets/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/search_drawer.dart b/lib/widgets/search_drawer.dart new file mode 100644 index 0000000..03418df --- /dev/null +++ b/lib/widgets/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/error_dialog.dart"; +import "package:nexus/widgets/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/settings/add_users_power_level_sheet.dart b/lib/widgets/settings/add_users_power_level_sheet.dart new file mode 100644 index 0000000..2603fd5 --- /dev/null +++ b/lib/widgets/settings/add_users_power_level_sheet.dart @@ -0,0 +1,112 @@ +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/avatar_or_hash.dart"; +import "package:nexus/widgets/settings/power_level_field.dart"; +import "package:nexus/widgets/settings/search_chooser.dart"; +import "package:nexus/widgets/sheet_layout.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 HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final chosen = useState(const ISet.empty()); + final error = useState(null); + final searchController = useTextEditingController(); + final levelController = useTextEditingController(text: "0"); + final level = PowerLevelField.levelFrom( + useValueListenable(levelController).text, + ownLevel, + ); + + 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 (existing.contains(id)) { + error.value = "Already has a power level"; + return; + } + chosen.value = chosen.value.add(id); + searchController.clear(); + } + + 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: [ + SearchChooser( + controller: searchController, + chosen: chosen.value, + options: members.keys + .where((id) => !existing.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: (id) => chosen.value = chosen.value.remove(id), + 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: "Add user", + hint: "Search members, or enter a user ID", + removeTooltip: "Remove user", + error: error.value, + 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..d517f98 --- /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:nexus/widgets/sheet_layout.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..6ba42fb --- /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:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/settings/search_chooser.dart"; +import "package:nexus/widgets/sheet_layout.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/dialog_list_tile.dart b/lib/widgets/settings/dialog_list_tile.dart new file mode 100644 index 0000000..b31182f --- /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:nexus/helpers/extensions/show_adaptive_sheet.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: TextOverflow.ellipsis, + style: onChanged == null || saving.active + ? .new(color: Colors.grey) + : null, + ), + ), + ), + ); + } +} 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/local_aliases_sheet.dart b/lib/widgets/settings/local_aliases_sheet.dart new file mode 100644 index 0000000..def7db9 --- /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: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"; +import "package:nexus/widgets/sheet_layout.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/power_level_entry_sheet.dart b/lib/widgets/settings/power_level_entry_sheet.dart new file mode 100644 index 0000000..9d5e457 --- /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:nexus/widgets/settings/power_level_field.dart"; +import "package:nexus/widgets/sheet_layout.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..5b7d4e6 --- /dev/null +++ b/lib/widgets/settings/power_level_tile.dart @@ -0,0 +1,120 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/widgets/settings/power_level_field.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Sets the power level needed for something. +class const PowerLevelTile({ + required final Widget leading, + required final String title, + required final String? description, + 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, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final enabled = onChanged != null && value <= 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", + style: enabled ? null : .new(color: Colors.grey), + ), + ), + if (onRemoved != null) + IconButton( + tooltip: "Remove", + icon: Icon(Icons.delete), + onPressed: enabled ? remove : null, + ), + ], + ), + onTap: () => context.showAdaptiveSheet( + (context) => HookBuilder( + builder: (context) { + final controller = useTextEditingController(text: "$value"); + 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..9f9512e --- /dev/null +++ b/lib/widgets/settings/radio_sheet.dart @@ -0,0 +1,55 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/sheet_layout.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: 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..feadcdd --- /dev/null +++ b/lib/widgets/settings/search_chooser.dart @@ -0,0 +1,142 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.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.trim().toLowerCase(); + final shown = options + .where( + (id) => + !chosen.contains(id) && + (nameOf(id).toLowerCase().contains(query) || + id.toLowerCase().contains(query)), + ) + .toIList(); + + 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..7f6c527 --- /dev/null +++ b/lib/widgets/settings/settings_layout.dart @@ -0,0 +1,256 @@ +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/models/settings_category.dart"; +import "package:nexus/widgets/pages/settings_category.dart"; +import "package:nexus/widgets/divider_text.dart"; +import "package:nexus/widgets/highlight_wrapper.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 query = controller.text.toLowerCase(); + + final matches = categories.values.flattenedToList + .asMap() + .entries + .expand( + (categoryEntry) => categoryEntry.value.settings + .asMap() + .entries + .where( + (settingEntry) => + settingEntry.value.title.toLowerCase().contains( + query, + ) || + settingEntry.value.description + .toLowerCase() + .contains(query), + ) + .map( + (settingEntry) => ( + (categoryEntry.key, settingEntry.key), + settingEntry.value, + ), + ), + ) + .toIList(); + + 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: [ + MaterialUiCompatibilityBridge( + child: 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..6f97eb0 --- /dev/null +++ b/lib/widgets/settings/text_setting_tile.dart @@ -0,0 +1,70 @@ +import "dart:async"; + +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/widgets/sheet_layout.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/sheet_layout.dart b/lib/widgets/sheet_layout.dart new file mode 100644 index 0000000..9a15a1d --- /dev/null +++ b/lib/widgets/sheet_layout.dart @@ -0,0 +1,42 @@ +import "package:material_ui/material_ui.dart"; + +class const SheetLayout({ + final Widget? title, + required final List children, + required final List actions, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) => Padding( + padding: .only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + if (title case final title?) + Padding( + padding: const .fromLTRB(24, 24, 24, 0), + child: DefaultTextStyle( + style: Theme.of(context).textTheme.headlineSmall!, + child: title, + ), + ), + Flexible( + child: SingleChildScrollView( + padding: .fromLTRB(24, title == null ? 24 : 12, 24, 12), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + spacing: 12, + children: children, + ), + ), + ), + Padding( + padding: const .fromLTRB(24, 0, 24, 24), + child: Row(mainAxisAlignment: .end, spacing: 8, children: actions), + ), + ], + ), + ); +} diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart new file mode 100644 index 0000000..3ed8daa --- /dev/null +++ b/lib/widgets/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/count_badge.dart"; +import "package:nexus/widgets/invites_list.dart"; +import "package:nexus/widgets/notifications_list.dart"; +import "package:nexus/widgets/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/space_hierarchy_sheet.dart b/lib/widgets/space_hierarchy_sheet.dart new file mode 100644 index 0000000..33a891d --- /dev/null +++ b/lib/widgets/space_hierarchy_sheet.dart @@ -0,0 +1,274 @@ +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/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/show_adaptive_sheet.dart"; +import "package:nexus/helpers/extensions/show_leave_room_sheet.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/space_hierarchy.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/knock_sheet.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/room_summary_sheet.dart"; +import "package:nexus/widgets/sheet_layout.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 level( + String spaceId, + SpaceHierarchy hierarchy, + ISet ancestors, + ) => HookConsumer( + builder: (context, ref, _) { + final loadingMore = useState(false); + + final byId = IMap.fromValues( + keyMapper: (room) => room.summary.roomId, + values: hierarchy.rooms, + ); + 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.name ?? + summary.canonicalAlias ?? + "Unnamed Room"; + + return ListTile( + leading: AvatarOrHash( + summary.avatarUrl, + name, + fallback: const Icon(Icons.numbers), + ), + title: Text(name, maxLines: 1, overflow: .ellipsis), + subtitle: summary.topic == null + ? null + : Text( + summary.topic!, + maxLines: 2, + overflow: .ellipsis, + ), + onTap: () => showSummary(room, child), + trailing: membershipButton(room, child, name: name), + ); + }, + ), + 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.name ?? + summary.canonicalAlias ?? + "Unnamed Space"; + 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: const Icon(Icons.workspaces), + ), + 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), + ], + ), + ), + 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( + loading: () => + const Loading(dimension: 24), + data: (hierarchy) => level( + summary.roomId, + hierarchy, + ancestors.add(child.roomId), + ), + ), + ) + : const SizedBox(width: .infinity), + ), + ], + ); + }, + ), + if (hierarchy.nextBatch != null) + Center( + child: 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"), + ), + ), + ], + ); + }, + ); + + return ref + .watch(SpaceHierarchyController.provider(spaceId)) + .betterWhen( + 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?.name ?? space?.canonicalAlias ?? "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/start_dm_sheet.dart b/lib/widgets/start_dm_sheet.dart new file mode 100644 index 0000000..4952b0a --- /dev/null +++ b/lib/widgets/start_dm_sheet.dart @@ -0,0 +1,78 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/create_room.dart"; +import "package:nexus/widgets/sheet_layout.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/swipe_to_reply.dart b/lib/widgets/swipe_to_reply.dart new file mode 100644 index 0000000..c02b310 --- /dev/null +++ b/lib/widgets/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/sync_status_banner.dart b/lib/widgets/sync_status_banner.dart new file mode 100644 index 0000000..3f1e47e --- /dev/null +++ b/lib/widgets/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/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: TextStyle( + color: banner.foreground, + fontWeight: .bold, + ), + ), + ), + if (details.isNotEmpty) + Icon(Icons.info_outline, color: banner.foreground), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/timestamp.dart b/lib/widgets/timestamp.dart new file mode 100644 index 0000000..bcdabd1 --- /dev/null +++ b/lib/widgets/timestamp.dart @@ -0,0 +1,17 @@ +import "package:flutter/material.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/timezone_field.dart b/lib/widgets/timezone_field.dart new file mode 100644 index 0000000..aeab833 --- /dev/null +++ b/lib/widgets/timezone_field.dart @@ -0,0 +1,102 @@ +import "dart:async"; + +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/timezones.dart"; +import "package:nexus/helpers/extensions/is_desktop.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 + .where((timezone) => timezone.toLowerCase().contains(query)) + .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/url_preview.dart b/lib/widgets/url_preview.dart new file mode 100644 index 0000000..6403427 --- /dev/null +++ b/lib/widgets/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/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart new file mode 100644 index 0000000..58e29d2 --- /dev/null +++ b/lib/widgets/user_bottom_sheet.dart @@ -0,0 +1,477 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:dynamic_color/dynamic_color.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/sheet_layout.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:intl/intl.dart"; +import "package:m3e_buttons/m3e_buttons.dart"; +import "package:nexus/controllers/client.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/show_adaptive_sheet.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/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/avatar_or_hash.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/editable_avatar.dart"; +import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/settings/inline_text_field.dart"; +import "package:nexus/widgets/start_dm_sheet.dart"; +import "package:nexus/widgets/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, + ), + ); + + // Green for accepting knocks, harmonized so it fits the current theme + final acceptScheme = ColorScheme.fromSeed( + seedColor: Colors.green.harmonizeWith(theme.colorScheme.primary), + brightness: theme.brightness, + ); + + 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, + children: [ + 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: acceptScheme.primary, + foregroundColor: acceptScheme.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/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index fd8ccf3..077376d 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,38 +6,34 @@ #include "generated_plugin_registrant.h" -#include +#include +#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) simple_secure_storage_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "SimpleSecureStorageLinuxPlugin"); - simple_secure_storage_linux_plugin_register_with_registrar(simple_secure_storage_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) webcrypto_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "WebcryptoPlugin"); - webcrypto_plugin_register_with_registrar(webcrypto_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 8d79b66..66d7f77 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,18 +3,16 @@ # list(APPEND FLUTTER_PLUGIN_LIST - dynamic_system_colors + app_links_linux + dynamic_color file_selector_linux - screen_retriever_linux - simple_secure_storage_linux + media_kit_libs_linux + media_kit_video + pasteboard url_launcher_linux - webcrypto - window_manager - window_size ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - flutter_vodozemac ) 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..10f495f --- /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; \ No newline at end of file diff --git a/linux/nix/default.nix b/linux/nix/default.nix new file mode 100644 index 0000000..3c0a301 --- /dev/null +++ b/linux/nix/default.nix @@ -0,0 +1,60 @@ +{ 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 { }; + }; + + 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..a47f824 --- /dev/null +++ b/linux/nix/devshell.nix @@ -0,0 +1,59 @@ +{ 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" + "36" + ]; + abiVersions = [ + "armeabi-v7a" + "arm64-v8a" + ]; + includeNDK = true; + ndkVersions = [ "28.2.13676358" ]; + }; +in +pkgs.mkShell { + packages = + with pkgs; + [ + go + git + + libGL + (flutter.override { + extraPkgConfigPackages = [ + mpv-unwrapped + libass + ]; + }) + android.platform-tools + ] + ++ 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 = 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"; + } + // lib.optionalAttrs pkgs.stdenv.isLinux { + LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; + }; +} 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..16d0fc0 --- /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 = "252b064f8ff4f0f265bc44294d4a7ed703dd7199688942c272dd8aa8d3039d5a"; + }; + aarch64-linux = { + arch = "arm64"; + sha256 = "cdcf17cfd18b9b0f965b72615ddf11246121114577dc570d0bba25964e56c726"; + }; + }; + 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.1.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..bb6b2bf --- /dev/null +++ b/linux/nix/pkg/default.nix @@ -0,0 +1,77 @@ +{ + lib, + callPackage, + mpv-unwrapped, + libass, + libxext, + libxfixes, + libclang, + flutter, + stdenv, + src, + commit ? "dev", +}: + +flutter.buildFlutterApplication { + pname = "nexus"; + version = "1.0.0-alpha5"; + 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="; + 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-HW0NukbddwOYd9iVUFA3L8iy1qggnyOBgcbMj0MTYss="; + window_manager = "sha256-xUd5hG+/V6F+m0ZUL/3XGPACFG/zqI5JM6IaMVvLgx4="; + 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..6c383d2 --- /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-nd0NEKXscgrFE/gBEW2aP+YeZNTOdbRa4QDLTRwIAZU="; + + 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..f10315f --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,32 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import app_links +import dynamic_color +import file_selector_macos +import flutter_local_notifications +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")) + 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..4e593b0 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,16 @@ + + + + + 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 + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..36999ec --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,45 @@ + + + + + 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 + + + + + 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..741903e --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + + + 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/nix/fake-rustup.sh b/nix/fake-rustup.sh deleted file mode 100644 index 7884c05..0000000 --- a/nix/fake-rustup.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -# Fake rustup for nix-managed Rust toolchains - -case "$1" in - run) - if [[ "$2" == "stable" ]]; then - shift 2 - if [[ $# -eq 0 ]]; then - echo "fake rustup: no command given" >&2 - exit 1 - fi - exec "$@" - exit 0 - fi - ;; - - toolchain) - if [[ "$2" == "list" ]]; then - echo "stable (default)" - exit 0 - fi - ;; - - target) - if [[ "$2" == "list" && "$3" == "--toolchain" && "$4" == "stable" && "$5" == "--installed" ]]; then - echo "x86_64-unknown-linux-gnu" - exit 0 - fi - ;; -esac - -echo "fake rustup: the command:" >&2 -echo " rustup $*" >&2 -echo "…is not mocked yet" >&2 -exit 1 \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 871bb6f..670b2ca 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,50 +5,82 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + sha256: "48e48f73a431eb269eec66d243a63a07ed90ef9417a74047a3b98d52338626ad" url: "https://pub.dev" source: hosted - version: "91.0.0" + version: "109.0.0" analysis_server_plugin: dependency: transitive description: name: analysis_server_plugin - sha256: "44adba4d74a2541173bad4c11531d2a4d22810c29c5ddb458a38e9f4d0e5eac7" + sha256: "9f293f95cf41cb513a9a4f322354e2fa5483125e0f18bbeca254543ac9c3dda6" url: "https://pub.dev" source: hosted - version: "0.3.4" + version: "0.3.24" analyzer: - dependency: "direct overridden" + dependency: transitive description: name: analyzer - sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + sha256: "6d377901d198536b648172974d9d4f277910502cad92193466869cac0ff34990" url: "https://pub.dev" source: hosted - version: "8.4.1" + version: "14.5.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: "2c59e6fba4971dc9ece97acd55b4f95b67a98e6ae0a55bff6f9dfc2bb59e8b1a" url: "https://pub.dev" source: hosted - version: "0.13.11" + version: "0.14.18" + 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: transitive + 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: f773153144ce0456ae77d96bd247f2efd58efe4f3e5ad42616d0385c031aea11 url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.5.1" args: dependency: transitive description: @@ -57,30 +89,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" async: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" - base58check: - dependency: transitive + version: "2.13.1" + avif_native: + dependency: "direct main" description: - name: base58check - sha256: "6c300dfc33e598d2fe26319e13f6243fea81eaf8204cb4c6b69ef20a625319a5" + name: avif_native + sha256: "0a374a7ddc6e48d671792bd33efd9f46f199389cdcd42561210bd886facbc62e" url: "https://pub.dev" source: hosted - version: "2.0.0" - blurhash_dart: - dependency: transitive - description: - name: blurhash_dart - sha256: "43955b6c2e30a7d440028d1af0fa185852f3534b795cc6eb81fbf397b464409f" - url: "https://pub.dev" - source: hosted - version: "1.2.1" + version: "0.1.1" boolean_selector: dependency: transitive description: @@ -93,74 +125,74 @@ packages: dependency: transitive description: name: build - sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413 + sha256: "8a5c5761af8e31748bba3c82f68925ace40f3225c3eea25be9beb57eca7cd7a8" url: "https://pub.dev" source: hosted - version: "4.0.3" - build_cli_annotations: - dependency: transitive - description: - name: build_cli_annotations - sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 - url: "https://pub.dev" - source: hosted - version: "2.1.1" + version: "4.0.11" build_config: dependency: transitive description: name: build_config - sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.3" build_daemon: dependency: transitive description: name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.6" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057" + sha256: "6feadd32d90a68253c1f7b4fcff76adb4b41ad32a53f8bad835dc65f01ac9dc5" url: "https://pub.dev" source: hosted - version: "2.10.4" + version: "2.16.2" 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" - canonical_json: + version: "8.13.0" + button_m3e: dependency: transitive description: - name: canonical_json - sha256: d6be1dd66b420c6ac9f42e3693e09edf4ff6edfee26cb4c28c1c019fdb8c0c15 + name: button_m3e + sha256: "6754ddeb9068ad2005bd26d5ceabc41268029465095686d7d228296c2e706909" url: "https://pub.dev" source: hosted - version: "1.1.2" + 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" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -169,22 +201,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: @@ -193,30 +209,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.2" - clipboard: - dependency: "direct main" - description: - name: clipboard - sha256: "619f4e9e946cfd637ac994f49af356bb590ab88b0c4aded03204ee566fd69d9e" - url: "https://pub.dev" - source: hosted - version: "3.0.8" clock: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e url: "https://pub.dev" source: hosted - version: "1.1.2" - code_builder: + version: "1.1.3" + cnativeapi: dependency: transitive description: - name: code_builder - sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + name: cnativeapi + sha256: "251fb93cd7ec205445fa512ee5ddeb0b82b3273d071cfc0784ddffbbdebbf5d3" url: "https://pub.dev" source: hosted - version: "4.11.0" + version: "0.5.2" + code_assets: + dependency: "direct main" + description: + name: code_assets + sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57" + url: "https://pub.dev" + source: hosted + version: "2.1.0" collection: dependency: "direct main" description: @@ -241,30 +257,14 @@ 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: transitive - 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: @@ -281,78 +281,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: - dependency: transitive - description: - name: custom_lint_visitor - sha256: "91f2a81e9f0abb4b9f3bb529f78b6227ce6050300d1ae5b1e2c69c66c7a566d8" - url: "https://pub.dev" - source: hosted - version: "1.0.0+8.4.0" + version: "1.0.2" dart_style: dependency: transitive description: name: dart_style - sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + sha256: "82ade9fc4273f29ed673e33166944465225b4f7fc5d4aaef48605cc751c18fc1" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.13" 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_system_colors: + version: "0.7.15" + dynamic_color: dependency: "direct main" description: - name: dynamic_system_colors - sha256: "43794e658fa88cbdec9f397dd1afd2eb69b6c9717e99b93b16ba37c3aa3b3a8c" + name: dynamic_color + sha256: "869b3bce0100eb519768ecd71c0111f0b5ea679f5f62e1f59c49d80067e730c5" url: "https://pub.dev" source: hosted - version: "1.8.0" + version: "2.1.0" + dynamic_polls: + dependency: "direct main" + description: + name: dynamic_polls + sha256: "58cd1fafbbaba6b485a61692aa3bd33ded6f0f61734543b6c754511c362d8ae1" + url: "https://pub.dev" + source: hosted + version: "0.0.8" + encrypt: + dependency: transitive + description: + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + 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: @@ -365,18 +358,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: transitive + dependency: "direct main" description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + 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: "6f55d99ac1857fc063b58c3b31753df2bba417028ba98d30c6d625a385855064" + url: "https://pub.dev" + source: hosted + version: "23.0.0" file: dependency: transitive description: @@ -385,30 +394,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: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" + url: "https://pub.dev" + source: hosted + version: "0.5.3+6" 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: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 url: "https://pub.dev" source: hosted - version: "0.9.5" + version: "0.9.5+1" file_selector_platform_interface: dependency: transitive description: @@ -417,14 +442,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: @@ -438,23 +471,22 @@ 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_file_dialog: dependency: "direct main" description: - path: "packages/flutter_chat_ui" - ref: HEAD - resolved-ref: "6cfbadbf364251dd3c6a986e20c9d97636ad3412" - url: "https://github.com/Henry-Hiles/flutter_chat_ui" - source: git - version: "2.9.1" + name: flutter_file_dialog + sha256: "5d270d69b6a5fdf45bdb88ebcf590e073dd89dfb45526ac83a64524ba57303b5" + url: "https://pub.dev" + source: hosted + version: "3.3.3" flutter_hooks: dependency: "direct main" description: @@ -471,15 +503,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.14.4" - flutter_link_previewer: + flutter_linkify: dependency: "direct main" description: - path: "packages/flutter_link_previewer" - ref: HEAD - resolved-ref: "6cfbadbf364251dd3c6a986e20c9d97636ad3412" - url: "https://github.com/Henry-Hiles/flutter_chat_ui" - source: git - version: "4.1.2" + name: flutter_linkify + sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073" + url: "https://pub.dev" + source: hosted + version: "6.0.0" flutter_lints: dependency: "direct dev" description: @@ -488,64 +519,80 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "357be7ff996dc272e2037c69c1bdd5d1bd21172219d55f8bd2e11aeea602e6d3" + url: "https://pub.dev" + source: hosted + version: "22.3.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" + url: "https://pub.dev" + source: hosted + version: "8.0.1" + 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_math_fork: - dependency: transitive - description: - name: flutter_math_fork - sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407" - url: "https://pub.dev" - source: hosted - version: "0.7.4" 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" - flutter_rust_bridge: - dependency: transitive - description: - name: flutter_rust_bridge - sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e" - url: "https://pub.dev" - source: hosted - version: "2.11.1" + version: "3.4.3" flutter_svg: dependency: "direct main" description: name: flutter_svg - sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.3.0" flutter_test: dependency: transitive description: flutter source: sdk version: "0.0.0" - flutter_vodozemac: - dependency: "direct main" - description: - name: flutter_vodozemac - sha256: "16d4b44dd338689441fe42a80d0184e5c864e9563823de9e7e6371620d2c0590" - url: "https://pub.dev" - source: hosted - version: "0.4.1" flutter_web_plugins: dependency: transitive description: flutter @@ -555,59 +602,26 @@ 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" + sha256: "04514674b41a063b97901aedf6970d0675b828bd723a0fb9f9dba89b91953382" url: "https://pub.dev" source: hosted - version: "2.3.1" - flyer_chat_file_message: - dependency: "direct main" - description: - name: flyer_chat_file_message - sha256: "96c5c25908cd671dda1963ade03e188e6a14bba6b116e73fac329f1abefc9ad1" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - flyer_chat_image_message: - dependency: "direct main" - description: - name: flyer_chat_image_message - sha256: "04730c9373c9c7315ba0e1a360c67ac5f6c7ec8a700ffe2d2dc00e29b7f8ff90" - url: "https://pub.dev" - source: hosted - version: "2.3.0" - flyer_chat_system_message: - dependency: "direct main" - description: - name: flyer_chat_system_message - sha256: d254f85be55949f8eb1a4a9a9b1c5b54ffed0c9a39dfa7e4fa6a6358bdb5d45a - url: "https://pub.dev" - source: hosted - version: "2.2.0" - flyer_chat_text_message: - dependency: "direct main" - description: - path: "packages/flyer_chat_text_message" - ref: HEAD - resolved-ref: "6cfbadbf364251dd3c6a986e20c9d97636ad3412" - url: "https://github.com/Henry-Hiles/flutter_chat_ui" - source: git - version: "2.5.2" + version: "2.3.2" freezed: dependency: "direct dev" description: name: freezed - sha256: "13065f10e135263a4f5a4391b79a8efc5fb8106f8dd555a9e49b750b45393d77" + sha256: "3af98c92d7d4dd182ea1393193e69a840fcbaee1f66f1158bd20504e5f3f6f4f" url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "4.0.2" freezed_annotation: dependency: "direct main" description: @@ -616,30 +630,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" - frontend_server_client: + get_x_storage: dependency: transitive description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + name: get_x_storage + sha256: "597cd8f6ef8b01c747a6d95ff7b0310066f6c82ddf5565ca371f047f316fe43e" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "0.2.0" glob: dependency: transitive description: name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" url: "https://pub.dev" source: hosted - version: "2.1.3" - gpt_markdown: - dependency: transitive - description: - name: gpt_markdown - sha256: "9b88dfaffea644070b648c204ca4a55745a49f4ad0b58ed0ab70913ad593c7a1" - url: "https://pub.dev" - source: hosted - version: "1.1.5" + version: "2.2.0" graphs: dependency: transitive description: @@ -648,32 +654,32 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hooks: + dependency: "direct main" + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + 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 description: name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" url: "https://pub.dev" source: hosted - version: "0.15.6" - html_unescape: - dependency: transitive - description: - name: html_unescape - sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3" - url: "https://pub.dev" - source: hosted - version: "2.0.0" + version: "0.15.7" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -696,38 +702,38 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - idb_shim: + icon_button_m3e: dependency: transitive description: - name: idb_shim - sha256: "071f3b05032fa62e60ca15db9939f8afbaf403b37e67747ac88f858c3e999228" + name: icon_button_m3e + sha256: c4524d6141a468679821bbb635b833ac6831925d8a6ae4a4511430b0e4ab9c67 url: "https://pub.dev" source: hosted - version: "2.6.7+1" + 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: @@ -740,10 +746,10 @@ packages: dependency: transitive description: name: image_picker_ios - sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" + sha256: "4fb88d4350ffc208c2a09fc321db1f80282688716b8cfe10c8c80acc5ae8934a" url: "https://pub.dev" source: hosted - version: "0.8.13+3" + version: "0.8.13+10" image_picker_linux: dependency: transitive description: @@ -764,10 +770,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: @@ -780,18 +786,18 @@ packages: 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" + version: "1.1.0" js: dependency: transitive description: @@ -804,26 +810,18 @@ packages: 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: e45aefa0324f08c683caafbb94b72837aa6193c61822799c916e45f4a263113d url: "https://pub.dev" source: hosted - version: "6.11.3" - just_throttle_it: - dependency: transitive - description: - name: just_throttle_it - sha256: af2d0c1e5c7f4e0bef79a55edf3d74c180908253f89203467bc432730f5fac5b - url: "https://pub.dev" - source: hosted - version: "3.0.1" + version: "6.14.1" leak_tracker: dependency: transitive description: @@ -848,14 +846,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: @@ -864,86 +879,241 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - markdown: - dependency: transitive + m3e_buttons: + dependency: "direct main" description: - name: markdown - sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + name: m3e_buttons + sha256: dca780e813563754576194ed18e3f6a33422ed136c8d4ccfeca20909edddd728 url: "https://pub.dev" source: hosted - version: "7.3.0" + version: "1.0.3" + 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_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" - matrix: + version: "0.13.0" + material_emoji_picker: dependency: "direct main" description: - name: matrix - sha256: fb116ee89f6871441f22f76a988db15cfcfb6dfac97e3e2d654c240080015707 + name: material_emoji_picker + sha256: "738e70a8d2f592be05dd9889037cc6098a63e0d8c48723125848865ab305f138" url: "https://pub.dev" source: hosted - version: "4.1.0" - mention_tag_text_field: + version: "1.2.0" + material_ui: dependency: "direct main" description: - name: mention_tag_text_field - sha256: ba7b9d8003e0f340a65c6dcdb7770f4340f653ae1612a9e31e11d12f7f1dd80f + name: material_ui + sha256: "98ab9eda579ecae623e4b376c603035c5def78ce706f735f30bd632af5814695" url: "https://pub.dev" source: hosted - version: "0.0.9" + version: "1.6.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: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.19.0" mime: - dependency: transitive + 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" + 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: "quad/fix/native-aarch64" + resolved-ref: ecc5b48feab382d2bd2dfe70321c692213ee5218 + url: "https://github.com/Henry-Hiles/native_toolchain_cmake" + source: git + version: "0.3.2" + nativeapi: + dependency: transitive + description: + name: nativeapi + sha256: c4625e81f5ce2f4a9044c0ec1bdb38355119ffcf89e24eb20ae9fc5027a01e59 + url: "https://pub.dev" + source: hosted + version: "0.5.2" + nativeapi_flutter: + dependency: transitive + description: + name: nativeapi_flutter + sha256: "4feaa68cb5c4fc3b2eeaee1a25e56e2d092178e1c59516d9db5dd34940dcf36c" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + 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: fedbe8da188d2f713aa8b01260737342e6e1087534a3ab26e1a719f8d3e8f32f + url: "https://pub.dev" + source: hosted + version: "0.5.0" path: dependency: "direct main" description: @@ -964,42 +1134,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 + dependency: "direct overridden" 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: @@ -1012,18 +1182,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: @@ -1032,86 +1202,94 @@ 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: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e url: "https://pub.dev" source: hosted - version: "6.0.3" - provider: + version: "6.5.2" + 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" 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: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 url: "https://pub.dev" source: hosted - version: "1.5.0" - punycode: + version: "1.6.0" + quiver: dependency: transitive description: - name: punycode - sha256: "39b874cc1f78b94e57db17e74b3f2ba2a96e25c0bebdcc8a571614dccda0ff0c" + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 url: "https://pub.dev" source: hosted - version: "1.0.0" - random_string: + version: "3.2.2" + record_use: dependency: transitive description: - name: random_string - sha256: "03b52435aae8cbdd1056cf91bfc5bf845e9706724dd35ae2e99fa14a1ef79d02" + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" url: "https://pub.dev" source: hosted - version: "2.3.1" + 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" + version: "3.1.9" rxdart: dependency: transitive description: @@ -1120,102 +1298,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" - screen_retriever: + safe_local_storage: dependency: transitive description: - name: screen_retriever - sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" + name: safe_local_storage + sha256: "494b982d5edb71030650ea463d939670e91b232b588323dc75229d2c5f23e7b7" 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: c2f713509f18f88f637b2084b47a90c91fb1ef066d5d82d2cf3194d8509dc6ab - url: "https://pub.dev" - source: hosted - version: "1.26.2" - sdp_transform: - dependency: transitive - description: - name: sdp_transform - sha256: "73e412a5279a5c2de74001535208e20fff88f225c9a4571af0f7146202755e45" - url: "https://pub.dev" - source: hosted - version: "0.3.2" - sembast: - dependency: transitive - description: - name: sembast - sha256: c8063c3146c3c8d5f5b04230de7682c768440a575fbda2634f14d22f263197c3 - url: "https://pub.dev" - source: hosted - version: "3.8.5+2" - sembast_web: - dependency: transitive - description: - name: sembast_web - sha256: "0362c7c241ad6546d3e27b4cfffaae505e5a9661e238dbcdd176756cc960fe7a" - url: "https://pub.dev" - source: hosted - version: "2.4.2" + version: "2.0.6" 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: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" url: "https://pub.dev" source: hosted - version: "2.4.18" + version: "2.4.28" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" url: "https://pub.dev" source: hosted - version: "2.5.6" + version: "2.5.7" shared_preferences_linux: dependency: transitive description: @@ -1228,10 +1342,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: @@ -1256,22 +1370,6 @@ packages: 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" shelf_web_socket: dependency: transitive description: @@ -1280,147 +1378,43 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - simple_secure_storage: - dependency: "direct main" - description: - name: simple_secure_storage - sha256: ca823a355bb7bb0e9b969876508e7d3a5dc0d1fb2dcb681c85b6e315f1e876e9 - url: "https://pub.dev" - source: hosted - version: "0.3.7" - simple_secure_storage_android: - dependency: transitive - description: - name: simple_secure_storage_android - sha256: "50fb27267755843af039da116d0e545f313ae329ef8838101880802259e0f741" - url: "https://pub.dev" - source: hosted - version: "0.3.1" - simple_secure_storage_darwin: - dependency: transitive - description: - name: simple_secure_storage_darwin - sha256: "8bd2ffcc62b478957ce20046bb96618b91a11e74af5d9fe2b4b229117bad18a7" - url: "https://pub.dev" - source: hosted - version: "0.2.2" - simple_secure_storage_linux: - dependency: transitive - description: - name: simple_secure_storage_linux - sha256: a7b7dccfaf496c27f882c26634ac083f2f545c0a4ca0818534c6261205a83686 - url: "https://pub.dev" - source: hosted - version: "0.2.5" - simple_secure_storage_platform_interface: - dependency: transitive - description: - name: simple_secure_storage_platform_interface - sha256: "04fd4ce4c2b97c01a12eba46f51e3075a793d11f13340d06a64eb9b45a463ca5" - url: "https://pub.dev" - source: hosted - version: "0.2.3" - simple_secure_storage_web: - dependency: transitive - description: - name: simple_secure_storage_web - sha256: "63a3474a9931ab2587e01d22e7e95c0b7cc31338c0fafed5db9d1d798d1d3e0e" - url: "https://pub.dev" - source: hosted - version: "0.2.3" - simple_secure_storage_windows: - dependency: transitive - description: - name: simple_secure_storage_windows - sha256: cf31d2a97c26cf854aeb3c9774cd253f6600fb3fdfc6d807d480afae678cef10 - url: "https://pub.dev" - source: hosted - version: "0.3.2" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" - slugify: + source_gen: dependency: transitive - description: - name: slugify - sha256: b272501565cb28050cac2d96b7bf28a2d24c8dae359280361d124f3093d337c3 - url: "https://pub.dev" - source: hosted - version: "2.0.0" - source_gen: - dependency: "direct overridden" description: name: source_gen - sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75" + sha256: "11b6047da8e4eb6c643ccac3d0fda3f9c9e11651695f7da24a85ade8aff15a44" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.3.0" source_helper: dependency: transitive description: name: source_helper - sha256: e82b1996c63da42aa3e6a34cc1ec17427728a1baf72ed017717a5669a7123f0d + sha256: "5e6f216fdf6376c9f3852381ae037499797a3385377d388b011dac98d303c67c" 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.3.13" source_span: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" - url: "https://pub.dev" - source: hosted - version: "2.5.6" - sqflite_common_ffi: - dependency: "direct main" - description: - name: sqflite_common_ffi - sha256: "9faa2fedc5385ef238ce772589f7718c24cdddd27419b609bb9c6f703ea27988" - url: "https://pub.dev" - source: hosted - version: "2.3.6" - sqlite3: - dependency: transitive - description: - name: sqlite3 - sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" - url: "https://pub.dev" - source: hosted - version: "2.9.4" + 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: @@ -1441,10 +1435,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: @@ -1453,14 +1447,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + super_sliver_list: + dependency: "direct main" + description: + name: super_sliver_list + sha256: b1e1e64d08ce40e459b9bb5d9f8e361617c26b8c9f3bb967760b0f436b6e3f56 + url: "https://pub.dev" + source: hosted + version: "0.4.1" synchronized: dependency: transitive description: name: synchronized - sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + sha256: "397b146f97613b83d84bdccb439bc8d0f3aebb6c1d14a9641e6f24201c490b2d" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.4.2" term_glyph: dependency: transitive description: @@ -1469,46 +1471,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" - test: - dependency: transitive - description: - name: test - sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" - url: "https://pub.dev" - source: hosted - version: "1.26.2" test_api: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.6" - test_core: - dependency: transitive + version: "0.7.12" + timeago: + dependency: "direct main" description: - name: test_core - sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + name: timeago + sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e url: "https://pub.dev" source: hosted - version: "0.6.11" - thumbhash: - dependency: transitive + version: "3.7.1" + timezone: + dependency: "direct main" description: - name: thumbhash - sha256: "5f6d31c5279ca0b5caa81ec10aae8dcaab098d82cb699ea66ada4ed09c794a37" + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" url: "https://pub.dev" source: hosted - version: "0.1.0+1" - tuple: - dependency: transitive - description: - name: tuple - sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 - url: "https://pub.dev" - source: hosted - version: "2.0.2" + version: "0.11.1" typed_data: dependency: transitive description: @@ -1517,54 +1503,112 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - unorm_dart: - dependency: transitive + unifiedpush: + dependency: "direct main" description: - name: unorm_dart - sha256: "5b35bff83fce4d76467641438f9e867dc9bcfdb8c1694854f230579d68cd8f4b" + name: unifiedpush + sha256: "8ed9767f750a1dc6159a77e2171641d0cb825dc87682d1ce1b8618689b79f58e" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "6.2.0" + unifiedpush_android: + dependency: transitive + description: + name: unifiedpush_android + sha256: a902e505e91c8e28b54b379bb27f255f66df409274a97af24d6719f12f0d13dc + url: "https://pub.dev" + source: hosted + 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 description: @@ -1577,34 +1621,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: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.3" vector_graphics_codec: dependency: transitive description: @@ -1617,42 +1661,50 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.3.0" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.3" vm_service: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.dev" source: hosted - version: "15.0.2" - vodozemac: - dependency: "direct main" + version: "15.3.0" + wakelock_plus: + dependency: transitive description: - name: vodozemac - sha256: "39144e20740807731871c9248d811ed5a037b21d0aa9ffcfa630954de74139d9" + name: wakelock_plus + sha256: "22b3e7e937721de70e63c85e7139f4ac781dc22863b9262431a53ac030eb074b" url: "https://pub.dev" source: hosted - version: "0.4.0" + 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: name: watcher - sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249 + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" web: dependency: transitive description: @@ -1678,56 +1730,50 @@ packages: source: hosted version: "3.0.3" webcrypto: + dependency: "direct overridden" + description: + path: "." + ref: "fix/apple-x86_64-fiat-asm-upstream" + resolved-ref: e50fcce3d0c30627b11ffc74eb964b1073d42620 + url: "https://github.com/DavidClaudeAI/webcrypto.dart" + source: git + version: "0.6.2-wip" + webpush_encryption: dependency: transitive description: - name: webcrypto - sha256: "6b43001c4110856ff7fa5e5e65e7b2d44bec1d8b54a4d84d5fa2c7622267c5c1" + name: webpush_encryption + sha256: "5b83272b91acda6ae515fcd980c94f06bf413702282497c5a68f5dfc64fed27f" url: "https://pub.dev" source: hosted - version: "0.6.0" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - webrtc_interface: - dependency: transitive - description: - name: webrtc_interface - sha256: "2e604a31703ad26781782fb14fa8a4ee621154ee2c513d2b9938e486fa695233" - url: "https://pub.dev" - source: hosted - version: "1.3.0" + 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" - url: "https://pub.dev" - source: hosted - version: "0.5.1" - window_size: + path: "." + ref: HEAD + resolved-ref: "80acd536a1ea1c121c36e32d30e638a295087ed1" + url: "https://github.com/leanflutter/window_manager" + source: git + version: "0.6.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" @@ -1738,26 +1784,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.9.2 <4.0.0" - flutter: ">=3.35.0" + dart: "3.13.3" + flutter: ">=3.47.0" diff --git a/pubspec.yaml b/pubspec.yaml index 9551407..4f39749 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,86 +1,148 @@ name: nexus -description: "Yet another Matrix client" -version: 1.0.0 +description: "A simple and user-friendly Matrix client" +version: 1.0.0-alpha5 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 + path_provider_android: 2.2.23 # Pinned to avoid JNI + 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/DavidClaudeAI/webcrypto.dart + ref: fix/apple-x86_64-fiat-asm-upstream # Uses build hooks and fixes macOS x86_64, unreleased + native_toolchain_cmake: + git: + url: https://github.com/Henry-Hiles/native_toolchain_cmake + ref: quad/fix/native-aarch64 # Fixes native aarch64 linux builds + 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 + window_manager: + git: + url: https://github.com/leanflutter/window_manager dependencies: - flutter: - sdk: flutter - flutter_localizations: - sdk: flutter - flutter_hooks: ^0.21.2 - 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 - flyer_chat_text_message: - git: - url: https://github.com/Henry-Hiles/flutter_chat_ui - path: packages/flyer_chat_text_message - 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 - matrix: ^4.1.0 - sqflite_common_ffi: ^2.3.6 - color_hash: ^1.0.1 - flutter_vodozemac: ^0.4.1 - flutter_widget_from_html_core: ^0.17.0 - flutter_svg: ^2.2.2 - simple_secure_storage: ^0.3.6 - json_annotation: ^4.9.0 - vodozemac: ^0.4.0 - clipboard: ^3.0.8 - shared_preferences: ^2.5.3 - mention_tag_text_field: ^0.0.9 - fluttertagger: ^2.3.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.5.2 + color_hash: 1.0.1 + flutter_widget_from_html_core: 0.18.0-beta.1 + flutter_svg: 2.3.0 + json_annotation: 4.12.0 + shared_preferences: 2.5.6 + fluttertagger: 2.3.2 + dynamic_polls: 0.0.8 + 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 + flutter_linkify: 6.0.0 + linkify: 5.0.0 + 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.3 + 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.6.0 + unifiedpush: 6.2.0 + unifiedpush_storage_shared_preferences: 1.0.0 + flutter_local_notifications: 22.3.1 + timezone: 0.11.1 + material_emoji_picker: 1.2.0 + xdg_desktop_portal: + git: + url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart + ref: quad/nexus + pasteboard: 0.5.0 + flutter_file_dialog: 3.3.3 + mime: 2.1.0 + avif_native: 0.1.1 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 + build_runner: 2.16.2 + flutter_lints: 6.0.0 + freezed: 4.0.2 + riverpod_lint: 3.1.9 + flutter_launcher_icons: 0.14.4 + json_serializable: 6.14.1 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 \ 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 new file mode 100644 index 0000000..d8a8cb1 --- /dev/null +++ b/scripts/generate.dart @@ -0,0 +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("../gomuks")); + + print("Generating FFI bindings..."); + + final libclangPath = Platform.environment["LIBCLANG_PATH"]; + FfiGenerator( + output: .new( + dart: .new( + path: Platform.script.resolve("../lib/src/third_party/gomuks.g.dart"), + ), + ), + visitors: [.new(func: (node) => node.isIncluded = true)], + input: .new( + entryPoints: [File(join(repoDir.path, "pkg", "ffi", "gomuksffi.h")).uri], + compilerOptions: [ + "--no-warnings", + if (Platform.isMacOS) "-I${await getXCodeTool()}/usr/include", + ], + ), + ).generate( + libclangDylib: libclangPath == null + ? null + : Uri.file( + join( + libclangPath, + "libclang.${(Platform.isLinux || Platform.isAndroid) + ? "so" + : Platform.isMacOS + ? "dylib" + : Platform.isWindows + ? "dll" + : throw UnsupportedError("Unsupported Platform")}", + ), + ), + ); + print("FFI bindings generated!"); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 9d7af86..7dc1c3c 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,30 +6,27 @@ #include "generated_plugin_registrant.h" -#include +#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")); - SimpleSecureStorageWindowsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("SimpleSecureStorageWindowsPluginCApi")); + MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); + MediaKitVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); + PasteboardPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PasteboardPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); - WebcryptoPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("WebcryptoPlugin")); - WindowManagerPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("WindowManagerPlugin")); - WindowSizePluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("WindowSizePlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index dcf3309..68413e7 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,18 +3,17 @@ # list(APPEND FLUTTER_PLUGIN_LIST - dynamic_system_colors + app_links + dynamic_color file_selector_windows - screen_retriever_windows - simple_secure_storage_windows + media_kit_libs_windows_video + media_kit_video + pasteboard url_launcher_windows - webcrypto - window_manager - window_size ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - flutter_vodozemac + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/windows/installer.iss b/windows/installer.iss new file mode 100644 index 0000000..22f4dcf --- /dev/null +++ b/windows/installer.iss @@ -0,0 +1,21 @@ +[Setup] +AppName=Nexus +AppVersion=1.0.0 +DefaultDirName={pf}\Nexus +DefaultGroupName=Nexus +OutputDir=dist +OutputBaseFilename=Nexus-Setup +Compression=lzma +SolidCompression=yes +ArchitecturesInstallIn64BitMode=x64 + +[Files] +Source: "..\build\windows\x64\runner\Release\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion + +[Icons] +Name: "{group}\Nexus"; Filename: "{app}\nexus.exe" +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""" \ No newline at end of file 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 c04e20c..f8a91f7 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ