Compare commits

..
326 changed files with 4746 additions and 16113 deletions

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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
View file

@ -42,5 +42,3 @@ password.txt
# Nix # Nix
/result /result
# Widget Preview related
.widget_preview/

1
.gitmodules vendored
View file

@ -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
View file

@ -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" }
}
]
}

View file

@ -10,10 +10,7 @@
"msgtype", "msgtype",
"muks", "muks",
"prefs", "prefs",
"recents",
"unban", "unban",
"unifiedpush", "unredact"
"unredact",
"webpush"
] ]
} }

View file

@ -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
View file

@ -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.
| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------: | | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------: |
| ![Screenshot of Nexus Client in dark mode, showing users talking, with a sidebar showing rooms and spaces, and another sidebar showing members](./assets/screenshotDark.png) | ![The same screenshot as above, but in light mode](./assets/screenshotLight.png) | | ![Screenshot of Nexus Client in dark mode, showing users talking, with a sidebar showing rooms and spaces, and another sidebar showing members](./assets/screenshotDark.png) | ![The same screenshot as above, but in light mode](./assets/screenshotLight.png) |
## Progress
- [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
View 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": []
}

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,5 @@
package nexus.federated.nexus
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()

View file

@ -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) }
}
}

View file

@ -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>

View file

@ -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/Roboto.ttf Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 616 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 616 KiB

Before After
Before After

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

24
flake.lock generated
View file

@ -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": {

View file

@ -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

@ -1 +1 @@
Subproject commit 53cae0e0334aef5ac376ea84f19ba1cff8a92310 Subproject commit 0e06c7b74f96d3e29a8101893ca1921d13a90573

View file

@ -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

View file

@ -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)
} }

View file

@ -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>(

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -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);

View file

@ -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,

View file

@ -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", {

View file

@ -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",

View 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,
);
}

View file

@ -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

View file

@ -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);
}

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -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,
); );
} }

View file

@ -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,
);
}

View file

@ -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,
); );
} }

View file

@ -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);
} }

View file

@ -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,

View file

@ -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<

View file

@ -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,
);
}

View file

@ -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);
}

View file

@ -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,
);
}

View file

@ -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);
}

View file

@ -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,
); );
} }

View file

@ -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,
);
}

View file

@ -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,
}, },

View file

@ -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,
);
}

View file

@ -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,

View file

@ -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,
);
}

View file

@ -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,

View file

@ -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);
}

View file

@ -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,
);
}

View file

@ -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,
); );
} }

View 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,
);
}

View file

@ -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,
);
}

View file

@ -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);
}

View file

@ -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>(

View file

@ -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,
);
}

View file

@ -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>>(

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -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()));
} }

View file

@ -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";

View file

@ -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),

View file

@ -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,
); );
} }

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -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,
); );
} }

View file

@ -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<

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -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);
}

View file

@ -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);

View file

@ -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";

View file

@ -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
? "" ? ""

View file

@ -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,
); );
} }

View file

@ -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)),
),
),
]);
}
}

View file

@ -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",
);
}
}

View file

@ -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();
}

View file

@ -1,5 +0,0 @@
extension DistributorName on String {
String get distributorName => startsWith("nexus.federated.nexus")
? "Google Play Services"
: split(".").last;
}

View file

@ -1,3 +0,0 @@
extension EventIdIsFake on String {
bool get isFake => startsWith("~") || startsWith("\$gomuks");
}

View file

@ -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;
}
}

View file

@ -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