Compare commits
326 changed files with 4746 additions and 16113 deletions
4
.github/workflows/android.yml
vendored
4
.github/workflows/android.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
||||||
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
|
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
|
||||||
|
|
||||||
- name: Build app
|
- 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 }}"
|
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"
|
||||||
env:
|
env:
|
||||||
KEYSTORE_PATH: ../../keystore.jks
|
KEYSTORE_PATH: ../../keystore.jks
|
||||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
|
@ -36,4 +36,4 @@ jobs:
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: APK
|
name: APK
|
||||||
path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
|
path: build/app/outputs/flutter-apk/app-release.apk
|
||||||
36
.github/workflows/flatpak.yml
vendored
36
.github/workflows/flatpak.yml
vendored
|
|
@ -35,39 +35,3 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: flatpak-${{ matrix.arch }}
|
name: flatpak-${{ matrix.arch }}
|
||||||
path: result/nexus.federated.nexus.flatpak
|
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
|
|
||||||
|
|
|
||||||
4
.github/workflows/ios.yml
vendored
4
.github/workflows/ios.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
- name: Set up Flutter
|
- name: Set up Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: 3.47.4
|
flutter-version: 3.47.0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
|
|
@ -31,7 +31,7 @@ jobs:
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart scripts/generate.dart
|
dart scripts/generate.dart
|
||||||
flutter pub run build_runner build
|
flutter pub run build_runner build
|
||||||
flutter build ios --no-codesign --dart-define=GIT_COMMIT=${{ github.sha }}
|
flutter build ios --release --no-codesign
|
||||||
|
|
||||||
# Unsigned IPA: SideStore/AltStore re-sign this on-device with the
|
# 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.
|
# user's own Apple ID at install time, so no certificate is needed here.
|
||||||
|
|
|
||||||
4
.github/workflows/macos.yml
vendored
4
.github/workflows/macos.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
- name: Set up Flutter
|
- name: Set up Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: 3.47.4
|
flutter-version: 3.47.0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
|
|
@ -31,7 +31,7 @@ jobs:
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart scripts/generate.dart
|
dart scripts/generate.dart
|
||||||
flutter pub run build_runner build
|
flutter pub run build_runner build
|
||||||
flutter build macos --dart-define=GIT_COMMIT=${{ github.sha }}
|
flutter build macos --release
|
||||||
|
|
||||||
- name: Create DMG
|
- name: Create DMG
|
||||||
id: create-dmg
|
id: create-dmg
|
||||||
|
|
|
||||||
15
.github/workflows/windows.yml
vendored
15
.github/workflows/windows.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
- name: Set up Flutter
|
- name: Set up Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: 3.47.4
|
flutter-version: 3.47.0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
|
|
@ -29,7 +29,7 @@ jobs:
|
||||||
- name: Setup MSYS2
|
- name: Setup MSYS2
|
||||||
uses: msys2/setup-msys2@v2
|
uses: msys2/setup-msys2@v2
|
||||||
with:
|
with:
|
||||||
msystem: CLANG64
|
msystem: MINGW64
|
||||||
install: >-
|
install: >-
|
||||||
mingw-w64-x86_64-gcc
|
mingw-w64-x86_64-gcc
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ jobs:
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart scripts/generate.dart
|
dart scripts/generate.dart
|
||||||
flutter pub run build_runner build
|
flutter pub run build_runner build
|
||||||
flutter build windows --dart-define=GIT_COMMIT=${{ github.sha }}
|
flutter build windows --release
|
||||||
|
|
||||||
- name: Copy MinGW runtime DLLs
|
- name: Copy MinGW runtime DLLs
|
||||||
shell: msys2 {0}
|
shell: msys2 {0}
|
||||||
|
|
@ -52,15 +52,6 @@ jobs:
|
||||||
cp /mingw64/bin/libwinpthread-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/
|
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
|
- name: Install Inno Setup
|
||||||
run: choco install innosetup -y
|
run: choco install innosetup -y
|
||||||
|
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -42,5 +42,3 @@ password.txt
|
||||||
|
|
||||||
# Nix
|
# Nix
|
||||||
/result
|
/result
|
||||||
# Widget Preview related
|
|
||||||
.widget_preview/
|
|
||||||
|
|
|
||||||
1
.gitmodules
vendored
1
.gitmodules
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
[submodule "gomuks"]
|
[submodule "gomuks"]
|
||||||
path = gomuks
|
path = gomuks
|
||||||
url = https://github.com/gomuks/gomuks
|
url = https://github.com/gomuks/gomuks
|
||||||
|
branch = main
|
||||||
|
|
|
||||||
17
.vscode/launch.json
vendored
17
.vscode/launch.json
vendored
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"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" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
|
@ -10,10 +10,7 @@
|
||||||
"msgtype",
|
"msgtype",
|
||||||
"muks",
|
"muks",
|
||||||
"prefs",
|
"prefs",
|
||||||
"recents",
|
|
||||||
"unban",
|
"unban",
|
||||||
"unifiedpush",
|
"unredact"
|
||||||
"unredact",
|
|
||||||
"webpush"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,16 @@ See [Effective Dart: Style](https://dart.dev/effective-dart/style) for general r
|
||||||
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:
|
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
|
```dart
|
||||||
ref.read(MyController.provider.notifier).helperMethod()
|
ref.watch(MyController.provider.notifier).helperMethod()
|
||||||
```
|
```
|
||||||
|
|
||||||
We use an object oriented style for controllers, where `provider` is a static member on the controller class. E.g.
|
We use an object oriented style for controllers, where `provider` is a static member on the controller class. E.g.
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
class MyController(final SomeInputType input)
|
class MyController extends AsyncNotifier<DataThisControllerExposes> {
|
||||||
extends AsyncNotifier<DataThisControllerExposes> {
|
final SomeInputType input;
|
||||||
|
MyController(this.input);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<DataThisControllerExposes> build() async {
|
Future<DataThisControllerExposes> build() async {
|
||||||
return input.foo;
|
return input.foo;
|
||||||
|
|
@ -36,17 +38,11 @@ class MyController(final SomeInputType input)
|
||||||
|
|
||||||
static final provider =
|
static final provider =
|
||||||
AsyncNotifierProvider.family<MyController, DataThisControllerExposes, SomeInputType>(
|
AsyncNotifierProvider.family<MyController, DataThisControllerExposes, SomeInputType>(
|
||||||
MyController.new,
|
AuthorController.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/`.
|
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))
|
### Don't use StatefulWidgets ([Flutter Hooks](https://pub.dev/packages/flutter_hooks))
|
||||||
|
|
@ -65,36 +61,6 @@ When possible, use immutable collections instead of the mutable equivalent. For
|
||||||
|
|
||||||
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.
|
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/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.
|
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.
|
||||||
|
|
|
||||||
159
README.md
159
README.md
|
|
@ -1,5 +1,8 @@
|
||||||
# Nexus Client
|
# Nexus Client
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Nexus Client is still in development, and doesn't support everything needed for daily use.
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
|
A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
|
||||||
|
|
@ -10,63 +13,115 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
|
||||||
| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------: |
|
| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------: |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [x] Platform Support
|
||||||
|
- [x] Linux
|
||||||
|
- [x] Windows - Currently has some bugs, due to us not having any developers on Windows. If you want to fix these, get in contact with us though the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus).
|
||||||
|
- [x] Android
|
||||||
|
- [x] MacOS - Unsigned .dmg only
|
||||||
|
- [x] iOS - Unsigned .ipa only
|
||||||
|
- [ ] Web (may not be possible)
|
||||||
|
- [x] Login (via OAuth)
|
||||||
|
- [x] Rooms / Spaces
|
||||||
|
- [x] Displaying and choosing
|
||||||
|
- [x] Reading, showing unread
|
||||||
|
- [x] Mark as read button on rooms and spaces
|
||||||
|
- [ ] Searching
|
||||||
|
- [ ] Creating (Rooms, Spaces, and DMs)
|
||||||
|
- [x] Joining
|
||||||
|
- [x] Parse vias
|
||||||
|
- [x] Using a text/uri/link
|
||||||
|
- [x] Plain text
|
||||||
|
- [x] `matrix:` Uri
|
||||||
|
- [x] Matrix.to link
|
||||||
|
- [ ] From space
|
||||||
|
- [ ] From directory
|
||||||
|
- [x] Leaving
|
||||||
|
- [x] Subspaces
|
||||||
|
- [x] Messages
|
||||||
|
- [x] Encryption
|
||||||
|
- [x] Restoring crypto identity from a recovery passphrase/key
|
||||||
|
- [x] Sending
|
||||||
|
- [x] Plain text
|
||||||
|
- [x] HTML/Markdown
|
||||||
|
- [x] Replies
|
||||||
|
- [x] Choose ping on/off
|
||||||
|
- [x] Per message profiles
|
||||||
|
- [x] Attachments
|
||||||
|
- [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391)
|
||||||
|
- [x] Mentions
|
||||||
|
- [x] Users
|
||||||
|
- [x] Rooms
|
||||||
|
- [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions)
|
||||||
|
- [ ] Custom emojis/stickers
|
||||||
|
- [ ] GIFs using Gomuks' GIF proxies
|
||||||
|
- [x] Receiving
|
||||||
|
- [x] Plain text
|
||||||
|
- [x] Per message profiles
|
||||||
|
- [x] HTML
|
||||||
|
- [x] URL Previews
|
||||||
|
- [x] Replies
|
||||||
|
- [x] Viewing
|
||||||
|
- [ ] Jump to original message
|
||||||
|
- [x] In loaded timeline
|
||||||
|
- [ ] Out of loaded timeline
|
||||||
|
- [x] Edits
|
||||||
|
- [x] Attachments
|
||||||
|
- [x] Unencrypted
|
||||||
|
- [x] Encrypted
|
||||||
|
- [x] Blurhashing
|
||||||
|
- [ ] Downloading attachments
|
||||||
|
- [x] Opening attachments in their own view
|
||||||
|
- [ ] Polls
|
||||||
|
- [x] Mentions
|
||||||
|
- [x] Users
|
||||||
|
- [x] Clickable
|
||||||
|
- [x] Rooms
|
||||||
|
- [ ] Clickable
|
||||||
|
- [x] Matrix URIs
|
||||||
|
- [x] Matrix.to links
|
||||||
|
- [x] Events
|
||||||
|
- [ ] Render more nicely
|
||||||
|
- [ ] Clickable
|
||||||
|
- [x] Custom emojis/stickers
|
||||||
|
- [x] History loading
|
||||||
|
- [x] Editing
|
||||||
|
- [x] Deleting
|
||||||
|
- [x] Reactions
|
||||||
|
- [x] Pins
|
||||||
|
- [x] Displaying
|
||||||
|
- [x] Pinning/Unpinning
|
||||||
|
- [ ] Threads
|
||||||
|
- [x] Profile popouts
|
||||||
|
- [x] Working actions
|
||||||
|
- [x] Copy link to:
|
||||||
|
- [x] Room
|
||||||
|
- [x] Space
|
||||||
|
- [x] Message
|
||||||
|
- [ ] Reporting
|
||||||
|
- [x] Events
|
||||||
|
- [ ] Rooms
|
||||||
|
- [x] Member list
|
||||||
|
- [x] Sort by power level
|
||||||
|
- [ ] Colors based off of power level
|
||||||
|
- [ ] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35))
|
||||||
|
- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195)
|
||||||
|
- [ ] Invites
|
||||||
|
- [x] Settings
|
||||||
|
|
||||||
## Try it out
|
## Try it out
|
||||||
|
|
||||||
If you want to try out Nexus, download the [latest release](https://git.federated.nexus/Nexus/nexus/releases/latest).
|
If you want to try out Nexus, grab one of the following artifacts from CI:
|
||||||
|
|
||||||
### Nightly Builds
|
|
||||||
|
|
||||||
For the newest changes, grab one of the following nightly artifacts, built by CI from the latest commit on `main`.
|
|
||||||
|
|
||||||
- [Android APK](https://nightly.link/Henry-Hiles/nexus/workflows/android/main/APK.zip)
|
- [Android APK](https://nightly.link/Henry-Hiles/nexus/workflows/android/main/APK.zip)
|
||||||
|
- [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip)
|
||||||
- [Unsigned iOS IPA](https://nightly.link/Henry-Hiles/nexus/workflows/ios/main/Nexus.ipa.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)
|
- [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)
|
- [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip)
|
||||||
|
- Flatpak
|
||||||
Or, use one of the below methods:
|
- [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip)
|
||||||
|
- [x86_64/AMD64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-x86_64.zip)
|
||||||
### 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";
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
## Build it yourself
|
||||||
|
|
||||||
|
|
@ -74,7 +129,7 @@ This installs Nexus system-wide. With `enableNotifications`, it also installs KU
|
||||||
|
|
||||||
#### Linux
|
#### Linux
|
||||||
|
|
||||||
- On NixOS: Either use direnv and `direnv allow`, or `nix develop`
|
- On NixOS: Either use direnv and `direnv allow`, or `nix flake develop`
|
||||||
- On other distros: Install Flutter, Go, Git, Libclang, Libass, MPV, and Glibc. Do not use any Snap packages, they cause various compilation issues.
|
- On other distros: Install Flutter, Go, Git, Libclang, Libass, MPV, and Glibc. Do not use any Snap packages, they cause various compilation issues.
|
||||||
|
|
||||||
#### Windows
|
#### Windows
|
||||||
|
|
@ -153,7 +208,7 @@ Development instructions can be found in [DEVELOPMENT.md](./DEVELOPMENT.md).
|
||||||
|
|
||||||
Join the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client.
|
Join the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client.
|
||||||
|
|
||||||
## Credits
|
# Credits
|
||||||
|
|
||||||
Thank you Hylke Bons (https://planetpeanut.studio) for making the amazing icon for Nexus!
|
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!
|
Thank you Tulir Asokan for making [Gomuks](https://github.com/gomuks/gomuks), and helping us integrate it into Nexus!
|
||||||
|
|
|
||||||
22
altstore-source.json
Normal file
22
altstore-source.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "Nexus",
|
||||||
|
"identifier": "nexus.federated.nexus.source",
|
||||||
|
"subtitle": "Nexus for iOS",
|
||||||
|
"description": "Nexus iOS builds for AltStore and SideStore.",
|
||||||
|
"iconURL": "https://git.federated.nexus/Nexus/nexus/raw/branch/main/assets/icon.png",
|
||||||
|
"website": "https://git.federated.nexus/Nexus/nexus",
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"name": "Nexus",
|
||||||
|
"bundleIdentifier": "nexus.federated.nexus",
|
||||||
|
"developerName": "Nexus",
|
||||||
|
"subtitle": "A Matrix client",
|
||||||
|
"localizedDescription": "Nexus for iOS. Unsigned builds, resigned at install by AltStore/SideStore.",
|
||||||
|
"iconURL": "https://git.federated.nexus/Nexus/nexus/raw/branch/main/assets/icon.png",
|
||||||
|
"category": "social",
|
||||||
|
"screenshots": [],
|
||||||
|
"versions": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"news": []
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ analyzer:
|
||||||
errors:
|
errors:
|
||||||
invalid_annotation_target: ignore
|
invalid_annotation_target: ignore
|
||||||
avoid_print: ignore
|
avoid_print: ignore
|
||||||
|
annotate_overrides: ignore
|
||||||
exclude:
|
exclude:
|
||||||
- "build/**"
|
- "build/**"
|
||||||
- "**/*.g.dart"
|
- "**/*.g.dart"
|
||||||
|
|
|
||||||
|
|
@ -20,21 +20,16 @@ android {
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
targetCompatibility = JavaVersion.VERSION_21
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "nexus.federated.nexus"
|
applicationId = "nexus.federated.nexus"
|
||||||
|
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|
||||||
multiDexEnabled = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
@ -68,11 +63,6 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation("org.unifiedpush.android:embedded-fcm-distributor:3.1.0")
|
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="29" />
|
android:maxSdkVersion="29" />
|
||||||
<!-- Until https://github.com/flutter/flutter/issues/145290 is fixed, we need enableOnBackInvokedCallback as false -->
|
|
||||||
<application
|
<application
|
||||||
android:enableOnBackInvokedCallback="false"
|
|
||||||
android:label="Nexus"
|
android:label="Nexus"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
|
@ -51,16 +49,6 @@
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
<!-- Needed by pasteboard, for pasting files from the clipboard -->
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.provider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/provider_paths" />
|
|
||||||
</provider>
|
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package nexus.federated.nexus
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity: FlutterActivity()
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
package nexus.federated.nexus
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import android.view.WindowInsets
|
|
||||||
import android.view.WindowInsetsAnimation
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
|
||||||
|
|
||||||
// TODO: Revert this commit once https://github.com/flutter/flutter/issues/191156 is fixed
|
|
||||||
class MainActivity: FlutterActivity() {
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
|
|
||||||
|
|
||||||
window.decorView.setWindowInsetsAnimationCallback(
|
|
||||||
object : WindowInsetsAnimation.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
|
|
||||||
override fun onProgress(
|
|
||||||
insets: WindowInsets,
|
|
||||||
runningAnimations: List<WindowInsetsAnimation>,
|
|
||||||
) = insets
|
|
||||||
|
|
||||||
override fun onEnd(animation: WindowInsetsAnimation) {
|
|
||||||
if ((animation.typeMask and WindowInsets.Type.ime()) != 0) reapplyInsets()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
|
||||||
super.onWindowFocusChanged(hasFocus)
|
|
||||||
if (hasFocus && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) reapplyInsets()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Posted so it runs after Flutter has applied any insets it deferred.
|
|
||||||
private fun reapplyInsets() {
|
|
||||||
val view = findViewById<View>(FLUTTER_VIEW_ID) ?: return
|
|
||||||
view.post { view.rootWindowInsets?.let(view::onApplyWindowInsets) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<external-path
|
|
||||||
name="external_files"
|
|
||||||
path="." />
|
|
||||||
</paths>
|
|
||||||
|
|
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-all.zip
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
BIN
assets/fonts/NotoColorEmoji.ttf
Normal file
BIN
assets/fonts/NotoColorEmoji.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Roboto.ttf
Normal file
BIN
assets/fonts/Roboto.ttf
Normal file
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 616 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 616 KiB |
BIN
assets/twim/oauth.webp
Normal file
BIN
assets/twim/oauth.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
assets/twim/settings.png
Normal file
BIN
assets/twim/settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
24
flake.lock
generated
24
flake.lock
generated
|
|
@ -5,11 +5,11 @@
|
||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788450739,
|
"lastModified": 1785627969,
|
||||||
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
|
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
|
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -42,11 +42,11 @@
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1791751882,
|
"lastModified": 1774860670,
|
||||||
"narHash": "sha256-DWcMlQFQMEqQGAHU6rAbl4uKWmlJMR1HZG0BvH0H2fk=",
|
"narHash": "sha256-YjJkQrvxrErXtfDi3obUn6rNmkA+CIAZ3f5NgL5xuYE=",
|
||||||
"owner": "neobrain",
|
"owner": "neobrain",
|
||||||
"repo": "nix2flatpak",
|
"repo": "nix2flatpak",
|
||||||
"rev": "7731e8b3fca73879f8217de5a919c0a5d17303de",
|
"rev": "61d68e21e3fbc2d57590051f48736bea271f4aba",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -73,11 +73,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
"nixpkgs-lib": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788057806,
|
"lastModified": 1785031560,
|
||||||
"narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=",
|
"narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixpkgs.lib",
|
"repo": "nixpkgs.lib",
|
||||||
"rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c",
|
"rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -88,11 +88,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1790689126,
|
"lastModified": 1786862985,
|
||||||
"narHash": "sha256-ilerN1WLSvF+HMjziC/Wv99J5y02maDH+6hZPwsORKg=",
|
"narHash": "sha256-FBJRXmbGXiSUDvYEbfLYRkckayyZ6SK1UEqhCrIZ2Cs=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "b4fd65b198c599cbe814fcb9f42d25d021595ec9",
|
"rev": "e5bdc4a41d4c072fe1e3787eaa0320a384741d44",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
52
flake.nix
52
flake.nix
|
|
@ -16,12 +16,62 @@
|
||||||
...
|
...
|
||||||
}@inputs:
|
}@inputs:
|
||||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
imports = [ ./linux/nix ];
|
|
||||||
systems = [
|
systems = [
|
||||||
"x86_64-linux"
|
"x86_64-linux"
|
||||||
"aarch64-linux"
|
"aarch64-linux"
|
||||||
"aarch64-darwin"
|
"aarch64-darwin"
|
||||||
"x86_64-darwin"
|
"x86_64-darwin"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
perSystem =
|
||||||
|
{
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
system,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
{
|
||||||
|
_module.args.pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
config = {
|
||||||
|
android_sdk.accept_license = true;
|
||||||
|
allowUnfree = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
packages =
|
||||||
|
let
|
||||||
|
default = pkgs.callPackage ./linux/nix/pkg {
|
||||||
|
src = self;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit default;
|
||||||
|
|
||||||
|
flatpak = inputs.nix2flatpak.lib.${system}.mkFlatpak {
|
||||||
|
appName = "Nexus";
|
||||||
|
developer = "QuadRadical";
|
||||||
|
appId = "nexus.federated.nexus";
|
||||||
|
package = default;
|
||||||
|
runtime = "org.gnome.Platform/49";
|
||||||
|
permissions = {
|
||||||
|
share = [ "network" ];
|
||||||
|
sockets = [
|
||||||
|
"pulseaudio"
|
||||||
|
"fallback-x11"
|
||||||
|
"wayland"
|
||||||
|
];
|
||||||
|
devices = [ "dri" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
gomuks = pkgs.callPackage ./linux/nix/pkg/gomuks.nix {
|
||||||
|
src = self;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells.default = pkgs.callPackage ./linux/nix/devshell.nix { };
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
gomuks
2
gomuks
|
|
@ -1 +1 @@
|
||||||
Subproject commit 53cae0e0334aef5ac376ea84f19ba1cff8a92310
|
Subproject commit 0e06c7b74f96d3e29a8101893ca1921d13a90573
|
||||||
|
|
@ -122,7 +122,7 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
|
||||||
"sqlite_fts5",
|
"sqlite_fts5",
|
||||||
"goolm",
|
"goolm",
|
||||||
// goheif/dav1d is not supported on Android or iOS, would need to be fixed upstream
|
// goheif/dav1d is not supported on Android or iOS, would need to be fixed upstream
|
||||||
"noheic",
|
if (targetOS == OS.android || targetOS == OS.iOS) "noheic",
|
||||||
].join(",");
|
].join(",");
|
||||||
|
|
||||||
final archiveFile = targetOS == OS.iOS
|
final archiveFile = targetOS == OS.iOS
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import UIKit
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter/foundation.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/models/account_data.dart";
|
import "package:nexus/models/account_data.dart";
|
||||||
|
|
||||||
|
|
@ -10,16 +9,11 @@ class AccountDataController extends Notifier<AccountData> {
|
||||||
AccountData build() => .new();
|
AccountData build() => .new();
|
||||||
|
|
||||||
void update(IMap<String, IMap<String, dynamic>> newAccountData) =>
|
void update(IMap<String, IMap<String, dynamic>> newAccountData) =>
|
||||||
state = newAccountData.entries.fold(state, (data, entry) {
|
state = .fromJson({
|
||||||
try {
|
...json.decode(json.encode(state.toJson())),
|
||||||
return .fromJson({
|
...newAccountData
|
||||||
...json.decode(json.encode(data.toJson())),
|
.map((key, value) => MapEntry(key, value["content"]))
|
||||||
entry.key: entry.value["content"],
|
.unlock,
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
debugPrint("Ignoring invalid ${entry.key} account data: $error");
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
static final provider = NotifierProvider<AccountDataController, AccountData>(
|
static final provider = NotifierProvider<AccountDataController, AccountData>(
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:material_emoji_picker/material_emoji_picker.dart";
|
|
||||||
import "package:nexus/controllers/image_packs.dart";
|
|
||||||
import "package:nexus/helpers/extensions/image_pack_emoji.dart";
|
|
||||||
|
|
||||||
class AllEmojiController(final String roomId)
|
|
||||||
extends AsyncNotifier<IList<EmojiCategory>> {
|
|
||||||
@override
|
|
||||||
Future<IList<EmojiCategory>> build() async {
|
|
||||||
final packs = await ref.watch(ImagePacksController.provider(roomId).future);
|
|
||||||
final unicode = await ref.watch(EmojiController.provider.future);
|
|
||||||
|
|
||||||
return packs
|
|
||||||
.map((pack) => pack.emojiCategory)
|
|
||||||
.nonNulls
|
|
||||||
.toIList()
|
|
||||||
.addAll(unicode);
|
|
||||||
}
|
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.autoDispose
|
|
||||||
.family<AllEmojiController, IList<EmojiCategory>, String>(
|
|
||||||
AllEmojiController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import "package:flutter/widgets.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
|
|
||||||
class AppPausedController extends Notifier<bool> {
|
|
||||||
@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, bool>(
|
|
||||||
AppPausedController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -23,7 +23,7 @@ class AttachmentController(final String roomId)
|
||||||
);
|
);
|
||||||
|
|
||||||
final content = await ref
|
final content = await ref
|
||||||
.read(ClientController.provider.notifier)
|
.watch(ClientController.provider.notifier)
|
||||||
.uploadMedia(.new(path: file.path, encrypt: isEncrypted));
|
.uploadMedia(.new(path: file.path, encrypt: isEncrypted));
|
||||||
|
|
||||||
state = (filename, content);
|
state = (filename, content);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ class AuthUrlController(final Uri homeserver)
|
||||||
extends AsyncNotifier<OAuthAuthCodeResponse> {
|
extends AsyncNotifier<OAuthAuthCodeResponse> {
|
||||||
@override
|
@override
|
||||||
Future<OAuthAuthCodeResponse> build() async => ref
|
Future<OAuthAuthCodeResponse> build() async => ref
|
||||||
.read(ClientController.provider.notifier)
|
.watch(ClientController.provider.notifier)
|
||||||
.getAuthUrl(
|
.getAuthUrl(
|
||||||
.new(
|
.new(
|
||||||
homeserverUrl: homeserver,
|
homeserverUrl: homeserver,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import "dart:async";
|
|
||||||
import "dart:ffi";
|
import "dart:ffi";
|
||||||
import "dart:io";
|
import "dart:io";
|
||||||
import "dart:isolate";
|
import "dart:isolate";
|
||||||
|
|
@ -6,44 +5,33 @@ import "dart:math";
|
||||||
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:ffi/ffi.dart";
|
import "package:ffi/ffi.dart";
|
||||||
import "package:flutter/cupertino.dart";
|
import "package:flutter/foundation.dart";
|
||||||
import "package:intl/intl.dart";
|
import "package:nexus/controllers/account_data.dart";
|
||||||
import "package:nexus/controllers/settings.dart";
|
import "package:nexus/controllers/client_state.dart";
|
||||||
|
import "package:nexus/controllers/init_complete.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/helpers/extensions/gomuks_buffer.dart";
|
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
||||||
import "package:nexus/models/capabilities.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:nexus/models/content/message.dart";
|
import "package:nexus/models/content/message.dart";
|
||||||
import "package:nexus/models/event.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/oauth_auth_code_response.dart";
|
||||||
import "package:nexus/models/open_graph_data.dart";
|
import "package:nexus/models/open_graph_data.dart";
|
||||||
import "package:nexus/models/paginate.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/download_media.dart";
|
||||||
import "package:nexus/models/requests/get_event.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_related_events.dart";
|
||||||
import "package:nexus/models/requests/get_room_state.dart";
|
import "package:nexus/models/requests/get_room_state.dart";
|
||||||
import "package:nexus/models/requests/get_specific_room_state.dart";
|
|
||||||
import "package:nexus/models/requests/get_space_hierarchy.dart";
|
|
||||||
import "package:nexus/models/requests/join_room.dart";
|
import "package:nexus/models/requests/join_room.dart";
|
||||||
import "package:nexus/models/profile_response.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/exchange_token.dart";
|
||||||
import "package:nexus/models/requests/oauth/get_auth_url.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/oauth/register_client.dart";
|
||||||
import "package:nexus/models/requests/paginate.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/redact_event.dart";
|
||||||
import "package:nexus/models/requests/register_pusher.dart";
|
|
||||||
import "package:nexus/models/requests/report.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_event.dart";
|
||||||
import "package:nexus/models/requests/send_message.dart";
|
import "package:nexus/models/requests/send_message.dart";
|
||||||
import "package:nexus/models/requests/set_account_data.dart";
|
import "package:nexus/models/requests/set_account_data.dart";
|
||||||
|
|
@ -52,8 +40,8 @@ import "package:nexus/models/requests/set_state.dart";
|
||||||
import "package:nexus/models/requests/upload_media.dart";
|
import "package:nexus/models/requests/upload_media.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
import "package:nexus/models/room_summary.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/models/spec_versions_response.dart";
|
||||||
|
import "package:nexus/models/sync_data.dart";
|
||||||
import "package:nexus/src/third_party/gomuks.g.dart";
|
import "package:nexus/src/third_party/gomuks.g.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:path_provider/path_provider.dart";
|
import "package:path_provider/path_provider.dart";
|
||||||
|
|
@ -61,115 +49,134 @@ import "package:path_provider/path_provider.dart";
|
||||||
class ClientController extends AsyncNotifier<int> {
|
class ClientController extends AsyncNotifier<int> {
|
||||||
@override
|
@override
|
||||||
Future<int> build() async {
|
Future<int> build() async {
|
||||||
debugPrint("Setting Gomuks env...");
|
final Pointer<Char> root;
|
||||||
if (Platform.isAndroid || Platform.isIOS) {
|
if (Platform.isAndroid || Platform.isIOS) {
|
||||||
final env = {
|
final dir = await getApplicationSupportDirectory();
|
||||||
"GOMUKS_ROOT": (await getApplicationSupportDirectory()).path,
|
root = "${dir.path}/gomuks".toNativeUtf8().cast();
|
||||||
"GOMUKS_CACHE_HOME": (await getApplicationCacheDirectory()).path,
|
} else {
|
||||||
};
|
root = nullptr.cast();
|
||||||
for (final MapEntry(:key, :value) in env.entries) {
|
|
||||||
final keyPtr = key.toNativeUtf8().cast<Char>();
|
|
||||||
final valuePtr = value.toNativeUtf8().cast<Char>();
|
|
||||||
try {
|
|
||||||
GomuksSetEnv(keyPtr, valuePtr);
|
|
||||||
} finally {
|
|
||||||
calloc
|
|
||||||
..free(keyPtr)
|
|
||||||
..free(valuePtr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint("Initializing Gomuks...");
|
final handle = GomuksInit(root);
|
||||||
final handle = await Isolate.run(() {
|
|
||||||
final bufferPointer = GomuksConfig(
|
|
||||||
matrix: .new(
|
|
||||||
initialDeviceDisplayName:
|
|
||||||
"Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
|
|
||||||
),
|
|
||||||
).toJson().toGomuksBufferPtr();
|
|
||||||
|
|
||||||
|
final callable =
|
||||||
|
NativeCallable<
|
||||||
|
Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer)
|
||||||
|
>.listener((
|
||||||
|
Pointer<Char> command,
|
||||||
|
int requestId,
|
||||||
|
GomuksOwnedBuffer data,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
return GomuksInit(bufferPointer.ref);
|
final muksEventType = command.cast<Utf8>().toDartString();
|
||||||
} finally {
|
debugPrint("Handling $muksEventType...");
|
||||||
calloc
|
final decodedMuksEvent = data.toJson();
|
||||||
..free(bufferPointer.ref.base)
|
|
||||||
..free(bufferPointer);
|
switch (muksEventType) {
|
||||||
|
case "client_state":
|
||||||
|
ref
|
||||||
|
.watch(ClientStateController.provider.notifier)
|
||||||
|
.set(.fromJson(decodedMuksEvent));
|
||||||
|
break;
|
||||||
|
case "sync_status":
|
||||||
|
ref
|
||||||
|
.watch(SyncStatusController.provider.notifier)
|
||||||
|
.set(.fromJson(decodedMuksEvent));
|
||||||
|
break;
|
||||||
|
case "init_complete":
|
||||||
|
ref.watch(InitCompleteController.provider.notifier).complete();
|
||||||
|
break;
|
||||||
|
case "send_complete":
|
||||||
|
final event = Event.fromJson(decodedMuksEvent["event"]);
|
||||||
|
ref
|
||||||
|
.watch(RoomsController.provider.notifier)
|
||||||
|
.update(
|
||||||
|
.new({
|
||||||
|
event.roomId: .new(events: .new({event.rowId: event})),
|
||||||
|
}),
|
||||||
|
.new(),
|
||||||
|
);
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "sync_complete":
|
||||||
|
final syncData = SyncData.fromJson(decodedMuksEvent);
|
||||||
|
final roomProvider = RoomsController.provider;
|
||||||
|
final accountDataProvider = AccountDataController.provider;
|
||||||
|
|
||||||
|
if (syncData.clearState) {
|
||||||
|
ref.invalidate(roomProvider);
|
||||||
|
ref.invalidate(accountDataProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
ref
|
||||||
|
.watch(roomProvider.notifier)
|
||||||
|
.update(syncData.rooms, syncData.leftRooms);
|
||||||
|
ref
|
||||||
|
.watch(accountDataProvider.notifier)
|
||||||
|
.update(syncData.accountData);
|
||||||
|
|
||||||
|
if (syncData.topLevelSpaces != null) {
|
||||||
|
ref
|
||||||
|
.watch(TopLevelSpacesController.provider.notifier)
|
||||||
|
.set(syncData.topLevelSpaces!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncData.spaceEdges != null) {
|
||||||
|
ref
|
||||||
|
.watch(SpaceEdgesController.provider.notifier)
|
||||||
|
.set(syncData.spaceEdges!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ref
|
||||||
|
// .watch(SyncStatusController.provider.notifier)
|
||||||
|
// .set(SyncStatus.fromJson(decodedMuksEvent));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
debugPrint("Unhandled event: $muksEventType");
|
||||||
|
}
|
||||||
|
debugPrint("Finished handling $muksEventType...");
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrintStack(stackTrace: stackTrace, label: error.toString());
|
||||||
|
rethrow;
|
||||||
|
} else {
|
||||||
|
showError(error, stackTrace);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!ref.mounted) {
|
|
||||||
GomuksDestroy(handle);
|
|
||||||
return Completer<int>().future;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.onDispose(() => GomuksDestroy(handle));
|
ref.onDispose(() => GomuksDestroy(handle));
|
||||||
|
ref.onDispose(callable.close);
|
||||||
|
|
||||||
return handle;
|
final errorCode = GomuksStart(handle, callable.nativeFunction);
|
||||||
}
|
|
||||||
|
|
||||||
/// Calls gomuks with [data] as JSON, returning its decoded response.
|
if (errorCode == 0) return handle;
|
||||||
Future<dynamic> callGomuksMethod(
|
throw Exception("GomuksStart returned error code $errorCode");
|
||||||
Map<String, dynamic> data,
|
|
||||||
void Function(
|
|
||||||
int handle,
|
|
||||||
GomuksOwnedBuffer data,
|
|
||||||
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
|
|
||||||
)
|
|
||||||
call,
|
|
||||||
) async {
|
|
||||||
final handle = await future;
|
|
||||||
final buffer = data.toGomuksOwnedBuffer();
|
|
||||||
|
|
||||||
final completer = Completer<dynamic>();
|
|
||||||
late final NativeCallable<Void Function(GomuksResponse)> callable;
|
|
||||||
callable = .listener((GomuksResponse response) {
|
|
||||||
callable.close();
|
|
||||||
try {
|
|
||||||
final json = response.buf.toJsonAndFree();
|
|
||||||
|
|
||||||
if (response.command.cast<Utf8>().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<PushNotification?> handlePush(Map<String, dynamic> data) async {
|
|
||||||
final response = await callGomuksMethod(data, GomuksHandlePushAsync);
|
|
||||||
|
|
||||||
return response?["notification"] == null
|
|
||||||
? null
|
|
||||||
: .fromJson(response["notification"]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<dynamic> _sendCommand(
|
Future<dynamic> _sendCommand(
|
||||||
String command, [
|
String command, [
|
||||||
Map<String, dynamic> data = const {},
|
Map<String, dynamic> data = const {},
|
||||||
]) => callGomuksMethod(data, (handle, data, callback) {
|
]) async {
|
||||||
final commandPointer = command.toNativeUtf8().cast<Char>();
|
final bufferPointer = data.toGomuksBufferPtr();
|
||||||
try {
|
final handle = await future;
|
||||||
GomuksSubmitCommandAsync(handle, commandPointer, data, callback);
|
final response = await Isolate.run(
|
||||||
} finally {
|
() => GomuksSubmitCommand(
|
||||||
// gomuks copies the command name before returning
|
handle,
|
||||||
calloc.free(commandPointer);
|
command.toNativeUtf8().cast<Char>(),
|
||||||
|
bufferPointer.ref,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
calloc.free(bufferPointer);
|
||||||
|
|
||||||
|
final json = response.buf.toJson();
|
||||||
|
if (response.command.cast<Utf8>().toDartString() == "error") {
|
||||||
|
throw json;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
Future<void> redactEvent(RedactEventRequest report) =>
|
Future<void> redactEvent(RedactEventRequest report) =>
|
||||||
_sendCommand("redact_event", report.toJson());
|
_sendCommand("redact_event", report.toJson());
|
||||||
|
|
@ -181,12 +188,11 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
final json = request.toJson();
|
final json = request.toJson();
|
||||||
final content = request.content.toJson();
|
final content = request.content.toJson();
|
||||||
|
|
||||||
return .fromJson(
|
return Event.fromJson(
|
||||||
await _sendCommand("send_event", {
|
await _sendCommand("send_event", {
|
||||||
...json,
|
...json,
|
||||||
"content": {
|
"content": {
|
||||||
...content,
|
...content,
|
||||||
if (request.relatesTo != null)
|
|
||||||
"m.relates_to": {
|
"m.relates_to": {
|
||||||
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
|
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
|
||||||
"event_id": request.relatesTo,
|
"event_id": request.relatesTo,
|
||||||
|
|
@ -200,54 +206,30 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<String?> setState(SetStateRequest request) async =>
|
Future<String?> setState(SetStateRequest request) async =>
|
||||||
await _sendCommand("set_state", request.toJson());
|
await _sendCommand("set_state", request.toJson());
|
||||||
|
|
||||||
Future<void> verify(String recoveryKey) =>
|
Future<String?> verify(String recoveryKey) async {
|
||||||
_sendCommand("verify", {"recovery_key": recoveryKey});
|
try {
|
||||||
|
await _sendCommand("verify", {"recovery_key": recoveryKey});
|
||||||
Future<RecoveryKey> generateRecoveryKey(String passphrase) async => .fromJson(
|
|
||||||
await _sendCommand("generate_recovery_key", {"passphrase": passphrase}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> resetEncryption(RecoveryKey key) =>
|
|
||||||
_sendCommand("reset_encryption", key.toJson());
|
|
||||||
|
|
||||||
Future<Uri?> 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;
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return error.toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> joinRoom(JoinRoomRequest request) async =>
|
Future<String> joinRoom(JoinRoomRequest request) async =>
|
||||||
(await _sendCommand("join_room", request.toJson()))["room_id"];
|
(await _sendCommand("join_room", request.toJson()))["room_id"];
|
||||||
|
|
||||||
Future<String> knockRoom(JoinRoomRequest request) async =>
|
|
||||||
(await _sendCommand("knock_room", request.toJson()))["room_id"];
|
|
||||||
|
|
||||||
Future<String> createRoom(CreateRoomRequest request) async =>
|
|
||||||
(await _sendCommand("create_room", request.toJson()))["room_id"];
|
|
||||||
|
|
||||||
Future<SpaceHierarchy> getSpaceHierarchy(
|
|
||||||
GetSpaceHierarchyRequest request,
|
|
||||||
) async =>
|
|
||||||
.fromJson(await _sendCommand("get_space_hierarchy", request.toJson()));
|
|
||||||
|
|
||||||
Future<RoomSummary> getRoomSummary(JoinRoomRequest request) async =>
|
Future<RoomSummary> getRoomSummary(JoinRoomRequest request) async =>
|
||||||
.fromJson(await _sendCommand("get_room_summary", request.toJson()));
|
.fromJson(await _sendCommand("get_room_summary", request.toJson()));
|
||||||
|
|
||||||
Future<void> leaveRoom(String roomId) =>
|
Future<void> leaveRoom(Room room) async {
|
||||||
_sendCommand("leave_room", {"room_id": roomId});
|
if (room.metadata == null) return;
|
||||||
|
await _sendCommand("leave_room", {"room_id": room.metadata!.id});
|
||||||
|
}
|
||||||
|
|
||||||
|
// (await _sendCommand("get_event_context", {
|
||||||
|
// "room_id": request.roomId,
|
||||||
|
// "event_id": r"$OqZT4NuTj0J1-771IOEEWRI4XdumRNu6ighlvO3K3gc",
|
||||||
|
// }));
|
||||||
|
|
||||||
Future<IList<Event>> getRoomState(GetRoomStateRequest request) async {
|
Future<IList<Event>> getRoomState(GetRoomStateRequest request) async {
|
||||||
Future<List?> getState(GetRoomStateRequest request) async =>
|
Future<List?> getState(GetRoomStateRequest request) async =>
|
||||||
|
|
@ -261,15 +243,6 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<IList<Event>> getSpecificRoomState(
|
|
||||||
GetSpecificRoomStateRequest request,
|
|
||||||
) async => .new(
|
|
||||||
((await _sendCommand("get_specific_room_state", request.toJson()))
|
|
||||||
as List? ??
|
|
||||||
[])
|
|
||||||
.map((event) => .fromJson(event)),
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<IList<Event>?> getRelatedEvents(
|
Future<IList<Event>?> getRelatedEvents(
|
||||||
GetRelatedEventsRequest request,
|
GetRelatedEventsRequest request,
|
||||||
) async {
|
) async {
|
||||||
|
|
@ -278,9 +251,6 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
return .new(response?.map((event) => .fromJson(event)));
|
return .new(response?.map((event) => .fromJson(event)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<MentionsResponse> getMentions(GetMentionsRequest request) async =>
|
|
||||||
.fromJson(await _sendCommand("get_mentions", request.toJson()));
|
|
||||||
|
|
||||||
Future<Event?> getEvent(GetEventRequest request) async {
|
Future<Event?> getEvent(GetEventRequest request) async {
|
||||||
final json = await _sendCommand("get_event", request.toJson());
|
final json = await _sendCommand("get_event", request.toJson());
|
||||||
return json == null ? null : .fromJson(json);
|
return json == null ? null : .fromJson(json);
|
||||||
|
|
@ -292,35 +262,14 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<Paginate> paginate(PaginateRequest request) async =>
|
Future<Paginate> paginate(PaginateRequest request) async =>
|
||||||
.fromJson(await _sendCommand("paginate", request.toJson()));
|
.fromJson(await _sendCommand("paginate", request.toJson()));
|
||||||
|
|
||||||
Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
|
|
||||||
.fromJson(await _sendCommand("paginate_manual", request.toJson()));
|
|
||||||
|
|
||||||
Future<PaginateManual> searchLocal(SearchRequest request) async =>
|
|
||||||
.fromJson(await _sendCommand("search_local", request.toJson()));
|
|
||||||
|
|
||||||
Future<PaginateManual> searchServer(SearchRequest request) async =>
|
|
||||||
.fromJson(await _sendCommand("search_server", request.toJson()));
|
|
||||||
|
|
||||||
Future<EventContext> getEventContext(GetEventContextRequest request) async =>
|
|
||||||
.fromJson(await _sendCommand("get_event_context", request.toJson()));
|
|
||||||
|
|
||||||
Future<ProfileResponse> getProfile(String userId) async {
|
Future<ProfileResponse> getProfile(String userId) async {
|
||||||
try {
|
try {
|
||||||
return .fromJson(await _sendCommand("get_profile", {"user_id": userId}));
|
return .fromJson(await _sendCommand("get_profile", {"user_id": userId}));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return .new(profile: .new(id: userId));
|
return ProfileResponse(profile: .new(id: userId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> 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<void> reportEvent(ReportRequest request) =>
|
Future<void> reportEvent(ReportRequest request) =>
|
||||||
_sendCommand("report_event", request.toJson());
|
_sendCommand("report_event", request.toJson());
|
||||||
|
|
||||||
|
|
@ -330,15 +279,6 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<void> setAccountData(SetAccountDataRequest request) =>
|
Future<void> setAccountData(SetAccountDataRequest request) =>
|
||||||
_sendCommand("set_account_data", request.toJson());
|
_sendCommand("set_account_data", request.toJson());
|
||||||
|
|
||||||
Future<void> registerPusher(RegisterPusherRequest request) =>
|
|
||||||
_sendCommand("register_homeserver_push", request.toJson());
|
|
||||||
|
|
||||||
Future<void> deregisterPusher(DeregisterPusherRequest request) =>
|
|
||||||
_sendCommand("register_homeserver_push", {
|
|
||||||
...request.toJson(),
|
|
||||||
"kind": null,
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<MessageContent> uploadMedia(UploadMediaRequest request) async =>
|
Future<MessageContent> uploadMedia(UploadMediaRequest request) async =>
|
||||||
.fromJson(await _sendCommand("upload_media", request.toJson()));
|
.fromJson(await _sendCommand("upload_media", request.toJson()));
|
||||||
|
|
||||||
|
|
@ -347,26 +287,14 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
|
|
||||||
Future<void> logout() => _sendCommand("logout");
|
Future<void> logout() => _sendCommand("logout");
|
||||||
|
|
||||||
Future<void> setTyping(String roomId, Duration timeout) => _sendCommand(
|
|
||||||
"set_typing",
|
|
||||||
{"room_id": roomId, "timeout": timeout.inMilliseconds},
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> markRead(Room room) async {
|
Future<void> markRead(Room room) async {
|
||||||
if (room.timeline.isEmpty || room.metadata == null) return;
|
|
||||||
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
|
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
|
||||||
final event = eventRowId == null ? null : room.events[eventRowId];
|
final event = eventRowId == null ? null : room.events[eventRowId];
|
||||||
|
if (event == null || room.metadata == null) return;
|
||||||
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", {
|
await _sendCommand("mark_read", {
|
||||||
"room_id": room.metadata!.id,
|
"room_id": room.metadata!.id,
|
||||||
"receipt_type": sendPublic ? "m.read" : "m.read.private",
|
"receipt_type": "m.read",
|
||||||
"event_id": event.eventId,
|
"event_id": event.eventId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -385,40 +313,9 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<void> exchangeToken(OAuthExchangeTokenRequest request) async =>
|
Future<void> exchangeToken(OAuthExchangeTokenRequest request) async =>
|
||||||
await _sendCommand("oauth_exchange_token", request.toJson());
|
await _sendCommand("oauth_exchange_token", request.toJson());
|
||||||
|
|
||||||
Future<IList<String>> getMutualRooms(String userId) async => IList(
|
|
||||||
((await _sendCommand("get_mutual_rooms", {"user_id": userId}))["joined"]
|
|
||||||
as List?)
|
|
||||||
?.cast<String>(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<IList<String>> getLocalAliases(String roomId) async => IList(
|
|
||||||
((await _sendCommand("get_local_aliases", {"room_id": roomId}))["aliases"]
|
|
||||||
as List?)
|
|
||||||
?.cast<String>(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> createAlias(String roomId, String alias) =>
|
|
||||||
_sendCommand("create_alias", {"room_id": roomId, "alias": alias});
|
|
||||||
|
|
||||||
Future<void> deleteAlias(String alias) =>
|
|
||||||
_sendCommand("delete_alias", {"alias": alias});
|
|
||||||
|
|
||||||
/// The room [alias] points to, or null if it doesn't exist.
|
|
||||||
Future<String?> 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<SpecVersionsResponse> getSpecVersions() async =>
|
Future<SpecVersionsResponse> getSpecVersions() async =>
|
||||||
.fromJson(await _sendCommand("get_versions"));
|
.fromJson(await _sendCommand("get_versions"));
|
||||||
|
|
||||||
Future<Capabilities> getCapabilities() async =>
|
|
||||||
.fromJson((await _sendCommand("get_capabilities"))["capabilities"]);
|
|
||||||
|
|
||||||
Future<Uri?> discoverHomeserver(Uri homeserver) async {
|
Future<Uri?> discoverHomeserver(Uri homeserver) async {
|
||||||
try {
|
try {
|
||||||
final response = await _sendCommand("discover_homeserver", {
|
final response = await _sendCommand("discover_homeserver", {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import "package:nexus/controllers/client.dart";
|
||||||
class ClientIdController(final Uri homeserver) extends AsyncNotifier<String> {
|
class ClientIdController(final Uri homeserver) extends AsyncNotifier<String> {
|
||||||
@override
|
@override
|
||||||
Future<String> build() => ref
|
Future<String> build() => ref
|
||||||
.read(ClientController.provider.notifier)
|
.watch(ClientController.provider.notifier)
|
||||||
.registerClient(
|
.registerClient(
|
||||||
.new(
|
.new(
|
||||||
clientName: "Nexus",
|
clientName: "Nexus",
|
||||||
|
|
|
||||||
84
lib/controllers/emoji.dart
Normal file
84
lib/controllers/emoji.dart
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import "dart:convert";
|
||||||
|
import "package:emoji_text_field/models/emoji_category.dart";
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:http/http.dart";
|
||||||
|
import "package:nexus/models/emoji.dart";
|
||||||
|
|
||||||
|
typedef EmojiTuple = (IMap<String, EmojiCategory>, IMap<String, List<String>>);
|
||||||
|
|
||||||
|
class EmojiController extends AsyncNotifier<EmojiTuple> {
|
||||||
|
@override
|
||||||
|
Future<EmojiTuple> build() async {
|
||||||
|
final response = await get(
|
||||||
|
.https("github.com", "github/gemoji/raw/refs/heads/master/db/emoji.json"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception("Failed to load emoji data");
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
|
||||||
|
final entries = (data as List)
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.map(Emoji.fromJson)
|
||||||
|
.toIList();
|
||||||
|
|
||||||
|
final categoryMap = entries.fold<IMap<String, IList<String>>>(
|
||||||
|
.new(),
|
||||||
|
(acc, entry) => acc.update(
|
||||||
|
entry.category,
|
||||||
|
(list) => list.add(entry.emoji),
|
||||||
|
ifAbsent: () => .new([entry.emoji]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final keywordMap = entries.fold<IMap<String, IList<String>>>(
|
||||||
|
.new(),
|
||||||
|
(acc, entry) => acc.add(
|
||||||
|
entry.emoji,
|
||||||
|
.new([...entry.tags, ...entry.aliases, entry.description]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final customCategories = IMap.fromEntries(
|
||||||
|
categoryMap.entries.map(
|
||||||
|
(entry) => MapEntry(
|
||||||
|
entry.key,
|
||||||
|
EmojiCategory(
|
||||||
|
name: entry.key,
|
||||||
|
icon: switch (entry.key) {
|
||||||
|
"Smileys & Emotion" => Icons.emoji_emotions,
|
||||||
|
"People & Body" => Icons.emoji_people,
|
||||||
|
"Animals & Nature" => Icons.emoji_nature,
|
||||||
|
"Food & Drink" => Icons.emoji_food_beverage,
|
||||||
|
"Travel & Places" => Icons.travel_explore,
|
||||||
|
"Activities" => Icons.sports_soccer,
|
||||||
|
"Objects" => Icons.emoji_objects,
|
||||||
|
"Symbols" => Icons.emoji_symbols,
|
||||||
|
"Flags" => Icons.emoji_flags,
|
||||||
|
_ => Icons.category,
|
||||||
|
},
|
||||||
|
emojis: entry.value.toList(growable: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final customKeywords = IMap(
|
||||||
|
.fromEntries(
|
||||||
|
keywordMap.entries.map(
|
||||||
|
(e) => .new(e.key, e.value.toList(growable: false)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (customCategories, customKeywords);
|
||||||
|
}
|
||||||
|
|
||||||
|
static final provider = AsyncNotifierProvider<EmojiController, EmojiTuple>(
|
||||||
|
EmojiController.new,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,40 +9,18 @@ class EventController(final GetEventRequest request)
|
||||||
extends AsyncNotifier<Event?> {
|
extends AsyncNotifier<Event?> {
|
||||||
@override
|
@override
|
||||||
Future<Event?> build() async {
|
Future<Event?> build() async {
|
||||||
final event = ref.watch(
|
final room = ref.watch(
|
||||||
RoomsController.provider.select(
|
RoomsController.provider.select((value) => value[request.roomId]),
|
||||||
(rooms) => rooms[request.roomId]?.events.values.firstWhereOrNull(
|
);
|
||||||
|
final event = room?.events.values.firstWhereOrNull(
|
||||||
(event) => event.eventId == request.eventId,
|
(event) => event.eventId == request.eventId,
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (event != null) return event;
|
return event ??
|
||||||
|
await ref
|
||||||
final link = ref.keepAlive();
|
.watch(ClientController.provider.notifier)
|
||||||
final fetchedEvent = await ref
|
|
||||||
.read(ClientController.provider.notifier)
|
|
||||||
.getEvent(request)
|
.getEvent(request)
|
||||||
.onError((_, _) => null)
|
.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
|
static final provider = AsyncNotifierProvider.family
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
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<int> rowIds, String? nextBatch});
|
|
||||||
|
|
||||||
class EventSearchController(final EventSearchParams info)
|
|
||||||
extends AsyncNotifier<EventSearchResults> {
|
|
||||||
// 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<EventSearchResults> build() async {
|
|
||||||
if (info.query.trim().isEmpty) {
|
|
||||||
return (rowIds: IList<int>(), 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<int>(), 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<void> 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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
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<void> {
|
|
||||||
@override
|
|
||||||
Future<void> build() async {
|
|
||||||
debugPrint("Starting gomuks...");
|
|
||||||
final handle = await ref.watch(ClientController.provider.future);
|
|
||||||
|
|
||||||
final callable =
|
|
||||||
NativeCallable<
|
|
||||||
Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer)
|
|
||||||
>.listener((
|
|
||||||
Pointer<Char> command,
|
|
||||||
int requestId,
|
|
||||||
GomuksOwnedBuffer data,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
final muksEventType = command.cast<Utf8>().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<String> deviceId() {
|
|
||||||
final completer = Completer<String>();
|
|
||||||
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<int> _start(
|
|
||||||
int handle,
|
|
||||||
Pointer<
|
|
||||||
NativeFunction<Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer)>
|
|
||||||
>
|
|
||||||
callback,
|
|
||||||
) => Isolate.run(() => GomuksStart(handle, callback));
|
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider<GomuksListenerController, void>(
|
|
||||||
GomuksListenerController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:nexus/controllers/account_data.dart";
|
|
||||||
import "package:nexus/controllers/client.dart";
|
|
||||||
import "package:nexus/controllers/rooms.dart";
|
|
||||||
import "package:nexus/controllers/space_edges.dart";
|
|
||||||
import "package:nexus/models/content/content.dart";
|
|
||||||
import "package:nexus/models/requests/get_specific_room_state.dart";
|
|
||||||
|
|
||||||
typedef ImagePackSource = ({String roomId, String? stateKey});
|
|
||||||
|
|
||||||
class ImagePackSourcesController(final String roomId)
|
|
||||||
extends AsyncNotifier<IList<ImagePackSource>> {
|
|
||||||
@override
|
|
||||||
Future<IList<ImagePackSource>> build() async {
|
|
||||||
final enabledPacks = ref.watch(
|
|
||||||
AccountDataController.provider.select(
|
|
||||||
(data) =>
|
|
||||||
(data.imagePackRooms ?? data.legacyImagePackRooms)?.rooms.map(
|
|
||||||
(roomId, packs) => .new(roomId, packs.keys.toISet()),
|
|
||||||
) ??
|
|
||||||
const .empty(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final spaceEdges = ref.watch(SpaceEdgesController.provider);
|
|
||||||
final directParents = ref.watch(
|
|
||||||
RoomsController.provider.select(
|
|
||||||
(rooms) =>
|
|
||||||
rooms[roomId]?.state["m.space.parent"]?.keys.toISet() ??
|
|
||||||
const .empty(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Iterable<String> parentsOf(String childId) => spaceEdges.entries
|
|
||||||
.where((entry) => entry.value.any((edge) => edge.childId == childId))
|
|
||||||
.map((entry) => entry.key)
|
|
||||||
.followedBy(childId == roomId ? directParents : const []);
|
|
||||||
|
|
||||||
ISet<String> withAncestors(ISet<String> spaces, String childId) =>
|
|
||||||
parentsOf(childId).fold(
|
|
||||||
spaces,
|
|
||||||
(spaces, parentId) => spaces.contains(parentId)
|
|
||||||
? spaces
|
|
||||||
: withAncestors(spaces.add(parentId), parentId),
|
|
||||||
);
|
|
||||||
|
|
||||||
final rooms = ref.read(RoomsController.provider);
|
|
||||||
final spaces = withAncestors(
|
|
||||||
const .empty(),
|
|
||||||
roomId,
|
|
||||||
).remove(roomId).where(rooms.containsKey).toIList();
|
|
||||||
|
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
|
||||||
final roomsNotifier = ref.read(RoomsController.provider.notifier);
|
|
||||||
|
|
||||||
await Future.wait(
|
|
||||||
spaces
|
|
||||||
.where((spaceId) => !rooms[spaceId]!.hasFetchedState)
|
|
||||||
.map(
|
|
||||||
(spaceId) async => roomsNotifier.addState(
|
|
||||||
spaceId,
|
|
||||||
await client.getRoomState(.new(roomId: spaceId)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const packTypes = <EventType>[.imagePack, .legacyImagePack];
|
|
||||||
// Rooms we aren't in can't be fetched, and fetched rooms have every pack
|
|
||||||
final missingKeys = enabledPacks.entries
|
|
||||||
.where((entry) => !(rooms[entry.key]?.hasFetchedState ?? true))
|
|
||||||
.expand(
|
|
||||||
(entry) => entry.value
|
|
||||||
.where(
|
|
||||||
(stateKey) => packTypes.every(
|
|
||||||
(type) =>
|
|
||||||
rooms[entry.key]?.state[type.type]?[stateKey] == null,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.expand(
|
|
||||||
(stateKey) => packTypes.map(
|
|
||||||
(type) => RoomStateKey(
|
|
||||||
roomId: entry.key,
|
|
||||||
type: type.type,
|
|
||||||
stateKey: stateKey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toIList();
|
|
||||||
|
|
||||||
if (missingKeys.isNotEmpty) {
|
|
||||||
roomsNotifier.addSpecificState(
|
|
||||||
await client.getSpecificRoomState(.new(keys: missingKeys)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return .new([
|
|
||||||
(roomId: roomId, stateKey: null),
|
|
||||||
...spaces.map((spaceId) => (roomId: spaceId, stateKey: null)),
|
|
||||||
...enabledPacks.entries.expand(
|
|
||||||
(entry) => entry.value.map(
|
|
||||||
(stateKey) => (roomId: entry.key, stateKey: stateKey),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.autoDispose
|
|
||||||
.family<ImagePackSourcesController, IList<ImagePackSource>, String>(
|
|
||||||
ImagePackSourcesController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:nexus/controllers/image_pack_sources.dart";
|
|
||||||
import "package:nexus/controllers/rooms.dart";
|
|
||||||
import "package:nexus/models/content/content.dart";
|
|
||||||
import "package:nexus/models/content/image_pack.dart";
|
|
||||||
import "package:nexus/models/event.dart";
|
|
||||||
import "package:nexus/models/image_pack.dart";
|
|
||||||
|
|
||||||
class ImagePacksController(final String roomId)
|
|
||||||
extends AsyncNotifier<IList<ImagePack>> {
|
|
||||||
@override
|
|
||||||
Future<IList<ImagePack>> build() async {
|
|
||||||
final sources = await ref.watch(
|
|
||||||
ImagePackSourcesController.provider(roomId).future,
|
|
||||||
);
|
|
||||||
|
|
||||||
final packs = ref.watch(
|
|
||||||
RoomsController.provider.select(
|
|
||||||
(rooms) => sources.expand((source) {
|
|
||||||
final room = rooms[source.roomId];
|
|
||||||
if (room == null) return const <ImagePack>[];
|
|
||||||
|
|
||||||
final newPacks = room.state[EventType.imagePack.type];
|
|
||||||
final legacyPacks = room.state[EventType.legacyImagePack.type];
|
|
||||||
final roomName = room.metadata?.name ?? source.roomId;
|
|
||||||
|
|
||||||
return switch (source.stateKey) {
|
|
||||||
final stateKey? => ISet([stateKey]),
|
|
||||||
null => ISet(
|
|
||||||
newPacks?.keys ?? const <String>[],
|
|
||||||
).addAll(legacyPacks?.keys ?? const []),
|
|
||||||
}
|
|
||||||
.map(
|
|
||||||
(stateKey) =>
|
|
||||||
switch (newPacks?[stateKey] ?? legacyPacks?[stateKey]) {
|
|
||||||
final rowId? => switch (room.events[rowId]) {
|
|
||||||
Event(
|
|
||||||
redactedBy: null,
|
|
||||||
content: final ImagePackContent content,
|
|
||||||
) =>
|
|
||||||
ImagePack.fromContent(
|
|
||||||
content,
|
|
||||||
id: "${source.roomId}/$stateKey",
|
|
||||||
fallbackName: stateKey.isEmpty
|
|
||||||
? roomName
|
|
||||||
: "$roomName - $stateKey",
|
|
||||||
),
|
|
||||||
_ => null,
|
|
||||||
},
|
|
||||||
null => null,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.nonNulls;
|
|
||||||
}).toIList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return .new(
|
|
||||||
packs
|
|
||||||
.whereNoDuplicates(by: (pack) => pack.id)
|
|
||||||
.where(
|
|
||||||
(pack) => pack.emoticons.isNotEmpty || pack.stickers.isNotEmpty,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.autoDispose
|
|
||||||
.family<ImagePacksController, IList<ImagePack>, String>(
|
|
||||||
ImagePacksController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
import "package:collection/collection.dart";
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:nexus/controllers/client.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<IList<RoomSummary>> {
|
|
||||||
@override
|
|
||||||
IList<RoomSummary> build() => .new();
|
|
||||||
|
|
||||||
/// Adds new invites, and removes invites for rooms that have since been
|
|
||||||
/// joined or left.
|
|
||||||
void update(
|
|
||||||
IList<InvitedRoom> invitedRooms, {
|
|
||||||
ISet<String> joinedRooms = const .empty(),
|
|
||||||
ISet<String> 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<void> 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<void> declineAll() =>
|
|
||||||
Future.wait(state.map((invite) => decline(invite.roomId)));
|
|
||||||
|
|
||||||
static final provider =
|
|
||||||
NotifierProvider<InvitesController, IList<RoomSummary>>(
|
|
||||||
InvitesController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
|
|
||||||
typedef JumpRequest = ({String roomId, int rowId});
|
|
||||||
|
|
||||||
class JumpRequestController extends Notifier<JumpRequest?> {
|
|
||||||
@override
|
|
||||||
JumpRequest? build() => null;
|
|
||||||
|
|
||||||
void request(String roomId, int rowId) =>
|
|
||||||
state = (roomId: roomId, rowId: rowId);
|
|
||||||
|
|
||||||
void consume() => state = null;
|
|
||||||
|
|
||||||
static final provider = NotifierProvider<JumpRequestController, JumpRequest?>(
|
|
||||||
JumpRequestController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +1,27 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/shared_prefs.dart";
|
import "package:nexus/controllers/shared_prefs.dart";
|
||||||
|
|
||||||
class KeyController(final String key) extends AsyncNotifier<String?> {
|
class KeyController(final String key) extends Notifier<String?> {
|
||||||
static const String spaceKey = "space";
|
static const String spaceKey = "space";
|
||||||
static const String roomKey = "room";
|
static const String roomKey = "room";
|
||||||
static const String pushKeyKey = "pushKey";
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String?> build() =>
|
String? build() =>
|
||||||
ref.watch(SharedPrefsController.provider).getString(key);
|
ref.watch(SharedPrefsController.provider).requireValue.getString(key);
|
||||||
|
|
||||||
Future<void> set(String? value) async {
|
Future<void> set(String? value) async {
|
||||||
final prefs = ref.read(SharedPrefsController.provider);
|
final prefs = ref.watch(SharedPrefsController.provider).requireValue;
|
||||||
state = .data(value);
|
state = value;
|
||||||
|
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
await prefs.remove(key);
|
prefs.remove(key);
|
||||||
} else {
|
} else {
|
||||||
await prefs.setString(key, value);
|
prefs.setString(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider =
|
static final provider =
|
||||||
AsyncNotifierProvider.family<KeyController, String?, String>(
|
NotifierProvider.family<KeyController, String?, String>(
|
||||||
KeyController.new,
|
KeyController.new,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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<IList<String>> {
|
|
||||||
@override
|
|
||||||
Future<IList<String>> build() =>
|
|
||||||
ref.read(ClientController.provider.notifier).getLocalAliases(roomId);
|
|
||||||
|
|
||||||
Future<void> add(String alias) async {
|
|
||||||
await ref
|
|
||||||
.read(ClientController.provider.notifier)
|
|
||||||
.createAlias(roomId, alias);
|
|
||||||
ref.invalidateSelf();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> remove(String alias) async {
|
|
||||||
await ref.read(ClientController.provider.notifier).deleteAlias(alias);
|
|
||||||
ref.invalidateSelf();
|
|
||||||
}
|
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
|
||||||
.autoDispose<LocalAliasesController, IList<String>, String>(
|
|
||||||
LocalAliasesController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +1,22 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/shared_prefs.dart";
|
import "package:nexus/controllers/shared_prefs.dart";
|
||||||
|
|
||||||
class MemberListOpenedController extends AsyncNotifier<bool> {
|
class MemberListOpenedController extends Notifier<bool> {
|
||||||
static const String key = "memberListOpened";
|
static const String key = "memberListOpened";
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> build() async =>
|
bool build() =>
|
||||||
await ref.watch(SharedPrefsController.provider).getBool(key) ?? true;
|
ref.watch(SharedPrefsController.provider).requireValue.getBool(key) ??
|
||||||
|
true;
|
||||||
|
|
||||||
Future<void> set(bool value) async {
|
Future<void> set(bool value) async {
|
||||||
state = .data(value);
|
final prefs = ref.watch(SharedPrefsController.provider).requireValue;
|
||||||
await ref.read(SharedPrefsController.provider).setBool(key, value);
|
state = value;
|
||||||
|
|
||||||
|
prefs.setBool(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider =
|
static final provider = NotifierProvider<MemberListOpenedController, bool>(
|
||||||
AsyncNotifierProvider<MemberListOpenedController, bool>(
|
|
||||||
MemberListOpenedController.new,
|
MemberListOpenedController.new,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class MembersController(final String roomId)
|
||||||
|
|
||||||
if (!room.hasFetchedMembers) {
|
if (!room.hasFetchedMembers) {
|
||||||
final fetchedState = await ref
|
final fetchedState = await ref
|
||||||
.read(ClientController.provider.notifier)
|
.watch(ClientController.provider.notifier)
|
||||||
.getRoomState(
|
.getRoomState(
|
||||||
GetRoomStateRequest(
|
GetRoomStateRequest(
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
|
|
@ -27,7 +27,7 @@ class MembersController(final String roomId)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
ref
|
await ref
|
||||||
.read(RoomsController.provider.notifier)
|
.read(RoomsController.provider.notifier)
|
||||||
.addState(roomId, fetchedState, isMembers: true);
|
.addState(roomId, fetchedState, isMembers: true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,44 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/members_by_status.dart";
|
import "package:nexus/controllers/members_by_status.dart";
|
||||||
import "package:nexus/controllers/room_power_levels.dart";
|
import "package:nexus/controllers/room_creators.dart";
|
||||||
|
import "package:nexus/controllers/rooms.dart";
|
||||||
import "package:nexus/models/configs/members_by_status.dart";
|
import "package:nexus/models/configs/members_by_status.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
import "package:nexus/models/content/power_levels.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
|
|
||||||
class MembersGroupedController(final MembersByStatusConfig config)
|
class MembersGroupedController(final MembersByStatusConfig config)
|
||||||
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
|
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
|
||||||
@override
|
@override
|
||||||
Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
|
Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
|
||||||
final powerLevels = ref.watch(
|
final room = ref.watch(
|
||||||
RoomPowerLevelsController.provider(config.roomId),
|
RoomsController.provider.select((value) => value[config.roomId]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final roomCreators = room == null
|
||||||
|
? null
|
||||||
|
: ref.watch((RoomCreatorsController.provider(room)));
|
||||||
|
|
||||||
|
final powerLevelsRowId = room?.state[EventType.powerLevels.type]?[""];
|
||||||
|
final powerLevelsEvent = powerLevelsRowId == null
|
||||||
|
? null
|
||||||
|
: room?.events[powerLevelsRowId];
|
||||||
|
|
||||||
|
final content = switch (powerLevelsEvent?.content) {
|
||||||
|
PowerLevelsContent content => content,
|
||||||
|
_ => PowerLevelsContent(),
|
||||||
|
};
|
||||||
|
|
||||||
final members = await ref.watch(
|
final members = await ref.watch(
|
||||||
MembersByStatusController.provider(config).future,
|
MembersByStatusController.provider(config).future,
|
||||||
);
|
);
|
||||||
|
|
||||||
return members
|
return members
|
||||||
.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
|
.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
|
||||||
final groupKey = powerLevels.creators.contains(event.stateKey!)
|
final groupKey = roomCreators?.contains(event.stateKey!) == true
|
||||||
? null
|
? null
|
||||||
: powerLevels.levelOf(event.stateKey!).toInt();
|
: content.users[event.stateKey!] ?? content.usersDefault;
|
||||||
|
|
||||||
return result.update(
|
return result.update(
|
||||||
groupKey,
|
groupKey,
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,8 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
class MultiProviderController(final IList<AsyncNotifierProvider> providers)
|
class MultiProviderController(final IList<AsyncNotifierProvider> providers)
|
||||||
extends AsyncNotifier<void> {
|
extends AsyncNotifier<void> {
|
||||||
@override
|
@override
|
||||||
Future<void> build() => .wait(
|
Future<void> build() =>
|
||||||
providers.map((provider) => ref.watch(provider.future)),
|
.wait(providers.map((provider) => ref.watch(provider.future)));
|
||||||
eagerError: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
static final provider =
|
static final provider =
|
||||||
AsyncNotifierProvider.family<
|
AsyncNotifierProvider.family<
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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<IList<String>> {
|
|
||||||
@override
|
|
||||||
Future<IList<String>> build() =>
|
|
||||||
ref.read(ClientController.provider.notifier).getMutualRooms(userId);
|
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
|
||||||
.autoDispose<MutualRoomsController, IList<String>, String>(
|
|
||||||
MutualRoomsController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,281 +0,0 @@
|
||||||
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<FlutterLocalNotificationsPlugin> {
|
|
||||||
@override
|
|
||||||
Future<FlutterLocalNotificationsPlugin> 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<bool> 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<bool> 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<void> 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(.element);
|
|
||||||
|
|
||||||
await portal.notification.addNotification(
|
|
||||||
id.toString(),
|
|
||||||
title: title,
|
|
||||||
body: prefixedBody,
|
|
||||||
markupBody: sender == null
|
|
||||||
? null
|
|
||||||
: "<b>${escape.convert(sender.name)}</b>: ${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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
|
|
||||||
class NotificationRequestController extends Notifier<int?> {
|
|
||||||
@override
|
|
||||||
int? build() => null;
|
|
||||||
|
|
||||||
void request(int rowId) => state = rowId;
|
|
||||||
|
|
||||||
void consume() => state = null;
|
|
||||||
|
|
||||||
static final provider = NotifierProvider<NotificationRequestController, int?>(
|
|
||||||
NotificationRequestController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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<IList<(int, String)>> {
|
|
||||||
@override
|
|
||||||
Future<IList<(int, String)>> 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<void> 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);
|
|
||||||
}
|
|
||||||
|
|
@ -2,25 +2,26 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:nexus/controllers/event.dart";
|
import "package:nexus/controllers/event.dart";
|
||||||
import "package:nexus/controllers/pinned_ids.dart";
|
import "package:nexus/controllers/pinned_ids.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
|
|
||||||
class PinnedEventsController(final String roomId)
|
class PinnedEventsController(final String roomId)
|
||||||
extends AsyncNotifier<IList<int>> {
|
extends AsyncNotifier<IList<Event>> {
|
||||||
@override
|
@override
|
||||||
Future<IList<int>> build() async {
|
Future<IList<Event>> build() async {
|
||||||
final pinIds = ref.watch(PinnedIdsController.provider(roomId));
|
final pinIds = ref.watch(PinnedIdsController.provider(roomId));
|
||||||
|
|
||||||
return (await Future.wait(
|
return (await Future.wait(
|
||||||
pinIds.map(
|
pinIds.map(
|
||||||
(eventId) => ref.watch(
|
(eventId) => ref.watch(
|
||||||
EventController.provider(.new(eventId: eventId, roomId: roomId))
|
EventController.provider(.new(eventId: eventId, roomId: roomId))
|
||||||
.selectAsync((data) => data?.rowId),
|
.future,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)).nonNulls.toIList();
|
)).nonNulls.toIList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<PinnedEventsController, IList<int>, String>(
|
.autoDispose<PinnedEventsController, IList<Event>, String>(
|
||||||
PinnedEventsController.new,
|
PinnedEventsController.new,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import "dart:io";
|
|
||||||
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
|
|
||||||
|
|
||||||
class PortalController extends AsyncNotifier<XdgDesktopPortalClient> {
|
|
||||||
@override
|
|
||||||
Future<XdgDesktopPortalClient> 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, XdgDesktopPortalClient>(
|
|
||||||
PortalController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client_state.dart";
|
import "package:nexus/controllers/client_state.dart";
|
||||||
import "package:nexus/controllers/room_power_levels.dart";
|
import "package:nexus/controllers/room_creators.dart";
|
||||||
|
import "package:nexus/controllers/rooms.dart";
|
||||||
import "package:nexus/models/configs/power_level.dart";
|
import "package:nexus/models/configs/power_level.dart";
|
||||||
import "package:nexus/models/content/content.dart";
|
import "package:nexus/models/content/content.dart";
|
||||||
|
import "package:nexus/models/content/power_levels.dart";
|
||||||
|
|
||||||
class PowerLevelController(final PowerLevelConfig config)
|
class PowerLevelController(final PowerLevelConfig config)
|
||||||
extends Notifier<bool> {
|
extends Notifier<bool> {
|
||||||
|
|
@ -15,17 +17,31 @@ class PowerLevelController(final PowerLevelConfig config)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final powerLevels = ref.watch(
|
final room = ref.watch(
|
||||||
RoomPowerLevelsController.provider(config.roomId),
|
RoomsController.provider.select((value) => value[config.roomId]),
|
||||||
);
|
);
|
||||||
final content = powerLevels.content;
|
|
||||||
|
final roomCreators = room == null
|
||||||
|
? null
|
||||||
|
: ref.watch(RoomCreatorsController.provider(room));
|
||||||
|
|
||||||
|
final eventRowId = room?.state[EventType.powerLevels.type]?[""];
|
||||||
|
|
||||||
|
final event = eventRowId == null ? null : room?.events[eventRowId];
|
||||||
|
final content = event?.content is PowerLevelsContent
|
||||||
|
? event!.content
|
||||||
|
: PowerLevelsContent();
|
||||||
|
|
||||||
final user = ref.watch(
|
final user = ref.watch(
|
||||||
ClientStateController.provider.select((value) => value?.userId),
|
ClientStateController.provider.select((value) => value?.userId),
|
||||||
);
|
);
|
||||||
if (user == null) return false;
|
if (user == null || content is! PowerLevelsContent) return false;
|
||||||
|
|
||||||
final userLevel = powerLevels.levelOf(user);
|
double powerLevelOf(String userId) => roomCreators?.contains(userId) == true
|
||||||
|
? double.infinity
|
||||||
|
: (content.users[userId] ?? content.usersDefault).toDouble();
|
||||||
|
|
||||||
|
final userLevel = powerLevelOf(user);
|
||||||
|
|
||||||
return switch (config) {
|
return switch (config) {
|
||||||
EventPowerLevelConfig(:final eventType) =>
|
EventPowerLevelConfig(:final eventType) =>
|
||||||
|
|
@ -36,12 +52,10 @@ class PowerLevelController(final PowerLevelConfig config)
|
||||||
.invite => userLevel >= content.invite,
|
.invite => userLevel >= content.invite,
|
||||||
|
|
||||||
.kick =>
|
.kick =>
|
||||||
userLevel >= content.kick &&
|
userLevel >= content.kick && userLevel > powerLevelOf(targetUser),
|
||||||
userLevel > powerLevels.levelOf(targetUser),
|
|
||||||
|
|
||||||
.ban =>
|
.ban =>
|
||||||
userLevel >= content.ban &&
|
userLevel >= content.ban && userLevel > powerLevelOf(targetUser),
|
||||||
userLevel > powerLevels.levelOf(targetUser),
|
|
||||||
|
|
||||||
.unban => userLevel >= content.ban,
|
.unban => userLevel >= content.ban,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
|
|
||||||
class PowerLevelsSavingController(final String roomId) extends Notifier<bool> {
|
|
||||||
@override
|
|
||||||
bool build() => false;
|
|
||||||
|
|
||||||
Future<void> run(Future<void> Function() action) async {
|
|
||||||
state = true;
|
|
||||||
try {
|
|
||||||
await action();
|
|
||||||
} finally {
|
|
||||||
if (ref.mounted) state = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static final provider = NotifierProvider.autoDispose
|
|
||||||
.family<PowerLevelsSavingController, bool, String>(
|
|
||||||
PowerLevelsSavingController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -6,19 +6,10 @@ class ProfileController(final String userId)
|
||||||
extends AsyncNotifier<ProfileResponse> {
|
extends AsyncNotifier<ProfileResponse> {
|
||||||
@override
|
@override
|
||||||
Future<ProfileResponse> build() {
|
Future<ProfileResponse> build() {
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
final client = ref.watch(ClientController.provider.notifier);
|
||||||
return client.getProfile(userId);
|
return client.getProfile(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> 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
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<ProfileController, ProfileResponse, String>(
|
.autoDispose<ProfileController, ProfileResponse, String>(
|
||||||
ProfileController.new,
|
ProfileController.new,
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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<String?> {
|
|
||||||
@override
|
|
||||||
Future<String?> build() async => json.decode(
|
|
||||||
await ref.watch(KeyController.provider(KeyController.pushKeyKey).future) ??
|
|
||||||
"{}",
|
|
||||||
)[instance];
|
|
||||||
|
|
||||||
Future<void> 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, String?, String>(
|
|
||||||
PushKeyController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -18,7 +18,7 @@ class ReactionsController(final ReactionsConfig config)
|
||||||
|
|
||||||
final reactionEvents = eventInfo?.$2.isNotEmpty == true
|
final reactionEvents = eventInfo?.$2.isNotEmpty == true
|
||||||
? await ref
|
? await ref
|
||||||
.read(ClientController.provider.notifier)
|
.watch(ClientController.provider.notifier)
|
||||||
.getRelatedEvents(
|
.getRelatedEvents(
|
||||||
.new(
|
.new(
|
||||||
roomId: config.roomId,
|
roomId: config.roomId,
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import "package:collection/collection.dart";
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:nexus/controllers/client_state.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<IMap<String, IList<ReadReceipt>>> {
|
|
||||||
@override
|
|
||||||
IMap<String, IList<ReadReceipt>> 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<String, IList<ReadReceipt>>,
|
|
||||||
ChatParams
|
|
||||||
>(ReadReceiptsController.new);
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import "package:collection/collection.dart";
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package: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<IList<RecentEmoji>> {
|
|
||||||
@override
|
|
||||||
IList<RecentEmoji> build() {
|
|
||||||
final Set<String> emoji = ref
|
|
||||||
.watch(EmojiController.provider)
|
|
||||||
.requireValue
|
|
||||||
.map((element) => element.emojis)
|
|
||||||
.flattened
|
|
||||||
.map((e) => e.value)
|
|
||||||
.toSet();
|
|
||||||
|
|
||||||
final IList<RecentEmoji> recents = ref.watch(
|
|
||||||
AccountDataController.provider.select(
|
|
||||||
(value) => value.recentEmoji.recentEmoji,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return .new(
|
|
||||||
recents.where(
|
|
||||||
(recent) =>
|
|
||||||
// Is unicode or custom emote
|
|
||||||
emoji.contains(recent.emoji) || recent.emoji.startsWith("mxc://"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> 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, IList<RecentEmoji>>(
|
|
||||||
RecentEmojiController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -9,134 +9,81 @@ import "package:nexus/controllers/attachment.dart";
|
||||||
import "package:nexus/controllers/client.dart";
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
import "package:nexus/controllers/rooms.dart";
|
||||||
import "package:nexus/models/content/content.dart";
|
import "package:nexus/models/content/content.dart";
|
||||||
import "package:nexus/models/content/image_pack.dart";
|
|
||||||
import "package:nexus/models/content/reaction.dart";
|
import "package:nexus/models/content/reaction.dart";
|
||||||
import "package:nexus/models/content/sticker.dart";
|
|
||||||
import "package:nexus/models/direction.dart";
|
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/requests/redact_event.dart";
|
import "package:nexus/models/requests/redact_event.dart";
|
||||||
import "package:nexus/models/relation_type.dart";
|
import "package:nexus/models/relation_type.dart";
|
||||||
import "package:nexus/models/requests/send_message.dart";
|
import "package:nexus/models/requests/send_message.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
import "package:nexus/models/room_chat.dart";
|
|
||||||
|
|
||||||
typedef ChatParams = ({String roomId, int? contextualEvent});
|
class RoomChatController(final String roomId)
|
||||||
|
extends AsyncNotifier<IList<Event>?> {
|
||||||
class RoomChatController(final ChatParams info)
|
|
||||||
extends AsyncNotifier<RoomChat?> {
|
|
||||||
@override
|
@override
|
||||||
Future<RoomChat?> build() async {
|
Future<IList<Event>?> build() async {
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
final client = ref.watch(ClientController.provider.notifier);
|
||||||
|
final room = ref.watch(
|
||||||
final room = ref.read(
|
RoomsController.provider.select((rooms) => rooms[roomId]),
|
||||||
RoomsController.provider.select((rooms) => rooms[info.roomId]),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (room == null) return null;
|
if (room == null) return null;
|
||||||
|
|
||||||
if (!room.hasFetchedState) {
|
if (!room.hasFetchedState) {
|
||||||
final state = await client.getRoomState(.new(roomId: info.roomId));
|
final state = await client.getRoomState(.new(roomId: roomId));
|
||||||
ref.read(RoomsController.provider.notifier).addState(info.roomId, state);
|
|
||||||
|
await ref.read(RoomsController.provider.notifier).addState(roomId, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
final timeline = room.timeline
|
// While there are under 20 events, try to load more
|
||||||
|
// until there's no more or the conditions are met.
|
||||||
|
if (room.hasMore && room.timeline.length < 20) {
|
||||||
|
loadOlder();
|
||||||
|
}
|
||||||
|
|
||||||
|
return room.timeline
|
||||||
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
|
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
|
||||||
.map((e) => e.value)
|
.map((element) => element.value)
|
||||||
.toIList()
|
.toIList()
|
||||||
.addAll(room.clientSticky)
|
.addAll(room.sticky)
|
||||||
|
.map((entry) {
|
||||||
|
final foundEvent = entry == null ? null : room.events[entry];
|
||||||
|
|
||||||
|
final editedEvent =
|
||||||
|
foundEvent == null || foundEvent.lastEditRowId == 0
|
||||||
|
? null
|
||||||
|
: room.events[foundEvent.lastEditRowId];
|
||||||
|
|
||||||
|
return editedEvent == null
|
||||||
|
? foundEvent
|
||||||
|
: foundEvent?.copyWith(
|
||||||
|
content: editedEvent.content,
|
||||||
|
localContent: editedEvent.localContent,
|
||||||
|
);
|
||||||
|
})
|
||||||
.nonNulls
|
.nonNulls
|
||||||
.toIList();
|
.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<void> deleteMessage(Event event, {String? reason}) => ref
|
Future<void> deleteMessage(Event event, {String? reason}) => ref
|
||||||
.read(ClientController.provider.notifier)
|
.watch(ClientController.provider.notifier)
|
||||||
.redactEvent(
|
.redactEvent(
|
||||||
RedactEventRequest(
|
RedactEventRequest(
|
||||||
eventId: event.eventId,
|
eventId: event.eventId,
|
||||||
roomId: info.roomId,
|
roomId: roomId,
|
||||||
reason: reason,
|
reason: reason,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
void hideClientSticky(int eventRowId) => ref
|
Future<bool> loadOlder() async {
|
||||||
.read(RoomsController.provider.notifier)
|
state = AsyncLoading();
|
||||||
.update(
|
|
||||||
.new({
|
|
||||||
info.roomId: Room(
|
|
||||||
resetClientSticky: true,
|
|
||||||
clientSticky:
|
|
||||||
ref
|
|
||||||
.read(
|
|
||||||
RoomsController.provider.select(
|
|
||||||
(rooms) => rooms[info.roomId]?.clientSticky,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
?.remove(eventRowId) ??
|
|
||||||
.new(),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> 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
|
final timelineKeys = ref
|
||||||
.read(RoomsController.provider.select((value) => value[info.roomId]))
|
.read(RoomsController.provider.select((value) => value[roomId]))
|
||||||
?.timeline
|
?.timeline
|
||||||
.keys;
|
.keys;
|
||||||
final response = await client.paginate(
|
final response = await ref
|
||||||
|
.read(ClientController.provider.notifier)
|
||||||
|
.paginate(
|
||||||
.new(
|
.new(
|
||||||
roomId: info.roomId,
|
roomId: roomId,
|
||||||
maxTimelineId: timelineKeys?.isNotEmpty == true
|
maxTimelineId: timelineKeys?.isNotEmpty == true
|
||||||
? timelineKeys?.reduce(min)
|
? timelineKeys?.reduce(min)
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -144,21 +91,19 @@ class RoomChatController(final ChatParams info)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.events.isEmpty) {
|
if (response.events.isEmpty) {
|
||||||
state = .data(chat);
|
state = .data(state.value);
|
||||||
}
|
} else {
|
||||||
|
|
||||||
ref
|
ref
|
||||||
.read(RoomsController.provider.notifier)
|
.read(RoomsController.provider.notifier)
|
||||||
.update(
|
.update(
|
||||||
IMap({
|
IMap({
|
||||||
info.roomId: Room(
|
roomId: Room(
|
||||||
events: IMap.fromIterable(
|
events: IMap.fromIterable(
|
||||||
response.events.addAll(response.relatedEvents),
|
response.events.addAll(response.relatedEvents),
|
||||||
keyMapper: (event) => event.rowId,
|
keyMapper: (event) => event.rowId,
|
||||||
valueMapper: (event) => event,
|
valueMapper: (event) => event,
|
||||||
),
|
),
|
||||||
hasMore: response.hasMore,
|
hasMore: response.hasMore,
|
||||||
receipts: response.receipts,
|
|
||||||
timeline: IMap.fromIterable(
|
timeline: IMap.fromIterable(
|
||||||
response.events,
|
response.events,
|
||||||
keyMapper: (event) => event.timelineRowId,
|
keyMapper: (event) => event.timelineRowId,
|
||||||
|
|
@ -166,57 +111,11 @@ class RoomChatController(final ChatParams info)
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
);
|
.new(),
|
||||||
} 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return response.hasMore;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> send(
|
Future<void> send(
|
||||||
|
|
@ -224,62 +123,60 @@ class RoomChatController(final ChatParams info)
|
||||||
bool shouldMention = true,
|
bool shouldMention = true,
|
||||||
required IList<Tag> tags,
|
required IList<Tag> tags,
|
||||||
required RelationType relationType,
|
required RelationType relationType,
|
||||||
int? relatedEventRowId,
|
Event? relation,
|
||||||
}) async {
|
}) async {
|
||||||
Content? baseContent;
|
Content? baseContent;
|
||||||
final relatedEvent = relatedEventRowId == null
|
if (relationType == .edit) {
|
||||||
? null
|
baseContent = relation?.content;
|
||||||
: ref.read(
|
|
||||||
RoomsController.provider.select(
|
|
||||||
(rooms) => rooms[info.roomId]?.events[relatedEventRowId],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (relationType == .edit && relatedEventRowId != null) {
|
|
||||||
baseContent = relatedEvent?.content;
|
|
||||||
} else {
|
} else {
|
||||||
final provider = AttachmentController.provider(info.roomId);
|
final provider = AttachmentController.provider(roomId);
|
||||||
baseContent = ref.read(provider)?.$2;
|
baseContent = ref.watch(provider)?.$2;
|
||||||
ref.invalidate(provider);
|
ref.invalidate(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replaces each formatted tag, like "@id#name#", with its ID
|
var taggedMessage = text;
|
||||||
final taggedMessage = tags.fold(
|
|
||||||
text,
|
|
||||||
(message, tag) => message.replaceAllMapped(
|
|
||||||
RegExp(r"@+(" + RegExp.escape(tag.id) + r")(#[^#]*#)?"),
|
|
||||||
(match) => match.group(1)!,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
for (final tag in tags) {
|
||||||
|
final escaped = RegExp.escape(tag.id);
|
||||||
|
final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?");
|
||||||
|
|
||||||
|
taggedMessage = taggedMessage.replaceAllMapped(
|
||||||
|
pattern,
|
||||||
|
(match) => match.group(1)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = ref.watch(ClientController.provider.notifier);
|
||||||
final event = await client.sendMessage(
|
final event = await client.sendMessage(
|
||||||
SendMessageRequest(
|
SendMessageRequest(
|
||||||
roomId: info.roomId,
|
roomId: roomId,
|
||||||
baseContent: baseContent,
|
baseContent: baseContent,
|
||||||
mentions: Mentions(
|
mentions: Mentions(
|
||||||
userIds: [
|
userIds: [
|
||||||
if (shouldMention && relatedEvent != null && relationType == .reply)
|
if (shouldMention == true &&
|
||||||
relatedEvent.sender,
|
relation != null &&
|
||||||
|
relationType == RelationType.reply)
|
||||||
|
relation.sender,
|
||||||
].toIList(),
|
].toIList(),
|
||||||
room: taggedMessage.contains("@room"),
|
room: taggedMessage.contains("@room"),
|
||||||
),
|
),
|
||||||
text: taggedMessage,
|
text: taggedMessage,
|
||||||
relation: relatedEvent == null
|
relation: relation == null
|
||||||
? null
|
? null
|
||||||
: .new(eventId: relatedEvent.eventId, relationType: relationType),
|
: .new(eventId: relation.eventId, relationType: relationType),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
ref
|
ref
|
||||||
.read(RoomsController.provider.notifier)
|
.watch(RoomsController.provider.notifier)
|
||||||
.update(
|
.update(
|
||||||
.new({
|
.new({
|
||||||
info.roomId: .new(
|
roomId: .new(
|
||||||
events: .new({event.rowId: event}),
|
events: .new({event.rowId: event}),
|
||||||
clientSticky: .new({event.rowId}),
|
sticky: .new({event.rowId}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
.new(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,19 +185,21 @@ class RoomChatController(final ChatParams info)
|
||||||
Event event,
|
Event event,
|
||||||
String userId,
|
String userId,
|
||||||
) async {
|
) async {
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
final client = ref.watch(ClientController.provider.notifier);
|
||||||
final allReactionEvents = await client.getRelatedEvents(
|
final allReactionEvents = await client.getRelatedEvents(
|
||||||
.new(
|
.new(
|
||||||
roomId: info.roomId,
|
roomId: roomId,
|
||||||
eventId: event.eventId,
|
eventId: event.eventId,
|
||||||
relationType: "m.annotation",
|
relationType: "m.annotation",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final reactionEvent = allReactionEvents?.firstWhereOrNull(
|
final reactionEvents = allReactionEvents
|
||||||
(event) =>
|
?.where((event) => event.redactedBy == null)
|
||||||
event.redactedBy == null &&
|
.toIList();
|
||||||
switch (event.content) {
|
|
||||||
|
final reactionEvent = reactionEvents?.firstWhereOrNull(
|
||||||
|
(event) => switch (event.content) {
|
||||||
ReactionContent(:final key) =>
|
ReactionContent(:final key) =>
|
||||||
key == reaction && event.sender == userId,
|
key == reaction && event.sender == userId,
|
||||||
_ => false,
|
_ => false,
|
||||||
|
|
@ -308,30 +207,20 @@ class RoomChatController(final ChatParams info)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (reactionEvent != null) {
|
if (reactionEvent != null) {
|
||||||
await client.redactEvent(
|
await ref
|
||||||
.new(eventId: reactionEvent.eventId, roomId: info.roomId),
|
.watch(ClientController.provider.notifier)
|
||||||
);
|
.redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reacts to [event] with [reaction], which is an mxc URL for custom emoji,
|
Future<void> sendReaction(String reaction, Event event) async {
|
||||||
/// with their [shortcode].
|
final client = ref.watch(ClientController.provider.notifier);
|
||||||
Future<void> sendReaction(
|
|
||||||
String reaction,
|
|
||||||
Event event, {
|
|
||||||
String? shortcode,
|
|
||||||
}) async {
|
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
|
||||||
|
|
||||||
await client.sendEvent(
|
await client.sendEvent(
|
||||||
.new(
|
.new(
|
||||||
roomId: info.roomId,
|
roomId: roomId,
|
||||||
type: EventType.reaction.type,
|
type: EventType.reaction.type,
|
||||||
// Wrapped in colons, like other clients send it
|
content: ReactionContent(key: reaction),
|
||||||
content: ReactionContent(
|
|
||||||
key: reaction,
|
|
||||||
shortcode: shortcode == null ? null : ":$shortcode:",
|
|
||||||
),
|
|
||||||
synchronous: true,
|
synchronous: true,
|
||||||
disableEncryption: true,
|
disableEncryption: true,
|
||||||
relatesTo: event.eventId,
|
relatesTo: event.eventId,
|
||||||
|
|
@ -340,35 +229,8 @@ class RoomChatController(final ChatParams info)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendSticker(String shortcode, PackImage image) async {
|
|
||||||
final event = await ref
|
|
||||||
.read(ClientController.provider.notifier)
|
|
||||||
.sendEvent(
|
|
||||||
.new(
|
|
||||||
roomId: info.roomId,
|
|
||||||
type: EventType.sticker.type,
|
|
||||||
content: StickerContent(
|
|
||||||
body: image.body ?? shortcode,
|
|
||||||
info: image.info ?? const .new(),
|
|
||||||
url: image.url,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
ref
|
|
||||||
.read(RoomsController.provider.notifier)
|
|
||||||
.update(
|
|
||||||
.new({
|
|
||||||
info.roomId: .new(
|
|
||||||
events: .new({event.rowId: event}),
|
|
||||||
clientSticky: .new({event.rowId}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
|
.autoDispose<RoomChatController, IList<Event>?, String>(
|
||||||
RoomChatController.new,
|
RoomChatController.new,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
lib/controllers/room_creators.dart
Normal file
30
lib/controllers/room_creators.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
import "package:nexus/models/content/create.dart";
|
||||||
|
import "package:nexus/models/room.dart";
|
||||||
|
|
||||||
|
class RoomCreatorsController(final Room room) extends Notifier<IList<String>> {
|
||||||
|
@override
|
||||||
|
IList<String> build() {
|
||||||
|
final createRowId = room.state[EventType.create.type]?[""];
|
||||||
|
final createEvent = createRowId == null ? null : room.events[createRowId];
|
||||||
|
|
||||||
|
if (createEvent == null) return .new();
|
||||||
|
|
||||||
|
final createEventContent = switch (createEvent.content) {
|
||||||
|
CreateContent content => content,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return switch (createEventContent?.additionalCreatorIds) {
|
||||||
|
IList<String> creators => creators.add(createEvent.sender),
|
||||||
|
_ => .new([createEvent.sender]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static final provider =
|
||||||
|
NotifierProvider.family<RoomCreatorsController, IList<String>, Room>(
|
||||||
|
RoomCreatorsController.new,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
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<RoomPowerLevels> {
|
|
||||||
@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, RoomPowerLevels, String>(
|
|
||||||
RoomPowerLevelsController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,830 +0,0 @@
|
||||||
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/extensions/join_rule_name.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/power_level_permission.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<IMap<String, IList<SettingsCategory>>> {
|
|
||||||
@override
|
|
||||||
Future<IMap<String, IList<SettingsCategory>>> 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<String>.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);
|
|
||||||
final encrypted = stateContent(.encryption) is EncryptionContent;
|
|
||||||
final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted);
|
|
||||||
|
|
||||||
Future<void> 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<void> setAliases(
|
|
||||||
String? canonical,
|
|
||||||
ISet<String> 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<void> setAllowedRooms(ISet<String> 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<void> setPowerLevels(PowerLevelsContent content) => ref
|
|
||||||
.read(PowerLevelsSavingController.provider(roomId).notifier)
|
|
||||||
.run(() => setState(.powerLevels, content).onError(showError));
|
|
||||||
|
|
||||||
// Event types with their own level that don't have a setting of their own
|
|
||||||
final listedEvents = shownEvents
|
|
||||||
.addAll(PowerLevelGroup.values.expand((group) => group.events))
|
|
||||||
.map((event) => event.type.type)
|
|
||||||
.toISet();
|
|
||||||
final otherEvents = powerLevels.events.entries
|
|
||||||
.where((entry) => !listedEvents.contains(entry.key))
|
|
||||||
.toIList()
|
|
||||||
.sort((a, b) => a.key.compareTo(b.key));
|
|
||||||
|
|
||||||
// Users with their own level, the highest first
|
|
||||||
final users = powerLevels.users.entries.toIList().sort(
|
|
||||||
(a, b) => switch (b.value.compareTo(a.value)) {
|
|
||||||
0 => a.key.compareTo(b.key),
|
|
||||||
final order => order,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
MembershipContent? memberOf(String userId) =>
|
|
||||||
switch (state[EventType.membership.type]?[userId]) {
|
|
||||||
final rowId? => switch (contentOf(rowId)) {
|
|
||||||
final MembershipContent content => content,
|
|
||||||
_ => null,
|
|
||||||
},
|
|
||||||
null => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// A tile opening a [sheet] to add entries, like event types or users, with their own power level.
|
|
||||||
Setting addSetting({
|
|
||||||
required String title,
|
|
||||||
required String description,
|
|
||||||
required Widget Function(void Function(PowerLevelsContent content) save)
|
|
||||||
sheet,
|
|
||||||
}) => .new(
|
|
||||||
title: title,
|
|
||||||
description: description,
|
|
||||||
icon: Icons.add,
|
|
||||||
builder: (title, description, icon) => Builder(
|
|
||||||
builder: (context) => ListTile(
|
|
||||||
enabled: canSetPowerLevels,
|
|
||||||
leading: Icon(icon),
|
|
||||||
title: Text(title),
|
|
||||||
subtitle: Text(description),
|
|
||||||
onTap: () => context.showAdaptiveSheet((_) => sheet(setPowerLevels)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Setting powerLevelSetting({
|
|
||||||
required String title,
|
|
||||||
required String description,
|
|
||||||
required IconData icon,
|
|
||||||
required int value,
|
|
||||||
required PowerLevelsContent Function(int level) update,
|
|
||||||
}) => .new(
|
|
||||||
title: title,
|
|
||||||
description: description,
|
|
||||||
icon: icon,
|
|
||||||
builder: (title, description, icon) => PowerLevelTile(
|
|
||||||
leading: Icon(icon),
|
|
||||||
title: title,
|
|
||||||
description: description,
|
|
||||||
value: value,
|
|
||||||
ownLevel: ownLevel,
|
|
||||||
onChanged: canSetPowerLevels
|
|
||||||
? (level) => setPowerLevels(update(level))
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Setting permissionSetting(PowerLevelPermission permission) =>
|
|
||||||
powerLevelSetting(
|
|
||||||
title: permission.title,
|
|
||||||
description: permission.description,
|
|
||||||
icon: permission.icon,
|
|
||||||
value: permission.levelIn(powerLevels),
|
|
||||||
update: (level) => permission.withLevel(powerLevels, level),
|
|
||||||
);
|
|
||||||
|
|
||||||
/// A setting for the level needed to send [event], which otherwise needs [fallback].
|
|
||||||
Setting eventSetting(PowerLevelEvent event, {required int fallback}) =>
|
|
||||||
powerLevelSetting(
|
|
||||||
title: event.title,
|
|
||||||
description: event.description,
|
|
||||||
icon: event.icon,
|
|
||||||
value: powerLevels.events[event.type.type] ?? fallback,
|
|
||||||
update: (level) => powerLevels.copyWith(
|
|
||||||
events: powerLevels.events.add(event.type.type, level),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget tileOf(Setting setting) =>
|
|
||||||
setting.builder(setting.title, setting.description, setting.icon);
|
|
||||||
|
|
||||||
/// A setting for all of [group]'s levels at once, which expands to set each.
|
|
||||||
Setting groupSetting(PowerLevelGroup group) => .new(
|
|
||||||
title: group.title,
|
|
||||||
description: group.description,
|
|
||||||
icon: group.icon,
|
|
||||||
builder: (title, description, icon) => HookBuilder(
|
|
||||||
builder: (context) {
|
|
||||||
final expanded = useState(false);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: .min,
|
|
||||||
children: [
|
|
||||||
PowerLevelTile(
|
|
||||||
leading: Icon(icon),
|
|
||||||
title: title,
|
|
||||||
description: description,
|
|
||||||
value: group.levelIn(powerLevels),
|
|
||||||
ownLevel: ownLevel,
|
|
||||||
// Changing it would change levels above the user's own
|
|
||||||
onChanged:
|
|
||||||
canSetPowerLevels &&
|
|
||||||
group.highestIn(powerLevels) <= ownLevel
|
|
||||||
? (level) =>
|
|
||||||
setPowerLevels(group.withLevel(powerLevels, level))
|
|
||||||
: null,
|
|
||||||
trailing: IconButton(
|
|
||||||
tooltip: expanded.value ? "Collapse" : "Set each",
|
|
||||||
icon: AnimatedRotation(
|
|
||||||
turns: expanded.value ? 0.5 : 0,
|
|
||||||
duration: Durations.medium2,
|
|
||||||
child: Icon(Icons.expand_more),
|
|
||||||
),
|
|
||||||
onPressed: () => expanded.value = !expanded.value,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
AnimatedSize(
|
|
||||||
duration: Durations.medium2,
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
alignment: .topCenter,
|
|
||||||
child: expanded.value
|
|
||||||
? Padding(
|
|
||||||
padding: .only(left: 16),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: .min,
|
|
||||||
children: [
|
|
||||||
...group.permissions
|
|
||||||
.map(permissionSetting)
|
|
||||||
.map(tileOf),
|
|
||||||
...group.events
|
|
||||||
.map(
|
|
||||||
(event) => eventSetting(
|
|
||||||
event,
|
|
||||||
fallback: powerLevels.stateDefault,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.map(tileOf),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const SizedBox(width: .infinity),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return .new({
|
|
||||||
"Room": .new([
|
|
||||||
.new(
|
|
||||||
title: "General",
|
|
||||||
icon: Icons.tune,
|
|
||||||
settings: .new([
|
|
||||||
.new(
|
|
||||||
title: "Avatar",
|
|
||||||
description: "The image shown for the room.",
|
|
||||||
icon: Icons.image,
|
|
||||||
builder: (title, description, icon) => HookBuilder(
|
|
||||||
builder: (context) {
|
|
||||||
final saving = Saving.use();
|
|
||||||
Future<void> save(Future<void> 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<JoinRule>(
|
|
||||||
icon: Icon(icon),
|
|
||||||
title: title,
|
|
||||||
subtitle: Text(description),
|
|
||||||
value: joinRules.joinRule,
|
|
||||||
options: joinRuleOptions,
|
|
||||||
getName: (rule) => rule.displayName,
|
|
||||||
onChanged: canSetJoinRules
|
|
||||||
? (rule) => setState(
|
|
||||||
.joinRules,
|
|
||||||
JoinRulesContent(
|
|
||||||
joinRule: rule,
|
|
||||||
allow: isRestricted(rule) && joinRules.allow.isEmpty
|
|
||||||
? .new(
|
|
||||||
parentSpaces.map(
|
|
||||||
(roomId) =>
|
|
||||||
.new(roomId: roomId, type: .membership),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: joinRules.allow,
|
|
||||||
),
|
|
||||||
).onError(showError)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isRestricted(joinRules.joinRule))
|
|
||||||
.new(
|
|
||||||
title: "Allowed Rooms",
|
|
||||||
description:
|
|
||||||
"Members of these rooms can join without an invite.",
|
|
||||||
icon: Icons.meeting_room,
|
|
||||||
builder: (title, description, icon) => HookConsumer(
|
|
||||||
builder: (context, ref, _) {
|
|
||||||
final saving = Saving.use();
|
|
||||||
final names = ref.watch(
|
|
||||||
RoomsController.provider.select(
|
|
||||||
(rooms) => allowedRooms
|
|
||||||
.map(
|
|
||||||
(roomId) =>
|
|
||||||
rooms[roomId]?.metadata?.name ?? roomId,
|
|
||||||
)
|
|
||||||
.toIList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
enabled: canSetJoinRules && !saving.active,
|
|
||||||
leading: Icon(icon),
|
|
||||||
title: Text(title),
|
|
||||||
subtitle: Text(
|
|
||||||
names.isEmpty
|
|
||||||
? "None, so only invited users can join"
|
|
||||||
: names.join(", "),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: .ellipsis,
|
|
||||||
),
|
|
||||||
onTap: () => context.showAdaptiveSheet(
|
|
||||||
(_) => AllowedRoomsSheet(
|
|
||||||
roomId: roomId,
|
|
||||||
allowed: allowedRooms,
|
|
||||||
parentSpaces: parentSpaces,
|
|
||||||
onSave: (allowed) => saving.run(
|
|
||||||
() => setAllowedRooms(allowed).onError(showError),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
.new(
|
|
||||||
title: "Aliases",
|
|
||||||
description:
|
|
||||||
"The addresses the room advertises, and its main one.",
|
|
||||||
icon: Icons.alternate_email,
|
|
||||||
builder: (title, description, icon) => HookBuilder(
|
|
||||||
builder: (context) {
|
|
||||||
final saving = Saving.use();
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
enabled: canSetAliases && !saving.active,
|
|
||||||
leading: Icon(icon),
|
|
||||||
title: Text(title),
|
|
||||||
subtitle: Text(
|
|
||||||
switch ((
|
|
||||||
canonicalAlias.alias,
|
|
||||||
canonicalAlias.altAliases.length,
|
|
||||||
)) {
|
|
||||||
(null, 0) => description,
|
|
||||||
(final alias?, 0) => alias,
|
|
||||||
(final alias?, 1) =>
|
|
||||||
"$alias and ${canonicalAlias.altAliases.first}",
|
|
||||||
(final alias?, final others) =>
|
|
||||||
"$alias and $others more",
|
|
||||||
(null, _) => canonicalAlias.altAliases.join(", "),
|
|
||||||
},
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: .ellipsis,
|
|
||||||
),
|
|
||||||
onTap: () => context.showAdaptiveSheet(
|
|
||||||
(_) => AliasesSheet(
|
|
||||||
aliases: knownAliases,
|
|
||||||
canonical: canonicalAlias.alias,
|
|
||||||
alternatives: canonicalAlias.altAliases,
|
|
||||||
onSave: (canonical, alternatives) => saving.run(
|
|
||||||
() => setAliases(
|
|
||||||
canonical,
|
|
||||||
alternatives,
|
|
||||||
).onError(showError),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
.new(
|
|
||||||
title: "Local Aliases",
|
|
||||||
description: "Addresses for the room on your server.",
|
|
||||||
icon: Icons.dns,
|
|
||||||
builder: (title, description, icon) => Builder(
|
|
||||||
builder: (context) => ListTile(
|
|
||||||
leading: Icon(icon),
|
|
||||||
title: Text(title),
|
|
||||||
subtitle: Text(
|
|
||||||
localAliases.isEmpty
|
|
||||||
? description
|
|
||||||
: localAliases.join(", "),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: .ellipsis,
|
|
||||||
),
|
|
||||||
onTap: () => context.showAdaptiveSheet(
|
|
||||||
(_) => LocalAliasesSheet(
|
|
||||||
roomId,
|
|
||||||
onRemoved: canSetAliases
|
|
||||||
? (alias) async {
|
|
||||||
if (published.contains(alias)) {
|
|
||||||
await setAliases(
|
|
||||||
canonicalAlias.alias,
|
|
||||||
canonicalAlias.altAliases,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
.new(
|
|
||||||
title: "History Visibility",
|
|
||||||
description:
|
|
||||||
"Which messages from before joining members can read.",
|
|
||||||
icon: Icons.history,
|
|
||||||
builder: (title, description, icon) =>
|
|
||||||
DialogListTile<HistoryVisibility>(
|
|
||||||
icon: Icon(icon),
|
|
||||||
title: title,
|
|
||||||
subtitle: Text(description),
|
|
||||||
value: historyVisibility,
|
|
||||||
options: [.shared, .invited, .joined],
|
|
||||||
getName: (visibility) => switch (visibility) {
|
|
||||||
.shared => "All events",
|
|
||||||
.invited => "After invite",
|
|
||||||
.joined => "After join",
|
|
||||||
.worldReadable => "Anyone",
|
|
||||||
.unknown => "Unknown",
|
|
||||||
},
|
|
||||||
onChanged: canSetHistoryVisibility
|
|
||||||
? (visibility) => setState(
|
|
||||||
.historyVisibility,
|
|
||||||
HistoryVisibilityContent(
|
|
||||||
historyVisibility: visibility,
|
|
||||||
),
|
|
||||||
).onError(showError)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
.new(
|
|
||||||
title: "Permissions",
|
|
||||||
icon: Icons.admin_panel_settings,
|
|
||||||
settings: .new([
|
|
||||||
permissionSetting(.usersDefault),
|
|
||||||
header("Users", "Power levels of specific users.", Icons.group),
|
|
||||||
...users.map((entry) {
|
|
||||||
final member = memberOf(entry.key);
|
|
||||||
// Others can only be changed if they're below you, but your own level can be lowered
|
|
||||||
final canChange =
|
|
||||||
canSetPowerLevels &&
|
|
||||||
(entry.key == userId || entry.value < ownLevel);
|
|
||||||
|
|
||||||
return Setting(
|
|
||||||
title: member?.displayName ?? entry.key,
|
|
||||||
description: entry.key,
|
|
||||||
icon: Icons.person,
|
|
||||||
builder: (title, description, _) => PowerLevelTile(
|
|
||||||
leading: AvatarOrHash(
|
|
||||||
member?.avatarUrl,
|
|
||||||
title,
|
|
||||||
dimension: 40,
|
|
||||||
),
|
|
||||||
title: title,
|
|
||||||
description: description,
|
|
||||||
value: entry.value,
|
|
||||||
ownLevel: ownLevel,
|
|
||||||
onChanged: canChange
|
|
||||||
? (level) => setPowerLevels(
|
|
||||||
powerLevels.copyWith(
|
|
||||||
users: powerLevels.users.add(entry.key, level),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onRemoved: canChange
|
|
||||||
? () => setPowerLevels(
|
|
||||||
powerLevels.copyWith(
|
|
||||||
users: powerLevels.users.remove(entry.key),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
removeConfirmation: entry.key == userId
|
|
||||||
? "Remove your own power level? You'll have the default of ${powerLevels.usersDefault}, and won't be able to raise it again yourself."
|
|
||||||
: "Remove $title's power level? They'll have the default of ${powerLevels.usersDefault}.",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
addSetting(
|
|
||||||
title: "Add Users",
|
|
||||||
description: "Give users their own power level.",
|
|
||||||
sheet: (save) => AddUsersPowerLevelSheet(
|
|
||||||
roomId: roomId,
|
|
||||||
existing: powerLevels.users.keys.toISet().union(
|
|
||||||
roomPowerLevels.creators,
|
|
||||||
),
|
|
||||||
ownLevel: ownLevel,
|
|
||||||
onSave: (users, level) => save(
|
|
||||||
powerLevels.copyWith(
|
|
||||||
users: powerLevels.users.addEntries(
|
|
||||||
users.map((user) => MapEntry(user, level)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
header(
|
|
||||||
"Groups",
|
|
||||||
"Power levels set together, which can each be set on their own.",
|
|
||||||
Icons.workspaces,
|
|
||||||
),
|
|
||||||
...PowerLevelGroup.values.map(groupSetting),
|
|
||||||
header(
|
|
||||||
"Regular Events",
|
|
||||||
"Power levels needed to send messages and other timeline events.",
|
|
||||||
Icons.chat,
|
|
||||||
),
|
|
||||||
...shownEvents
|
|
||||||
.where((event) => event != .pinnedEvents)
|
|
||||||
.map(
|
|
||||||
(event) =>
|
|
||||||
eventSetting(event, fallback: powerLevels.eventsDefault),
|
|
||||||
),
|
|
||||||
permissionSetting(.eventsDefault),
|
|
||||||
header(
|
|
||||||
"State Events",
|
|
||||||
"Power levels needed to change the room's state.",
|
|
||||||
Icons.settings,
|
|
||||||
),
|
|
||||||
permissionSetting(.invite),
|
|
||||||
permissionSetting(.stateDefault),
|
|
||||||
header(
|
|
||||||
"Other Events",
|
|
||||||
"Power levels for specific event types.",
|
|
||||||
Icons.code,
|
|
||||||
),
|
|
||||||
...otherEvents.map(
|
|
||||||
(entry) => Setting(
|
|
||||||
title: entry.key,
|
|
||||||
description: "The power level needed to send this event.",
|
|
||||||
icon: Icons.code,
|
|
||||||
builder: (title, _, icon) => PowerLevelTile(
|
|
||||||
leading: Icon(icon),
|
|
||||||
title: title,
|
|
||||||
description: null,
|
|
||||||
value: entry.value,
|
|
||||||
ownLevel: ownLevel,
|
|
||||||
onChanged: canSetPowerLevels
|
|
||||||
? (level) => setPowerLevels(
|
|
||||||
powerLevels.copyWith(
|
|
||||||
events: powerLevels.events.add(entry.key, level),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onRemoved: canSetPowerLevels
|
|
||||||
? () => setPowerLevels(
|
|
||||||
powerLevels.copyWith(
|
|
||||||
events: powerLevels.events.remove(entry.key),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
addSetting(
|
|
||||||
title: "Add Event",
|
|
||||||
description: "Give an event type its own power level.",
|
|
||||||
sheet: (save) => PowerLevelEntrySheet(
|
|
||||||
title: "Add Event",
|
|
||||||
keyLabel: "Event type",
|
|
||||||
keyHint: "m.room.name",
|
|
||||||
ownLevel: ownLevel,
|
|
||||||
onSave: (type, level) => save(
|
|
||||||
powerLevels.copyWith(
|
|
||||||
events: powerLevels.events.add(type, level),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
|
||||||
.autoDispose<
|
|
||||||
RoomSettingsSectionsController,
|
|
||||||
IMap<String, IList<SettingsCategory>>,
|
|
||||||
String
|
|
||||||
>(RoomSettingsSectionsController.new);
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,7 @@ class RoomSummaryController(final JoinRoomRequest request)
|
||||||
extends AsyncNotifier<RoomSummary> {
|
extends AsyncNotifier<RoomSummary> {
|
||||||
@override
|
@override
|
||||||
Future<RoomSummary> build() =>
|
Future<RoomSummary> build() =>
|
||||||
ref.read(ClientController.provider.notifier).getRoomSummary(request);
|
ref.watch(ClientController.provider.notifier).getRoomSummary(request);
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<RoomSummaryController, RoomSummary, JoinRoomRequest>(
|
.autoDispose<RoomSummaryController, RoomSummary, JoinRoomRequest>(
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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<IList<String>> {
|
|
||||||
@override
|
|
||||||
IList<String> 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<RoomTypingController, IList<String>, String>(
|
|
||||||
RoomTypingController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +1,42 @@
|
||||||
import "dart:async";
|
import "dart:isolate";
|
||||||
|
|
||||||
import "package:collection/collection.dart";
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/read_receipt.dart";
|
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
|
|
||||||
class RoomsController extends Notifier<IMap<String, Room>> {
|
class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
@override
|
@override
|
||||||
IMap<String, Room> build() => .new();
|
IMap<String, Room> build() => .new();
|
||||||
|
|
||||||
void addState(String roomId, IList<Event> state, {bool isMembers = false}) =>
|
Future<void> addState(
|
||||||
update(
|
String roomId,
|
||||||
|
IList<Event> state, {
|
||||||
|
bool isMembers = false,
|
||||||
|
}) async => update(
|
||||||
.new({
|
.new({
|
||||||
roomId: stateRoom(state)
|
roomId: Room(
|
||||||
.copyWith(hasFetchedState: true, hasFetchedMembers: isMembers),
|
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
|
||||||
|
hasFetchedState: true,
|
||||||
|
hasFetchedMembers: isMembers,
|
||||||
|
state: await Isolate.run(() {
|
||||||
|
final newState = state.fold<IMap<String, IMap<String, int>>>(
|
||||||
|
.new(),
|
||||||
|
(previousValue, stateEvent) => previousValue.add(
|
||||||
|
stateEvent.type,
|
||||||
|
(previousValue[stateEvent.type] ?? .new()).add(
|
||||||
|
stateEvent.stateKey!,
|
||||||
|
stateEvent.rowId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return newState;
|
||||||
|
}),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
.new(),
|
.new(),
|
||||||
);
|
);
|
||||||
|
|
||||||
void addSpecificState(Iterable<Event> state) => update(
|
void update(IMap<String, Room> rooms, ISet<String> leftRooms) {
|
||||||
.new(
|
|
||||||
state
|
|
||||||
.groupListsBy((event) => event.roomId)
|
|
||||||
.map((roomId, roomState) => .new(roomId, stateRoom(roomState))),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
static Room stateRoom(Iterable<Event> state) => Room(
|
|
||||||
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
|
|
||||||
state: .new(
|
|
||||||
state
|
|
||||||
.groupListsBy((event) => event.type)
|
|
||||||
.map(
|
|
||||||
(type, group) => .new(
|
|
||||||
type,
|
|
||||||
.fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
static IMap<String, IList<ReadReceipt>> mergeReceipts(
|
|
||||||
IMap<String, IList<ReadReceipt>> existing,
|
|
||||||
IMap<String, IList<ReadReceipt>> 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<Event> 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<String, Room> rooms, [
|
|
||||||
ISet<String> leftRooms = const .empty(),
|
|
||||||
]) {
|
|
||||||
final merged = rooms.entries.fold(state, (acc, entry) {
|
final merged = rooms.entries.fold(state, (acc, entry) {
|
||||||
final roomId = entry.key;
|
final roomId = entry.key;
|
||||||
final incoming = entry.value;
|
final incoming = entry.value;
|
||||||
|
|
@ -95,13 +46,10 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
roomId,
|
roomId,
|
||||||
existing?.copyWith(
|
existing?.copyWith(
|
||||||
hasMore: incoming.hasMore,
|
hasMore: incoming.hasMore,
|
||||||
clientSticky: incoming.resetClientSticky
|
sticky:
|
||||||
? incoming.clientSticky
|
(incoming.sticky.isEmpty == true
|
||||||
: (incoming.clientSticky.isEmpty
|
? existing.sticky
|
||||||
? existing.clientSticky
|
: existing.sticky.addAll(incoming.sticky))
|
||||||
: existing.clientSticky.addAll(
|
|
||||||
incoming.clientSticky,
|
|
||||||
))
|
|
||||||
.removeWhere(
|
.removeWhere(
|
||||||
(rowId) => incoming.timeline.values.contains(rowId),
|
(rowId) => incoming.timeline.values.contains(rowId),
|
||||||
),
|
),
|
||||||
|
|
@ -116,49 +64,32 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
(previousValue[event.key] ?? .new()).addAll(event.value),
|
(previousValue[event.key] ?? .new()).addAll(event.value),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
resetClientSticky: false,
|
|
||||||
reset: false,
|
reset: false,
|
||||||
hasFetchedMembers:
|
hasFetchedMembers:
|
||||||
incoming.hasFetchedMembers || existing.hasFetchedMembers,
|
incoming.hasFetchedMembers || existing.hasFetchedMembers,
|
||||||
hasFetchedState:
|
hasFetchedState:
|
||||||
incoming.hasFetchedState || existing.hasFetchedState,
|
incoming.hasFetchedState || existing.hasFetchedState,
|
||||||
timeline: incoming.reset
|
timeline: (incoming.reset
|
||||||
? incoming.timeline
|
? incoming.timeline
|
||||||
: existing.timeline.addAll(incoming.timeline),
|
: existing.timeline.addAll(incoming.timeline)),
|
||||||
receipts: mergeReceipts(existing.receipts, incoming.receipts),
|
receipts: incoming.receipts.entries.fold(
|
||||||
) ??
|
existing.receipts,
|
||||||
incoming.copyWith(
|
(receiptAcc, event) => receiptAcc.add(
|
||||||
receipts: mergeReceipts(const .empty(), incoming.receipts),
|
event.key,
|
||||||
|
(receiptAcc[event.key] ?? .new()).addAll(event.value),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
incoming,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
state = leftRooms.fold(merged, (acc, roomId) => acc.remove(roomId));
|
final prunedList = leftRooms.fold(
|
||||||
}
|
merged,
|
||||||
|
(acc, roomId) => acc.remove(roomId),
|
||||||
|
);
|
||||||
|
|
||||||
/// Waits for a sent state event to become the room's state, which happens
|
state = prunedList;
|
||||||
/// once it comes down sync. Gives up after a while, e.g. if another event
|
|
||||||
/// replaced it first.
|
|
||||||
Future<void> awaitState(
|
|
||||||
String roomId,
|
|
||||||
String type,
|
|
||||||
String eventId, {
|
|
||||||
String stateKey = "",
|
|
||||||
}) {
|
|
||||||
bool arrived(IMap<String, Room> 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<void>();
|
|
||||||
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, IMap<String, Room>>(
|
static final provider = NotifierProvider<RoomsController, IMap<String, Room>>(
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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<String?> {
|
|
||||||
@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, String?>(
|
|
||||||
SelectedRoomController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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<Space?> {
|
|
||||||
@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, Space?>(
|
|
||||||
SelectedSpaceController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/settings_file.dart";
|
import "package:nexus/controllers/settings_file.dart";
|
||||||
import "package:nexus/models/settings.dart";
|
import "package:nexus/models/settings.dart";
|
||||||
|
|
@ -18,7 +17,7 @@ class SettingsController extends AsyncNotifier<Settings> {
|
||||||
|
|
||||||
Future<void> set(Settings settings) async {
|
Future<void> set(Settings settings) async {
|
||||||
state = .data(settings);
|
state = .data(settings);
|
||||||
final file = await ref.read(SettingsFileController.provider.future);
|
final file = await ref.watch(SettingsFileController.provider.future);
|
||||||
await file.writeAsString(json.encode(settings.toJson()));
|
await file.writeAsString(json.encode(settings.toJson()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import "dart:io";
|
import "dart:io";
|
||||||
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:path/path.dart";
|
import "package:path/path.dart";
|
||||||
import "package:path_provider/path_provider.dart";
|
import "package:path_provider/path_provider.dart";
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,19 @@
|
||||||
import "dart:io";
|
import "dart:io";
|
||||||
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:intl/intl.dart";
|
import "package:intl/intl.dart";
|
||||||
import "package:m3e_buttons/m3e_buttons.dart";
|
import "package:m3e_buttons/m3e_buttons.dart";
|
||||||
import "package:nexus/controllers/account_data.dart";
|
import "package:nexus/controllers/account_data.dart";
|
||||||
import "package:nexus/controllers/client.dart";
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/controllers/client_state.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/settings.dart";
|
||||||
import "package:nexus/controllers/unified_push.dart";
|
|
||||||
import "package:nexus/controllers/spec_versions.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/account_data.dart";
|
||||||
import "package:nexus/models/client_state.dart";
|
|
||||||
import "package:nexus/models/name_color.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/settings_category.dart";
|
||||||
import "package:nexus/models/show_and_send.dart";
|
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:nexus/widgets/settings/dialog_list_tile.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
|
class SettingsSectionsController
|
||||||
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {
|
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {
|
||||||
|
|
@ -36,30 +21,6 @@ class SettingsSectionsController
|
||||||
Future<IMap<String, IList<SettingsCategory>>> build() async {
|
Future<IMap<String, IList<SettingsCategory>>> build() async {
|
||||||
final settings = await ref.watch(SettingsController.provider.future);
|
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<ShowAndSend>(
|
|
||||||
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({
|
return .new({
|
||||||
"General": .new([
|
"General": .new([
|
||||||
.new(
|
.new(
|
||||||
|
|
@ -75,11 +36,11 @@ class SettingsSectionsController
|
||||||
icon: Icon(icon),
|
icon: Icon(icon),
|
||||||
title: title,
|
title: title,
|
||||||
subtitle: Text(description),
|
subtitle: Text(description),
|
||||||
value: settings.theme,
|
initialValue: settings.theme,
|
||||||
options: ThemeMode.values,
|
options: ThemeMode.values,
|
||||||
getName: (option) => toBeginningOfSentenceCase(option.name),
|
getName: (option) => toBeginningOfSentenceCase(option.name),
|
||||||
onChanged: (value) => ref
|
onChanged: (value) => ref
|
||||||
.read(SettingsController.provider.notifier)
|
.watch(SettingsController.provider.notifier)
|
||||||
.set(settings.copyWith(theme: value))
|
.set(settings.copyWith(theme: value))
|
||||||
.onError(showError),
|
.onError(showError),
|
||||||
),
|
),
|
||||||
|
|
@ -99,108 +60,21 @@ class SettingsSectionsController
|
||||||
Platform.isMacOS ||
|
Platform.isMacOS ||
|
||||||
Platform.isWindows)
|
Platform.isWindows)
|
||||||
? (value) => ref
|
? (value) => ref
|
||||||
.read(SettingsController.provider.notifier)
|
.watch(SettingsController.provider.notifier)
|
||||||
.set(settings.copyWith(useDynamicTheming: value))
|
.set(settings.copyWith(useDynamicTheming: value))
|
||||||
.onError(showError)
|
.onError(showError)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
.new(
|
|
||||||
title: "Name Colors",
|
|
||||||
description: "Color names by power level, so moderators and admins stand out, or by user, so everyone has their own.",
|
|
||||||
icon: Icons.format_color_text,
|
|
||||||
builder: (title, description, icon) => DialogListTile<NameColor>(
|
|
||||||
icon: Icon(icon),
|
|
||||||
title: title,
|
|
||||||
subtitle: Text(description),
|
|
||||||
value: settings.nameColor,
|
|
||||||
options: NameColor.values,
|
|
||||||
getName: (option) => switch (option) {
|
|
||||||
.powerLevel => "By power level",
|
|
||||||
.user => "By user",
|
|
||||||
},
|
|
||||||
onChanged: (value) => ref
|
|
||||||
.read(SettingsController.provider.notifier)
|
|
||||||
.set(settings.copyWith(nameColor: value))
|
|
||||||
.onError(showError),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
.new(
|
.new(
|
||||||
title: "Behavior",
|
title: "Behavior",
|
||||||
icon: Icons.psychology,
|
icon: Icons.psychology,
|
||||||
settings: .new([
|
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<RoomOrder>(
|
|
||||||
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(
|
.new(
|
||||||
title: "Linux Mobile Mode",
|
title: "Linux Mobile Mode",
|
||||||
description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window, and swiping drawers open.",
|
description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.",
|
||||||
icon: Icons.construction,
|
icon: Icons.construction,
|
||||||
builder: (title, description, icon) => SwitchListTile(
|
builder: (title, description, icon) => SwitchListTile(
|
||||||
title: Text(title),
|
title: Text(title),
|
||||||
|
|
@ -209,201 +83,18 @@ class SettingsSectionsController
|
||||||
value: settings.linuxMobileMode,
|
value: settings.linuxMobileMode,
|
||||||
onChanged: Platform.isLinux
|
onChanged: Platform.isLinux
|
||||||
? (value) => ref
|
? (value) => ref
|
||||||
.read(SettingsController.provider.notifier)
|
.watch(SettingsController.provider.notifier)
|
||||||
.set(settings.copyWith(linuxMobileMode: value))
|
.set(settings.copyWith(linuxMobileMode: value))
|
||||||
.onError(showError)
|
.onError(showError)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
.new(
|
|
||||||
title: "Hide Membership Events",
|
|
||||||
description: "Hides membership events (invites, joins, leaves & bans) from appearing in conversations. Knocks are still shown.",
|
|
||||||
icon: Icons.person_off,
|
|
||||||
builder: (title, description, icon) => SwitchListTile(
|
|
||||||
title: Text(title),
|
|
||||||
subtitle: Text(description),
|
|
||||||
secondary: Icon(icon),
|
|
||||||
value: settings.hideMembershipEvents,
|
|
||||||
onChanged: (value) => ref
|
|
||||||
.read(SettingsController.provider.notifier)
|
|
||||||
.set(settings.copyWith(hideMembershipEvents: value))
|
|
||||||
.onError(showError),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
.new(
|
|
||||||
title: "Hide Server ACL Events",
|
|
||||||
description: "Hides server ACL (server ban list) changes from appearing in conversations.",
|
|
||||||
icon: Icons.dns,
|
|
||||||
builder: (title, description, icon) => SwitchListTile(
|
|
||||||
title: Text(title),
|
|
||||||
subtitle: Text(description),
|
|
||||||
secondary: Icon(icon),
|
|
||||||
value: settings.hideServerAclEvents,
|
|
||||||
onChanged: (value) => ref
|
|
||||||
.read(SettingsController.provider.notifier)
|
|
||||||
.set(settings.copyWith(hideServerAclEvents: value))
|
|
||||||
.onError(showError),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
.new(
|
|
||||||
title: "Privacy",
|
|
||||||
icon: Icons.privacy_tip,
|
|
||||||
settings: .new([
|
|
||||||
.new(
|
|
||||||
title: "Typing Indicators",
|
|
||||||
description: "Show when others are typing, and let others see when you're typing.",
|
|
||||||
icon: Icons.keyboard,
|
|
||||||
builder: (title, description, icon) => showAndSendTile(
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
icon,
|
|
||||||
value: settings.typingIndicators,
|
|
||||||
update: (value) => settings.copyWith(typingIndicators: value),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
.new(
|
|
||||||
title: "Read Receipts",
|
|
||||||
description: "Show how far others have read, and let others see what you've read. When not sent, read receipts are still sent privately, so your other devices know what you've read.",
|
|
||||||
icon: Icons.done_all,
|
|
||||||
builder: (title, description, icon) => showAndSendTile(
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
icon,
|
|
||||||
value: settings.readReceipts,
|
|
||||||
update: (value) => settings.copyWith(readReceipts: value),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
if (ref.watch(ClientStateController.provider) case ClientState(
|
if (ref.watch(ClientStateController.provider)?.isLoggedIn == true)
|
||||||
isLoggedIn: true,
|
|
||||||
:final userId?,
|
|
||||||
))
|
|
||||||
"Account": .new([
|
"Account": .new([
|
||||||
.new(
|
.new(title: "Profile", icon: Icons.person, settings: .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<String?>(
|
|
||||||
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(
|
.new(
|
||||||
title: "Safety",
|
title: "Safety",
|
||||||
icon: Icons.gpp_good,
|
icon: Icons.gpp_good,
|
||||||
|
|
@ -420,7 +111,7 @@ class SettingsSectionsController
|
||||||
icon: Icon(icon),
|
icon: Icon(icon),
|
||||||
title: title,
|
title: title,
|
||||||
subtitle: Text(description),
|
subtitle: Text(description),
|
||||||
value: ref
|
initialValue: ref
|
||||||
.watch(AccountDataController.provider)
|
.watch(AccountDataController.provider)
|
||||||
.invitePermissionConfig
|
.invitePermissionConfig
|
||||||
.defaultAction,
|
.defaultAction,
|
||||||
|
|
@ -441,7 +132,7 @@ class SettingsSectionsController
|
||||||
onChanged: specVersionsResponse.maybeWhen(
|
onChanged: specVersionsResponse.maybeWhen(
|
||||||
data: (_) =>
|
data: (_) =>
|
||||||
(value) => ref
|
(value) => ref
|
||||||
.read(ClientController.provider.notifier)
|
.watch(ClientController.provider.notifier)
|
||||||
.setAccountData(
|
.setAccountData(
|
||||||
.new(
|
.new(
|
||||||
type: AccountData.invitePermissionConfigKey,
|
type: AccountData.invitePermissionConfigKey,
|
||||||
|
|
@ -464,21 +155,6 @@ class SettingsSectionsController
|
||||||
title: "Other",
|
title: "Other",
|
||||||
icon: Icons.key,
|
icon: Icons.key,
|
||||||
settings: .new([
|
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(
|
.new(
|
||||||
title: "Log Out",
|
title: "Log Out",
|
||||||
description:
|
description:
|
||||||
|
|
@ -488,21 +164,14 @@ class SettingsSectionsController
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return M3EButton.icon(
|
return M3EButton.icon(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final container = ProviderScope.containerOf(context);
|
Navigator.of(context)
|
||||||
|
.popUntil((route) => route.isFirst);
|
||||||
|
|
||||||
final route = ModalRoute.of(context)!;
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
Navigator.of(context).popUntil((r) => r.isFirst);
|
|
||||||
await route.completed;
|
|
||||||
|
|
||||||
await container
|
await ref
|
||||||
.read(UnifiedPushController.provider.notifier)
|
.watch(ClientController.provider.notifier)
|
||||||
.deregister()
|
.logout();
|
||||||
.onError(showError);
|
|
||||||
container.invalidate(InitCompleteController.provider);
|
|
||||||
await container
|
|
||||||
.read(ClientController.provider.notifier)
|
|
||||||
.logout()
|
|
||||||
.onError(showError);
|
|
||||||
},
|
},
|
||||||
label: Text(title),
|
label: Text(title),
|
||||||
icon: Icon(icon),
|
icon: Icon(icon),
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:shared_preferences/shared_preferences.dart";
|
import "package:shared_preferences/shared_preferences.dart";
|
||||||
|
|
||||||
class SharedPrefsController extends Notifier<SharedPreferencesAsync> {
|
class SharedPrefsController extends AsyncNotifier<SharedPreferences> {
|
||||||
@override
|
@override
|
||||||
SharedPreferencesAsync build() => SharedPreferencesAsync();
|
Future<SharedPreferences> build() async => .getInstance();
|
||||||
|
|
||||||
static final provider =
|
static final provider =
|
||||||
NotifierProvider<SharedPrefsController, SharedPreferencesAsync>(
|
AsyncNotifierProvider<SharedPrefsController, SharedPreferences>(
|
||||||
SharedPrefsController.new,
|
SharedPrefsController.new,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:nexus/models/sidebar_page.dart";
|
|
||||||
|
|
||||||
class SidebarPageController extends Notifier<SidebarPage> {
|
|
||||||
@override
|
|
||||||
SidebarPage build() => .rooms;
|
|
||||||
|
|
||||||
void set(SidebarPage page) => state = page;
|
|
||||||
|
|
||||||
static final provider = NotifierProvider<SidebarPageController, SidebarPage>(
|
|
||||||
SidebarPageController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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<SpaceHierarchy> {
|
|
||||||
@override
|
|
||||||
Future<SpaceHierarchy> build() => ref
|
|
||||||
.read(ClientController.provider.notifier)
|
|
||||||
.getSpaceHierarchy(.new(roomId: spaceId, maxDepth: 1));
|
|
||||||
|
|
||||||
Future<void> 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, SpaceHierarchy, String>(
|
|
||||||
SpaceHierarchyController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -4,90 +4,87 @@ import "package:material_ui/material_ui.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/account_data.dart";
|
import "package:nexus/controllers/account_data.dart";
|
||||||
import "package:nexus/controllers/rooms.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/top_level_spaces.dart";
|
||||||
import "package:nexus/controllers/space_edges.dart";
|
import "package:nexus/controllers/space_edges.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
import "package:nexus/models/room_metadata.dart";
|
|
||||||
import "package:nexus/models/space.dart";
|
import "package:nexus/models/space.dart";
|
||||||
import "package:nexus/models/subspace.dart";
|
import "package:nexus/models/subspace.dart";
|
||||||
|
|
||||||
class SpacesController extends AsyncNotifier<IList<Space>> {
|
class SpacesController extends Notifier<IList<Space>> {
|
||||||
@override
|
@override
|
||||||
Future<IList<Space>> build() async {
|
IList<Space> build() {
|
||||||
final rooms = ref.watch(RoomsController.provider);
|
final rooms = ref.watch(RoomsController.provider);
|
||||||
final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider);
|
final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider);
|
||||||
final spaceEdges = ref.watch(SpaceEdgesController.provider);
|
final spaceEdges = ref.watch(SpaceEdgesController.provider);
|
||||||
final accountData = ref.watch(AccountDataController.provider);
|
final accountData = ref.watch(AccountDataController.provider);
|
||||||
|
|
||||||
final childrenById = spaceEdges.map(
|
final childrenById = {
|
||||||
(spaceId, edges) =>
|
for (final entry in spaceEdges.entries)
|
||||||
MapEntry(spaceId, edges.map((edge) => edge.childId).toList()),
|
entry.key: entry.value.map((e) => e.childId).toList(),
|
||||||
);
|
};
|
||||||
|
|
||||||
/// Everything under [spaceId], however deep, skipping any already
|
Set<String> collectDescendants(String startId) {
|
||||||
/// [visited] so cycles end.
|
final visited = <String>{};
|
||||||
ISet<String> collectDescendants(
|
final stack = [startId];
|
||||||
String spaceId, [
|
|
||||||
ISet<String> visited = const .empty(),
|
while (stack.isNotEmpty) {
|
||||||
]) => (childrenById[spaceId] ?? const []).fold(
|
final current = stack.removeLast();
|
||||||
visited,
|
final children = childrenById[current] ?? const [];
|
||||||
(visited, childId) => visited.contains(childId)
|
|
||||||
? visited
|
for (final child in children) {
|
||||||
: collectDescendants(childId, visited.add(childId)),
|
if (visited.add(child)) {
|
||||||
);
|
stack.add(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return visited;
|
||||||
|
}
|
||||||
|
|
||||||
Space buildSpace(String spaceId) {
|
Space buildSpace(String spaceId) {
|
||||||
final space = rooms[spaceId];
|
final space = rooms[spaceId];
|
||||||
final directChildrenIds = childrenById[spaceId] ?? const [];
|
final directChildrenIds = childrenById[spaceId] ?? const [];
|
||||||
|
|
||||||
|
final directRooms = <Room>[];
|
||||||
|
final subSpaces = <Subspace>[];
|
||||||
|
|
||||||
|
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(
|
return .new(
|
||||||
id: spaceId,
|
id: spaceId,
|
||||||
room: space,
|
room: space,
|
||||||
title: space?.metadata?.name ?? "Unnamed Space",
|
title: space?.metadata?.name ?? "Unnamed Space",
|
||||||
children: .new(
|
children: .new(directRooms),
|
||||||
directChildrenIds
|
subSpaces: .new(subSpaces),
|
||||||
.where((childId) => !childrenById.containsKey(childId))
|
|
||||||
.map((childId) => rooms[childId])
|
|
||||||
.nonNulls,
|
|
||||||
),
|
|
||||||
subSpaces: .new(
|
|
||||||
directChildrenIds
|
|
||||||
.where(childrenById.containsKey)
|
|
||||||
.map(
|
|
||||||
(childId) => switch (rooms[childId]) {
|
|
||||||
final room? => Subspace(
|
|
||||||
room: room,
|
|
||||||
children: .new(
|
|
||||||
collectDescendants(childId)
|
|
||||||
.map((id) => rooms[id])
|
|
||||||
.nonNulls,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
null => null,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.nonNulls,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final spaces = topLevelSpaceIds
|
final spaces = topLevelSpaceIds.map(buildSpace).toIList();
|
||||||
.where(rooms.containsKey)
|
|
||||||
.map(buildSpace)
|
|
||||||
.toIList();
|
|
||||||
|
|
||||||
final usedRooms = spaces
|
final usedRoomIds = {
|
||||||
.expand(
|
for (final space in spaces) ...[
|
||||||
(space) => [
|
...space.children.map((r) => r.metadata?.id),
|
||||||
...space.children,
|
...space.subSpaces.expand((s) => s.children.map((r) => r.metadata?.id)),
|
||||||
...space.subSpaces.expand((s) => s.children),
|
|
||||||
],
|
],
|
||||||
)
|
}.nonNulls.toISet();
|
||||||
.toISet();
|
|
||||||
final usedRoomIds = usedRooms.map((room) => room.metadata?.id).toSet();
|
|
||||||
|
|
||||||
final directMessages = accountData.directMessages.values.flattened.toSet();
|
final directMessages = accountData.directMessages.values.flattened;
|
||||||
|
|
||||||
final otherRooms = rooms.entries
|
final otherRooms = rooms.entries
|
||||||
.where(
|
.where(
|
||||||
|
|
@ -100,15 +97,6 @@ class SpacesController extends AsyncNotifier<IList<Space>> {
|
||||||
.toIList();
|
.toIList();
|
||||||
|
|
||||||
final homeRooms = otherRooms
|
final homeRooms = otherRooms
|
||||||
.addAll(
|
|
||||||
await ref.watch(
|
|
||||||
SettingsController.provider.selectAsync(
|
|
||||||
(data) => data.showAllInHome,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
? usedRooms
|
|
||||||
: .empty(),
|
|
||||||
)
|
|
||||||
.where((r) => !directMessages.contains(r.metadata?.id))
|
.where((r) => !directMessages.contains(r.metadata?.id))
|
||||||
.toIList();
|
.toIList();
|
||||||
|
|
||||||
|
|
@ -134,38 +122,28 @@ class SpacesController extends AsyncNotifier<IList<Space>> {
|
||||||
...spaces,
|
...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
|
return allSpaces
|
||||||
.map(
|
.map(
|
||||||
(space) => space.copyWith(
|
(space) => space.copyWith(
|
||||||
children: space.children.sort((a, b) {
|
children: .new(
|
||||||
if (roomOrder == .unreadFirst) {
|
space.children
|
||||||
final byUnread = unreadPriority(a).compareTo(unreadPriority(b));
|
.sortedBy(
|
||||||
if (byUnread != 0) return byUnread;
|
(element) =>
|
||||||
}
|
element
|
||||||
|
.metadata
|
||||||
// Most recent activity first
|
?.sortingTimestamp
|
||||||
return (b.metadata?.sortingTimestamp ?? DateTime(0)).compareTo(
|
.millisecondsSinceEpoch ??
|
||||||
a.metadata?.sortingTimestamp ?? DateTime(0),
|
0,
|
||||||
);
|
)
|
||||||
}),
|
.sortedBy((room) => room.metadata?.unreadMessages ?? 0)
|
||||||
|
.reversed,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toIList();
|
.toIList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider<SpacesController, IList<Space>>(
|
static final provider = NotifierProvider<SpacesController, IList<Space>>(
|
||||||
SpacesController.new,
|
SpacesController.new,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import "package:nexus/models/spec_versions_response.dart";
|
||||||
class SpecVersionsController extends AsyncNotifier<SpecVersionsResponse> {
|
class SpecVersionsController extends AsyncNotifier<SpecVersionsResponse> {
|
||||||
@override
|
@override
|
||||||
Future<SpecVersionsResponse> build() =>
|
Future<SpecVersionsResponse> build() =>
|
||||||
ref.read(ClientController.provider.notifier).getSpecVersions();
|
ref.watch(ClientController.provider.notifier).getSpecVersions();
|
||||||
|
|
||||||
static final provider =
|
static final provider =
|
||||||
AsyncNotifierProvider.autoDispose<
|
AsyncNotifierProvider.autoDispose<
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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<IList<String>> {
|
|
||||||
@override
|
|
||||||
IList<String> build() {
|
|
||||||
initializeTimeZones();
|
|
||||||
return timeZoneDatabase.locations.keys.toIList().sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
static final provider = NotifierProvider<TimezonesController, IList<String>>(
|
|
||||||
TimezonesController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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<IMap<String, IList<String>>> {
|
|
||||||
@override
|
|
||||||
IMap<String, IList<String>> build() => .new();
|
|
||||||
|
|
||||||
void set(Typing typing) => state = state.add(typing.roomId, typing.userIds);
|
|
||||||
|
|
||||||
static final provider =
|
|
||||||
NotifierProvider<TypingController, IMap<String, IList<String>>>(
|
|
||||||
TypingController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,260 +0,0 @@
|
||||||
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<String?> {
|
|
||||||
Completer<void>? _endpoint;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<String?> 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<String?> 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<void>();
|
|
||||||
|
|
||||||
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<bool> 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<void> 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, String?>(
|
|
||||||
UnifiedPushController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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<String?> {
|
|
||||||
@override
|
|
||||||
Future<String?> 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, String?>(
|
|
||||||
UnifiedPushAllowedController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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<IList<String>> {
|
|
||||||
@override
|
|
||||||
Future<IList<String>> 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<String>
|
|
||||||
>(UnifiedPushDistributorsController.new);
|
|
||||||
}
|
|
||||||
|
|
@ -11,7 +11,7 @@ class UrlPreviewController(final Uri url)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await ref
|
return await ref
|
||||||
.read(ClientController.provider.notifier)
|
.watch(ClientController.provider.notifier)
|
||||||
.getUrlPreview(url);
|
.getUrlPreview(url);
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
debugPrintStack(label: error.toString(), stackTrace: stackTrace);
|
debugPrintStack(label: error.toString(), stackTrace: stackTrace);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import "dart:async";
|
import "dart:async";
|
||||||
|
|
||||||
import "package:collection/collection.dart";
|
import "package:collection/collection.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/members.dart";
|
import "package:nexus/controllers/members.dart";
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,53 @@
|
||||||
|
import "package:collection/collection.dart";
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client_state.dart";
|
import "package:nexus/controllers/client_state.dart";
|
||||||
import "package:nexus/models/content/content.dart";
|
import "package:nexus/models/content/content.dart";
|
||||||
import "package:nexus/models/content/membership.dart";
|
import "package:nexus/models/content/membership.dart";
|
||||||
import "package:nexus/models/content/power_levels.dart";
|
import "package:nexus/models/content/power_levels.dart";
|
||||||
import "package:nexus/models/event.dart";
|
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
|
|
||||||
class ViaController(final Room room) extends Notifier<String> {
|
class ViaController(final Room room) extends Notifier<String> {
|
||||||
@override
|
@override
|
||||||
String build() {
|
String build() {
|
||||||
final powerLevelUsers =
|
final servers = <String>{};
|
||||||
switch (room.state[EventType.powerLevels.type]?[""]) {
|
|
||||||
final rowId? => switch (room.events[rowId]?.content) {
|
|
||||||
PowerLevelsContent(:final users) => users.keys.toIList(),
|
|
||||||
_ => null,
|
|
||||||
},
|
|
||||||
null => null,
|
|
||||||
} ??
|
|
||||||
.new([]);
|
|
||||||
|
|
||||||
final joinedMembers =
|
void addUserId(String? userId) {
|
||||||
(room.state[EventType.membership.type]?.values ?? const [])
|
final server = userId?.split(":").lastOrNull;
|
||||||
.map(
|
if (server != null) {
|
||||||
(rowId) => switch (room.events[rowId]) {
|
servers.add(server);
|
||||||
Event(
|
}
|
||||||
stateKey: final userId?,
|
}
|
||||||
content: MembershipContent(status: .join),
|
|
||||||
) =>
|
|
||||||
userId,
|
|
||||||
_ => null,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.nonNulls
|
|
||||||
.toIList();
|
|
||||||
|
|
||||||
final servers = [?ref.watch(ClientStateController.provider)?.userId]
|
addUserId(ref.watch(ClientStateController.provider)?.userId);
|
||||||
.toIList()
|
|
||||||
.addAll(powerLevelUsers)
|
final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""];
|
||||||
.addAll(joinedMembers)
|
final powerLevels = powerLevelsEventId == null
|
||||||
.map((userId) => userId.substring(userId.indexOf(":") + 1))
|
? null
|
||||||
.whereNoDuplicates()
|
: room.events[powerLevelsEventId];
|
||||||
.take(5)
|
|
||||||
.toISet();
|
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
|
return servers.isEmpty
|
||||||
? ""
|
? ""
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,10 @@ extension BetterWhen<T> on AsyncValue<T> {
|
||||||
required Widget Function(T value) data,
|
required Widget Function(T value) data,
|
||||||
Widget Function() loading = Loading.new,
|
Widget Function() loading = Loading.new,
|
||||||
bool skipLoadingOnRefresh = false,
|
bool skipLoadingOnRefresh = false,
|
||||||
bool skipLoadingOnReload = false,
|
|
||||||
}) => when(
|
}) => when(
|
||||||
data: data,
|
data: data,
|
||||||
error: (error, stackTrace) => ErrorDialog(error, stackTrace),
|
error: (error, stackTrace) => ErrorDialog(error, stackTrace),
|
||||||
loading: loading,
|
loading: loading,
|
||||||
skipLoadingOnRefresh: skipLoadingOnRefresh,
|
skipLoadingOnRefresh: skipLoadingOnRefresh,
|
||||||
skipLoadingOnReload: skipLoadingOnReload,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,295 +0,0 @@
|
||||||
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/widgets/mxc_emoji.dart";
|
|
||||||
import "package:nexus/main.dart";
|
|
||||||
|
|
||||||
extension BuildEventOptions on Event {
|
|
||||||
IList<PopupMenuEntry> 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<void> 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: [
|
|
||||||
...{
|
|
||||||
// Custom emoji are sent without their shortcode, as only
|
|
||||||
// their mxc URL is recorded
|
|
||||||
...ref
|
|
||||||
.read(RecentEmojiController.provider)
|
|
||||||
.map((entry) => entry.emoji),
|
|
||||||
"👍",
|
|
||||||
"🤣",
|
|
||||||
"😭",
|
|
||||||
"🤔",
|
|
||||||
"❤️",
|
|
||||||
}
|
|
||||||
.take(isDesktop ? 3 : 5)
|
|
||||||
.map(
|
|
||||||
(emoji) => IconButton(
|
|
||||||
icon: emoji.startsWith("mxc://")
|
|
||||||
? SizedBox.square(
|
|
||||||
dimension: isDesktop ? 18 : 24,
|
|
||||||
child: MxcEmoji(Uri.parse(emoji)),
|
|
||||||
)
|
|
||||||
: Text(
|
|
||||||
emoji,
|
|
||||||
style: .new(fontSize: isDesktop ? null : 18),
|
|
||||||
),
|
|
||||||
onPressed: () async {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
await ref
|
|
||||||
.read(notifier)
|
|
||||||
.sendReaction(emoji, this)
|
|
||||||
.onError(showError);
|
|
||||||
|
|
||||||
await ref
|
|
||||||
.read(RecentEmojiController.provider.notifier)
|
|
||||||
.add(emoji)
|
|
||||||
.onError(showError);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.emoji_emotions),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
|
|
||||||
context.showAdaptiveSheet(
|
|
||||||
(context) => EmojiPicker(
|
|
||||||
roomId: roomId,
|
|
||||||
allowFreeText: true,
|
|
||||||
onSelection: (emoji, {shortcode}) {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
ref
|
|
||||||
.read(notifier)
|
|
||||||
.sendReaction(emoji, this, shortcode: shortcode)
|
|
||||||
.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)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
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<String?> 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<bool>();
|
|
||||||
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<void> 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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:material_ui/material_ui.dart";
|
|
||||||
import "package:nexus/controllers/room_power_levels.dart";
|
|
||||||
import "package:nexus/controllers/settings.dart";
|
|
||||||
import "package:nexus/helpers/extensions/string_to_color.dart";
|
|
||||||
|
|
||||||
extension DisplaynameColor on WidgetRef {
|
|
||||||
/// The color of [userId]'s name in [roomId], from their power level
|
|
||||||
/// (so everyone with the same level shares one) or their user ID, depending
|
|
||||||
/// on the user's settings.
|
|
||||||
Color displaynameColor(String roomId, String userId) => switch (watch(
|
|
||||||
SettingsController.provider.select((settings) => settings.value?.nameColor),
|
|
||||||
)) {
|
|
||||||
.user => userId.colorHash,
|
|
||||||
.powerLevel || null => powerLevelColor(
|
|
||||||
watch(
|
|
||||||
RoomPowerLevelsController.provider(roomId)
|
|
||||||
.select((levels) => levels.levelOf(userId)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Like [displaynameColor], for when [userId]'s [level] is already known,
|
|
||||||
/// like a level they're being given.
|
|
||||||
Color displaynameColorAt(String userId, double level) => switch (watch(
|
|
||||||
SettingsController.provider.select((settings) => settings.value?.nameColor),
|
|
||||||
)) {
|
|
||||||
.user => userId.colorHash,
|
|
||||||
.powerLevel || null => powerLevelColor(level),
|
|
||||||
};
|
|
||||||
|
|
||||||
static Color powerLevelColor(double level) => HSLColor.fromAHSL(
|
|
||||||
1,
|
|
||||||
level.isInfinite ? 30 : (210 + level / 50 * 137.50776) % 360,
|
|
||||||
0.7,
|
|
||||||
0.5,
|
|
||||||
).toColor();
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
extension DistributorName on String {
|
|
||||||
String get distributorName => startsWith("nexus.federated.nexus")
|
|
||||||
? "Google Play Services"
|
|
||||||
: split(".").last;
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
extension EventIdIsFake on String {
|
|
||||||
bool get isFake => startsWith("~") || startsWith("\$gomuks");
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
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<bool> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
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<Room> get allChildRooms =>
|
|
||||||
children.addAll(subSpaces.expand((s) => s.children));
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue