Compare commits
109 changed files with 2553 additions and 4173 deletions
2
.github/workflows/android.yml
vendored
2
.github/workflows/android.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
||||||
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
|
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
|
||||||
|
|
||||||
- name: Build app
|
- name: Build app
|
||||||
run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64 --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
.github/workflows/flatpak.yml
vendored
36
.github/workflows/flatpak.yml
vendored
|
|
@ -35,39 +35,3 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: flatpak-${{ matrix.arch }}
|
name: flatpak-${{ matrix.arch }}
|
||||||
path: result/nexus.federated.nexus.flatpak
|
path: result/nexus.federated.nexus.flatpak
|
||||||
|
|
||||||
publish:
|
|
||||||
needs: build-flatpak
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
steps:
|
|
||||||
- name: Install flatpak tooling
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y flatpak ostree
|
|
||||||
|
|
||||||
- name: Download bundles
|
|
||||||
uses: actions/download-artifact@v7
|
|
||||||
with:
|
|
||||||
pattern: flatpak-*
|
|
||||||
path: bundles
|
|
||||||
|
|
||||||
- name: Build OSTree repo
|
|
||||||
run: |
|
|
||||||
ostree init --repo=repo --mode=archive-z2
|
|
||||||
for bundle in bundles/*/nexus.federated.nexus.flatpak; do
|
|
||||||
flatpak build-import-bundle repo "$bundle"
|
|
||||||
done
|
|
||||||
flatpak build-update-repo --prune repo
|
|
||||||
|
|
||||||
- uses: actions/upload-pages-artifact@v4
|
|
||||||
with:
|
|
||||||
path: repo
|
|
||||||
|
|
||||||
- name: Deploy to Pages
|
|
||||||
id: deployment
|
|
||||||
uses: actions/deploy-pages@v4
|
|
||||||
|
|
|
||||||
4
.github/workflows/ios.yml
vendored
4
.github/workflows/ios.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
- name: Set up Flutter
|
- name: Set up Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: 3.47.4
|
flutter-version: 3.47.0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
|
|
@ -31,7 +31,7 @@ jobs:
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart scripts/generate.dart
|
dart scripts/generate.dart
|
||||||
flutter pub run build_runner build
|
flutter pub run build_runner build
|
||||||
flutter build ios --no-codesign --dart-define=GIT_COMMIT=${{ github.sha }}
|
flutter build ios --release --no-codesign
|
||||||
|
|
||||||
# Unsigned IPA: SideStore/AltStore re-sign this on-device with the
|
# Unsigned IPA: SideStore/AltStore re-sign this on-device with the
|
||||||
# user's own Apple ID at install time, so no certificate is needed here.
|
# user's own Apple ID at install time, so no certificate is needed here.
|
||||||
|
|
|
||||||
4
.github/workflows/macos.yml
vendored
4
.github/workflows/macos.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
- name: Set up Flutter
|
- name: Set up Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: 3.47.4
|
flutter-version: 3.47.0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
|
|
@ -31,7 +31,7 @@ jobs:
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart scripts/generate.dart
|
dart scripts/generate.dart
|
||||||
flutter pub run build_runner build
|
flutter pub run build_runner build
|
||||||
flutter build macos --dart-define=GIT_COMMIT=${{ github.sha }}
|
flutter build macos --release
|
||||||
|
|
||||||
- name: Create DMG
|
- name: Create DMG
|
||||||
id: create-dmg
|
id: create-dmg
|
||||||
|
|
|
||||||
15
.github/workflows/windows.yml
vendored
15
.github/workflows/windows.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
- name: Set up Flutter
|
- name: Set up Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: 3.47.4
|
flutter-version: 3.47.0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
|
|
@ -29,7 +29,7 @@ jobs:
|
||||||
- name: Setup MSYS2
|
- name: Setup MSYS2
|
||||||
uses: msys2/setup-msys2@v2
|
uses: msys2/setup-msys2@v2
|
||||||
with:
|
with:
|
||||||
msystem: CLANG64
|
msystem: MINGW64
|
||||||
install: >-
|
install: >-
|
||||||
mingw-w64-x86_64-gcc
|
mingw-w64-x86_64-gcc
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ jobs:
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart scripts/generate.dart
|
dart scripts/generate.dart
|
||||||
flutter pub run build_runner build
|
flutter pub run build_runner build
|
||||||
flutter build windows --dart-define=GIT_COMMIT=${{ github.sha }}
|
flutter build windows --release
|
||||||
|
|
||||||
- name: Copy MinGW runtime DLLs
|
- name: Copy MinGW runtime DLLs
|
||||||
shell: msys2 {0}
|
shell: msys2 {0}
|
||||||
|
|
@ -52,15 +52,6 @@ jobs:
|
||||||
cp /mingw64/bin/libwinpthread-1.dll build/windows/x64/runner/Release/
|
cp /mingw64/bin/libwinpthread-1.dll build/windows/x64/runner/Release/
|
||||||
cp /mingw64/bin/libstdc++-6.dll build/windows/x64/runner/Release/
|
cp /mingw64/bin/libstdc++-6.dll build/windows/x64/runner/Release/
|
||||||
|
|
||||||
- name: Copy MSVC runtime DLLs
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
$release = "build/windows/x64/runner/Release"
|
|
||||||
|
|
||||||
Copy-Item C:\Windows\System32\MSVCP140.dll $release -Force
|
|
||||||
Copy-Item C:\Windows\System32\VCRUNTIME140.dll $release -Force
|
|
||||||
Copy-Item C:\Windows\System32\VCRUNTIME140_1.dll $release -Force
|
|
||||||
|
|
||||||
- name: Install Inno Setup
|
- name: Install Inno Setup
|
||||||
run: choco install innosetup -y
|
run: choco install innosetup -y
|
||||||
|
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -42,5 +42,3 @@ password.txt
|
||||||
|
|
||||||
# Nix
|
# Nix
|
||||||
/result
|
/result
|
||||||
# Widget Preview related
|
|
||||||
.widget_preview/
|
|
||||||
|
|
|
||||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -10,7 +10,6 @@
|
||||||
"msgtype",
|
"msgtype",
|
||||||
"muks",
|
"muks",
|
||||||
"prefs",
|
"prefs",
|
||||||
"recents",
|
|
||||||
"unban",
|
"unban",
|
||||||
"unifiedpush",
|
"unifiedpush",
|
||||||
"unredact",
|
"unredact",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ 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.
|
||||||
|
|
|
||||||
60
README.md
60
README.md
|
|
@ -17,20 +17,15 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
|
||||||
|
|
||||||
- [x] Platform Support
|
- [x] Platform Support
|
||||||
- [x] Linux
|
- [x] Linux
|
||||||
- [x] Notifications through UnifiedPush
|
|
||||||
- [x] Windows
|
- [x] Windows
|
||||||
- [ ] Background notifications
|
- 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] Android
|
||||||
- [x] Notifications through UnifiedPush
|
|
||||||
- [x] Notifications through FCM
|
|
||||||
- [x] MacOS
|
- [x] MacOS
|
||||||
- [x] Unsigned .ipa
|
- [x] Unsigned .ipa
|
||||||
- [ ] App Store
|
- [ ] App Store
|
||||||
- [ ] Background notifications
|
|
||||||
- [x] iOS
|
- [x] iOS
|
||||||
- [x] Unsigned .ipa
|
- [x] Unsigned .ipa
|
||||||
- [ ] App Store
|
- [ ] App Store
|
||||||
- [ ] Background notifications
|
|
||||||
- [ ] Web (may not be possible)
|
- [ ] Web (may not be possible)
|
||||||
- [x] Login (via OAuth)
|
- [x] Login (via OAuth)
|
||||||
- [x] Rooms / Spaces
|
- [x] Rooms / Spaces
|
||||||
|
|
@ -73,7 +68,9 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
|
||||||
- [x] URL Previews
|
- [x] URL Previews
|
||||||
- [x] Replies
|
- [x] Replies
|
||||||
- [x] Viewing
|
- [x] Viewing
|
||||||
- [x] Jump to original message
|
- [ ] Jump to original message
|
||||||
|
- [x] In loaded timeline
|
||||||
|
- [ ] Out of loaded timeline
|
||||||
- [x] Edits
|
- [x] Edits
|
||||||
- [x] Attachments
|
- [x] Attachments
|
||||||
- [x] Unencrypted
|
- [x] Unencrypted
|
||||||
|
|
@ -86,10 +83,12 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
|
||||||
- [x] Users
|
- [x] Users
|
||||||
- [x] Clickable
|
- [x] Clickable
|
||||||
- [x] Rooms
|
- [x] Rooms
|
||||||
- [x] Clickable
|
- [ ] Clickable
|
||||||
- [x] Matrix URIs
|
- [x] Matrix URIs
|
||||||
- [x] Matrix.to links
|
- [x] Matrix.to links
|
||||||
- [x] Events
|
- [x] Events
|
||||||
|
- [ ] Render more nicely
|
||||||
|
- [ ] Clickable
|
||||||
- [x] Custom emojis/stickers
|
- [x] Custom emojis/stickers
|
||||||
- [x] History loading
|
- [x] History loading
|
||||||
- [x] Editing
|
- [x] Editing
|
||||||
|
|
@ -125,45 +124,10 @@ If you want to try out Nexus, grab one of the following artifacts from CI:
|
||||||
- [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)
|
||||||
### Flatpak
|
- [NixOS Module](linux/nix/module.nix)
|
||||||
|
|
||||||
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";
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -171,7 +135,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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
BIN
assets/fonts/NotoColorEmoji.ttf
Normal file
BIN
assets/fonts/NotoColorEmoji.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Roboto.ttf
Normal file
BIN
assets/fonts/Roboto.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
assets/twim/oauth.webp
Normal file
BIN
assets/twim/oauth.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
assets/twim/settings.png
Normal file
BIN
assets/twim/settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
24
flake.lock
generated
24
flake.lock
generated
|
|
@ -5,11 +5,11 @@
|
||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788450739,
|
"lastModified": 1785627969,
|
||||||
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
|
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
|
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -42,11 +42,11 @@
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1790870263,
|
"lastModified": 1790043059,
|
||||||
"narHash": "sha256-ZBblCzxsUBBCZJRJR0knnaOt2+Xl5JGfI+YkGPf2SI4=",
|
"narHash": "sha256-7ID4HL4IrrlalCsxMASkcAr+v0VHTNFp6+sZ+dpvOV8=",
|
||||||
"owner": "Henry-Hiles",
|
"owner": "Henry-Hiles",
|
||||||
"repo": "nix2flatpak",
|
"repo": "nix2flatpak",
|
||||||
"rev": "2ef0e3cde2bdce0870f79a7568a72c2681ad20b5",
|
"rev": "290dd282e555cab988c021c2be5f2361e58c4d51",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -74,11 +74,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": {
|
||||||
|
|
@ -89,11 +89,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": {
|
||||||
|
|
|
||||||
2
gomuks
2
gomuks
|
|
@ -1 +1 @@
|
||||||
Subproject commit 38e5033c3a91db0cbea6cbd9f0feef9ff6441038
|
Subproject commit b11eedca121a7081d3ee48165ba363e722d18d87
|
||||||
|
|
@ -6,22 +6,27 @@ 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:intl/intl.dart";
|
||||||
|
import "package:nexus/controllers/account_data.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/main.dart";
|
||||||
import "package:nexus/models/capabilities.dart";
|
import "package:nexus/models/capabilities.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/gomuks_config.dart";
|
import "package:nexus/models/gomuks_config.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/deregister_pusher.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_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";
|
||||||
|
|
@ -31,7 +36,6 @@ 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/register_pusher.dart";
|
||||||
import "package:nexus/models/requests/report.dart";
|
import "package:nexus/models/requests/report.dart";
|
||||||
|
|
@ -42,9 +46,9 @@ import "package:nexus/models/requests/set_membership.dart";
|
||||||
import "package:nexus/models/requests/set_state.dart";
|
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_metadata.dart";
|
|
||||||
import "package:nexus/models/room_summary.dart";
|
import "package:nexus/models/room_summary.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";
|
||||||
|
|
@ -52,7 +56,6 @@ 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...");
|
|
||||||
if (Platform.isAndroid || Platform.isIOS) {
|
if (Platform.isAndroid || Platform.isIOS) {
|
||||||
final env = {
|
final env = {
|
||||||
"GOMUKS_ROOT": (await getApplicationSupportDirectory()).path,
|
"GOMUKS_ROOT": (await getApplicationSupportDirectory()).path,
|
||||||
|
|
@ -61,17 +64,12 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
for (final MapEntry(:key, :value) in env.entries) {
|
for (final MapEntry(:key, :value) in env.entries) {
|
||||||
final keyPtr = key.toNativeUtf8().cast<Char>();
|
final keyPtr = key.toNativeUtf8().cast<Char>();
|
||||||
final valuePtr = value.toNativeUtf8().cast<Char>();
|
final valuePtr = value.toNativeUtf8().cast<Char>();
|
||||||
try {
|
|
||||||
GomuksSetEnv(keyPtr, valuePtr);
|
GomuksSetEnv(keyPtr, valuePtr);
|
||||||
} finally {
|
|
||||||
calloc
|
calloc
|
||||||
..free(keyPtr)
|
..free(keyPtr)
|
||||||
..free(valuePtr);
|
..free(valuePtr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint("Initializing Gomuks...");
|
|
||||||
final handle = await Isolate.run(() {
|
final handle = await Isolate.run(() {
|
||||||
final bufferPointer = GomuksConfig(
|
final bufferPointer = GomuksConfig(
|
||||||
matrix: .new(
|
matrix: .new(
|
||||||
|
|
@ -83,15 +81,103 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
try {
|
try {
|
||||||
return GomuksInit(bufferPointer.ref);
|
return GomuksInit(bufferPointer.ref);
|
||||||
} finally {
|
} finally {
|
||||||
calloc
|
calloc.free(bufferPointer);
|
||||||
..free(bufferPointer.ref.base)
|
}
|
||||||
..free(bufferPointer);
|
});
|
||||||
|
|
||||||
|
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.toJson();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ref.onDispose(() => GomuksDestroy(handle));
|
ref.onDispose(() => GomuksDestroy(handle));
|
||||||
|
ref.onDispose(callable.close);
|
||||||
|
|
||||||
return handle;
|
final errorCode = GomuksStart(handle, callable.nativeFunction);
|
||||||
|
|
||||||
|
if (errorCode == 0) return handle;
|
||||||
|
throw Exception("GomuksStart returned error code $errorCode");
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<dynamic> callGomuksMethod(
|
Future<dynamic> callGomuksMethod(
|
||||||
|
|
@ -100,43 +186,31 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
callback,
|
callback,
|
||||||
) async {
|
) async {
|
||||||
final bufferPointer = data.toGomuksBufferPtr();
|
final bufferPointer = data.toGomuksBufferPtr();
|
||||||
|
|
||||||
try {
|
|
||||||
final handle = await future;
|
final handle = await future;
|
||||||
|
|
||||||
final response = await Isolate.run(
|
final response = await Isolate.run(
|
||||||
() => callback(handle, bufferPointer.ref),
|
() => callback(handle, bufferPointer.ref),
|
||||||
);
|
);
|
||||||
|
|
||||||
final json = response.buf.toJsonAndFree();
|
calloc.free(bufferPointer);
|
||||||
|
|
||||||
|
final json = response.buf.toJson();
|
||||||
if (response.command.cast<Utf8>().toDartString() == "error") {
|
if (response.command.cast<Utf8>().toDartString() == "error") {
|
||||||
throw json;
|
throw json;
|
||||||
}
|
}
|
||||||
|
|
||||||
return json;
|
return json;
|
||||||
} finally {
|
|
||||||
calloc
|
|
||||||
..free(bufferPointer.ref.base)
|
|
||||||
..free(bufferPointer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<(Event, RoomMetadata)?> handlePush(Map<String, dynamic> data) async {
|
Future<Event?> handlePush(Map<String, dynamic> data) async {
|
||||||
final response = await callGomuksMethod(
|
final response = await callGomuksMethod(
|
||||||
data,
|
data,
|
||||||
(handle, data) async => GomuksHandlePush(handle, data),
|
(handle, data) async => GomuksHandlePush(handle, data),
|
||||||
);
|
);
|
||||||
|
|
||||||
return response == null
|
return response == null ? null : .fromJson(response);
|
||||||
? null
|
|
||||||
: (
|
|
||||||
Event.fromJson(response["event"]),
|
|
||||||
RoomMetadata.fromJson(response["room"]),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<dynamic> _sendCommand(
|
dynamic _sendCommand(
|
||||||
String command, [
|
String command, [
|
||||||
Map<String, dynamic> data = const {},
|
Map<String, dynamic> data = const {},
|
||||||
]) => callGomuksMethod(data, (handle, data) {
|
]) => callGomuksMethod(data, (handle, data) {
|
||||||
|
|
@ -158,7 +232,7 @@ 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": {
|
||||||
|
|
@ -196,6 +270,11 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
await _sendCommand("leave_room", {"room_id": room.metadata!.id});
|
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 =>
|
||||||
(await _sendCommand("get_room_state", request.toJson())) as List?;
|
(await _sendCommand("get_room_state", request.toJson())) as List?;
|
||||||
|
|
@ -233,17 +312,11 @@ 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<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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -274,11 +347,9 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<void> logout() => _sendCommand("logout");
|
Future<void> logout() => _sendCommand("logout");
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
await _sendCommand("mark_read", {
|
await _sendCommand("mark_read", {
|
||||||
"room_id": room.metadata!.id,
|
"room_id": room.metadata!.id,
|
||||||
|
|
@ -304,8 +375,9 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<SpecVersionsResponse> getSpecVersions() async =>
|
Future<SpecVersionsResponse> getSpecVersions() async =>
|
||||||
.fromJson(await _sendCommand("get_versions"));
|
.fromJson(await _sendCommand("get_versions"));
|
||||||
|
|
||||||
Future<Capabilities> getCapabilities() async =>
|
Future<Capabilities> getCapabilities() async => Capabilities.fromJson(
|
||||||
.fromJson((await _sendCommand("get_capabilities"))["capabilities"]);
|
(await _sendCommand("get_capabilities"))["capabilities"],
|
||||||
|
);
|
||||||
|
|
||||||
Future<Uri?> discoverHomeserver(Uri homeserver) async {
|
Future<Uri?> discoverHomeserver(Uri homeserver) async {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -16,28 +16,11 @@ class EventController(final GetEventRequest request)
|
||||||
(event) => event.eventId == request.eventId,
|
(event) => event.eventId == request.eventId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (event != null) return event;
|
return event ??
|
||||||
|
await ref
|
||||||
final fetchedEvent = await ref
|
|
||||||
.watch(ClientController.provider.notifier)
|
.watch(ClientController.provider.notifier)
|
||||||
.getEvent(request)
|
.getEvent(request)
|
||||||
.onError((_, _) => null);
|
.onError((_, _) => null);
|
||||||
|
|
||||||
if (fetchedEvent != null) {
|
|
||||||
ref
|
|
||||||
.watch(RoomsController.provider.notifier)
|
|
||||||
.update(
|
|
||||||
.new({
|
|
||||||
request.roomId: .new(
|
|
||||||
events: .new({fetchedEvent.rowId: fetchedEvent}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return fetchedEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
static final provider = AsyncNotifierProvider.family
|
||||||
|
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
import "dart:async";
|
|
||||||
import "dart:ffi";
|
|
||||||
|
|
||||||
import "package:flutter/foundation.dart";
|
|
||||||
import "package:nexus/controllers/account_data.dart";
|
|
||||||
import "package:nexus/controllers/client.dart";
|
|
||||||
import "package:nexus/controllers/client_state.dart";
|
|
||||||
import "package:nexus/controllers/init_complete.dart";
|
|
||||||
import "package:nexus/controllers/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/main.dart";
|
|
||||||
import "package:ffi/ffi.dart";
|
|
||||||
import "package:nexus/models/event.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
|
|
||||||
.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})),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ref.onDispose(callable.close);
|
|
||||||
|
|
||||||
final errorCode = GomuksStart(handle, callable.nativeFunction);
|
|
||||||
if (errorCode != 0) {
|
|
||||||
throw Exception("GomuksStart returned error code $errorCode");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider<GomuksListenerController, void>(
|
|
||||||
GomuksListenerController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
|
|
||||||
typedef JumpRequest = ({String roomId, int rowId});
|
|
||||||
|
|
||||||
class JumpRequestController extends Notifier<JumpRequest?> {
|
|
||||||
@override
|
|
||||||
JumpRequest? build() => null;
|
|
||||||
|
|
||||||
void request(String roomId, int rowId) =>
|
|
||||||
state = (roomId: roomId, rowId: rowId);
|
|
||||||
|
|
||||||
void consume() => state = null;
|
|
||||||
|
|
||||||
static final provider = NotifierProvider<JumpRequestController, JumpRequest?>(
|
|
||||||
JumpRequestController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import "package:nexus/controllers/portal.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:flutter_local_notifications/flutter_local_notifications.dart";
|
import "package:flutter_local_notifications/flutter_local_notifications.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/widgets/notifications_list.dart";
|
import "package:nexus/pages/notifications.dart";
|
||||||
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
|
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
|
||||||
|
|
||||||
class NotificationController
|
class NotificationController
|
||||||
|
|
@ -31,14 +31,12 @@ class NotificationController
|
||||||
macOS: darwin,
|
macOS: darwin,
|
||||||
),
|
),
|
||||||
onDidReceiveNotificationResponse: (details) {
|
onDidReceiveNotificationResponse: (details) {
|
||||||
if (details.payload == null) return;
|
if (details.payload case final eventId?) {
|
||||||
|
|
||||||
if (int.tryParse(details.payload!) case final rowId?) {
|
|
||||||
if (navigatorKey.currentContext case final context?) {
|
if (navigatorKey.currentContext case final context?) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => NotificationsList(
|
builder: (_) => NotificationsPage(
|
||||||
highlightedEvent: rowId,
|
highlightedEventId: eventId,
|
||||||
defaultToAllNotifications: true,
|
defaultToAllNotifications: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -57,18 +55,18 @@ class NotificationController
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (int.tryParse(call.arguments) case int rowId) {
|
final eventId = call.arguments as String;
|
||||||
|
|
||||||
if (navigatorKey.currentContext case final context?) {
|
if (navigatorKey.currentContext case final context?) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => NotificationsList(
|
builder: (_) => NotificationsPage(
|
||||||
highlightedEvent: rowId,
|
highlightedEventId: eventId,
|
||||||
defaultToAllNotifications: true,
|
defaultToAllNotifications: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ref.onDispose(() => notificationChannel.setMethodCallHandler(null));
|
ref.onDispose(() => notificationChannel.setMethodCallHandler(null));
|
||||||
|
|
@ -126,18 +124,9 @@ class NotificationController
|
||||||
id.toString(),
|
id.toString(),
|
||||||
title: title,
|
title: title,
|
||||||
body: body,
|
body: body,
|
||||||
|
icon: icon == null ? null : XdgNotificationIconFile(icon.path),
|
||||||
defaultAction: "app.event",
|
defaultAction: "app.event",
|
||||||
defaultActionTarget: payload,
|
defaultActionTarget: payload,
|
||||||
category: XdgNotificationCategory.imReceived,
|
|
||||||
priority: .normal,
|
|
||||||
buttons: [
|
|
||||||
// TODO: Impl when notif daemons actualy support imReplyWithText
|
|
||||||
// .new(
|
|
||||||
// label: "Reply",
|
|
||||||
// action: "app.reply",
|
|
||||||
// purpose: XdgNotificationButtonPurpose.imReplyWithText,
|
|
||||||
// ),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
final notificationDetails = NotificationDetails(
|
final notificationDetails = NotificationDetails(
|
||||||
|
|
|
||||||
|
|
@ -1,93 +1,57 @@
|
||||||
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.dart";
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
|
|
||||||
typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
|
typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
|
||||||
|
|
||||||
class NotificationsController(final NotificationsRequest request)
|
class NotificationsController([final NotificationsRequest? request])
|
||||||
extends AsyncNotifier<IList<(int, String)>> {
|
extends AsyncNotifier<IList<Event>> {
|
||||||
void storeEvents(Iterable<Event> events) => ref
|
static const limit = 20;
|
||||||
.read(RoomsController.provider.notifier)
|
|
||||||
.update(
|
|
||||||
.new(
|
|
||||||
events
|
|
||||||
.groupListsBy((event) => event.roomId)
|
|
||||||
.map(
|
|
||||||
(roomId, roomEvents) => .new(
|
|
||||||
roomId,
|
|
||||||
.new(
|
|
||||||
events: IMap.fromIterable(
|
|
||||||
roomEvents,
|
|
||||||
keyMapper: (event) => event.rowId,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<IList<(int, String)>> build() async {
|
Future<IList<Event>> build() async {
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
final client = ref.watch(ClientController.provider.notifier);
|
||||||
|
|
||||||
final (unreadType, roomId) = request;
|
final (unreadType, roomId) = request ?? (null, null);
|
||||||
|
|
||||||
final mentions = await client.getMentions(
|
return await client.getMentions(
|
||||||
.new(
|
.new(
|
||||||
maxTimestamp: .now(),
|
maxTimestamp: .now(),
|
||||||
unreadType: unreadType ?? .highlight,
|
unreadType: unreadType ?? .highlight,
|
||||||
|
limit: limit,
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
storeEvents(mentions);
|
|
||||||
|
|
||||||
return .new(mentions.map((event) => (event.rowId, event.roomId)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadOlder() async {
|
Future<void> loadOlder() async {
|
||||||
final currentNotifications = await future;
|
final currentNotifications = await future;
|
||||||
state = .loading();
|
state = .loading();
|
||||||
state = await .guard(() async {
|
state = await .guard(() async {
|
||||||
final lastNotification = currentNotifications.lastOrNull;
|
final lastTs = currentNotifications.lastOrNull?.timestamp;
|
||||||
final lastTs = lastNotification == null
|
if (lastTs == null) return const .empty();
|
||||||
? null
|
|
||||||
: ref.watch(
|
|
||||||
RoomsController.provider.select(
|
|
||||||
(rooms) => rooms[lastNotification.$2]
|
|
||||||
?.events[lastNotification.$1]
|
|
||||||
?.timestamp,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (lastTs == null) return currentNotifications;
|
final client = ref.watch(ClientController.provider.notifier);
|
||||||
|
final (unreadType, roomId) = request ?? (null, null);
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
|
||||||
final (unreadType, roomId) = request;
|
|
||||||
|
|
||||||
final newNotifications = await client.getMentions(
|
final newNotifications = await client.getMentions(
|
||||||
.new(
|
.new(
|
||||||
maxTimestamp: lastTs,
|
maxTimestamp: lastTs,
|
||||||
unreadType: unreadType ?? .highlight,
|
unreadType: unreadType ?? .highlight,
|
||||||
|
limit: limit,
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
storeEvents(newNotifications);
|
return currentNotifications.addAll(newNotifications);
|
||||||
|
|
||||||
return currentNotifications.addAll(
|
|
||||||
newNotifications.map((event) => (event.rowId, event.roomId)),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<
|
.autoDispose<
|
||||||
NotificationsController,
|
NotificationsController,
|
||||||
IList<(int, String)>,
|
IList<Event>,
|
||||||
NotificationsRequest
|
NotificationsRequest?
|
||||||
>(NotificationsController.new);
|
>(NotificationsController.new);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,26 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:nexus/controllers/event.dart";
|
import "package:nexus/controllers/event.dart";
|
||||||
import "package:nexus/controllers/pinned_ids.dart";
|
import "package:nexus/controllers/pinned_ids.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
|
|
||||||
class PinnedEventsController(final String roomId)
|
class PinnedEventsController(final String roomId)
|
||||||
extends AsyncNotifier<IList<int>> {
|
extends AsyncNotifier<IList<Event>> {
|
||||||
@override
|
@override
|
||||||
Future<IList<int>> build() async {
|
Future<IList<Event>> build() async {
|
||||||
final pinIds = ref.watch(PinnedIdsController.provider(roomId));
|
final pinIds = ref.watch(PinnedIdsController.provider(roomId));
|
||||||
|
|
||||||
return (await Future.wait(
|
return (await Future.wait(
|
||||||
pinIds.map(
|
pinIds.map(
|
||||||
(eventId) => ref.watch(
|
(eventId) => ref.watch(
|
||||||
EventController.provider(.new(eventId: eventId, roomId: roomId))
|
EventController.provider(.new(eventId: eventId, roomId: roomId))
|
||||||
.selectAsync((data) => data?.rowId),
|
.future,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)).nonNulls.toIList();
|
)).nonNulls.toIList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<PinnedEventsController, IList<int>, String>(
|
.autoDispose<PinnedEventsController, IList<Event>, String>(
|
||||||
PinnedEventsController.new,
|
PinnedEventsController.new,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,18 @@
|
||||||
import "package:collection/collection.dart";
|
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:material_emoji_picker/material_emoji_picker.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/models/account_data.dart";
|
import "package:nexus/models/account_data.dart";
|
||||||
|
|
||||||
class RecentEmojiController extends Notifier<IList<RecentEmoji>> {
|
class RecentEmojiController extends Notifier<IList<RecentEmoji>> {
|
||||||
@override
|
@override
|
||||||
IList<RecentEmoji> build() {
|
IList<RecentEmoji> build() => ref.watch(
|
||||||
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(
|
AccountDataController.provider.select(
|
||||||
(value) => value.recentEmoji.recentEmoji,
|
(value) => value.recentEmoji.recentEmoji,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return .new(recents.where((recent) => emoji.contains(recent.emoji)));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> add(String emoji) => ref
|
Future<void> add(String emoji) => ref
|
||||||
.watch(ClientController.provider.notifier)
|
.watch(ClientController.provider.notifier)
|
||||||
.setAccountData(
|
.setAccountData(
|
||||||
|
|
|
||||||
|
|
@ -10,81 +10,57 @@ 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/reaction.dart";
|
import "package:nexus/models/content/reaction.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.read(
|
final room = ref.watch(
|
||||||
RoomsController.provider.select((rooms) => rooms[info.roomId]),
|
RoomsController.provider.select((rooms) => rooms[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.clientSticky)
|
||||||
|
.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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final events = context.before.add(context.event).addAll(context.after);
|
|
||||||
ref
|
|
||||||
.read(RoomsController.provider.notifier)
|
|
||||||
.update(
|
|
||||||
.new({
|
|
||||||
info.roomId: Room(
|
|
||||||
events: IMap.fromIterable(
|
|
||||||
events,
|
|
||||||
keyMapper: (event) => event.rowId,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
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
|
||||||
|
|
@ -92,53 +68,22 @@ class RoomChatController(final ChatParams info)
|
||||||
.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)
|
|
||||||
.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();
|
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,
|
||||||
|
|
@ -147,13 +92,12 @@ class RoomChatController(final ChatParams info)
|
||||||
|
|
||||||
if (response.events.isEmpty) {
|
if (response.events.isEmpty) {
|
||||||
state = .data(state.value);
|
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,
|
||||||
|
|
@ -167,61 +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)
|
|
||||||
.update(
|
|
||||||
.new({
|
|
||||||
info.roomId: Room(
|
|
||||||
events: IMap.fromIterable(
|
|
||||||
paginationResponse.events,
|
|
||||||
keyMapper: (event) => event.rowId,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
final eventRowIds = paginationResponse.events
|
|
||||||
.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(
|
||||||
|
|
@ -229,22 +123,14 @@ 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.watch(
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,36 +146,37 @@ class RoomChatController(final ChatParams info)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
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 == true &&
|
if (shouldMention == true &&
|
||||||
relatedEvent != null &&
|
relation != null &&
|
||||||
relationType == RelationType.reply)
|
relationType == RelationType.reply)
|
||||||
relatedEvent.sender,
|
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}),
|
clientSticky: .new({event.rowId}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
.new(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -298,10 +185,10 @@ 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",
|
||||||
),
|
),
|
||||||
|
|
@ -322,18 +209,16 @@ class RoomChatController(final ChatParams info)
|
||||||
if (reactionEvent != null) {
|
if (reactionEvent != null) {
|
||||||
await ref
|
await ref
|
||||||
.watch(ClientController.provider.notifier)
|
.watch(ClientController.provider.notifier)
|
||||||
.redactEvent(
|
.redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId));
|
||||||
.new(eventId: reactionEvent.eventId, roomId: info.roomId),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendReaction(String reaction, Event event) async {
|
Future<void> sendReaction(String reaction, Event event) async {
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
final client = ref.watch(ClientController.provider.notifier);
|
||||||
|
|
||||||
await client.sendEvent(
|
await client.sendEvent(
|
||||||
.new(
|
.new(
|
||||||
roomId: info.roomId,
|
roomId: roomId,
|
||||||
type: EventType.reaction.type,
|
type: EventType.reaction.type,
|
||||||
content: ReactionContent(key: reaction),
|
content: ReactionContent(key: reaction),
|
||||||
synchronous: true,
|
synchronous: true,
|
||||||
|
|
@ -345,7 +230,7 @@ class RoomChatController(final ChatParams info)
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
|
.autoDispose<RoomChatController, IList<Event>?, String>(
|
||||||
RoomChatController.new,
|
RoomChatController.new,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ class RoomSummaryController(final JoinRoomRequest request)
|
||||||
extends AsyncNotifier<RoomSummary> {
|
extends AsyncNotifier<RoomSummary> {
|
||||||
@override
|
@override
|
||||||
Future<RoomSummary> build() =>
|
Future<RoomSummary> build() =>
|
||||||
ref.read(ClientController.provider.notifier).getRoomSummary(request);
|
ref.watch(ClientController.provider.notifier).getRoomSummary(request);
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<RoomSummaryController, RoomSummary, JoinRoomRequest>(
|
.autoDispose<RoomSummaryController, RoomSummary, JoinRoomRequest>(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import "package:collection/collection.dart";
|
import "dart:isolate";
|
||||||
|
|
||||||
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";
|
||||||
|
|
@ -8,35 +9,35 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
@override
|
@override
|
||||||
IMap<String, Room> build() => .new();
|
IMap<String, Room> build() => .new();
|
||||||
|
|
||||||
void addState(
|
Future<void> addState(
|
||||||
String roomId,
|
String roomId,
|
||||||
IList<Event> state, {
|
IList<Event> state, {
|
||||||
bool isMembers = false,
|
bool isMembers = false,
|
||||||
}) => update(
|
}) async => update(
|
||||||
.new({
|
.new({
|
||||||
roomId: Room(
|
roomId: Room(
|
||||||
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
|
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
|
||||||
hasFetchedState: true,
|
hasFetchedState: true,
|
||||||
hasFetchedMembers: isMembers,
|
hasFetchedMembers: isMembers,
|
||||||
state: .new(
|
state: await Isolate.run(() {
|
||||||
state
|
final newState = state.fold<IMap<String, IMap<String, int>>>(
|
||||||
.groupListsBy((event) => event.type)
|
.new(),
|
||||||
.map(
|
(previousValue, stateEvent) => previousValue.add(
|
||||||
(type, group) => .new(
|
stateEvent.type,
|
||||||
type,
|
(previousValue[stateEvent.type] ?? .new()).add(
|
||||||
.fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))),
|
stateEvent.stateKey!,
|
||||||
),
|
stateEvent.rowId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
return newState;
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
.new(),
|
.new(),
|
||||||
);
|
);
|
||||||
|
|
||||||
void update(
|
void update(IMap<String, Room> rooms, ISet<String> leftRooms) {
|
||||||
IMap<String, Room> rooms, [
|
|
||||||
ISet<String> leftRooms = const ISet.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;
|
||||||
|
|
@ -46,13 +47,10 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
roomId,
|
roomId,
|
||||||
existing?.copyWith(
|
existing?.copyWith(
|
||||||
hasMore: incoming.hasMore,
|
hasMore: incoming.hasMore,
|
||||||
clientSticky: incoming.resetClientSticky
|
clientSticky:
|
||||||
? incoming.clientSticky
|
(incoming.clientSticky.isEmpty == true
|
||||||
: (incoming.clientSticky.isEmpty == true
|
|
||||||
? existing.clientSticky
|
? existing.clientSticky
|
||||||
: existing.clientSticky.addAll(
|
: existing.clientSticky.addAll(incoming.clientSticky))
|
||||||
incoming.clientSticky,
|
|
||||||
))
|
|
||||||
.removeWhere(
|
.removeWhere(
|
||||||
(rowId) => incoming.timeline.values.contains(rowId),
|
(rowId) => incoming.timeline.values.contains(rowId),
|
||||||
),
|
),
|
||||||
|
|
@ -67,7 +65,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ 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/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/unified_push.dart";
|
||||||
|
|
@ -77,38 +76,6 @@ class SettingsSectionsController
|
||||||
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
|
|
||||||
.watch(SettingsController.provider.notifier)
|
|
||||||
.set(settings.copyWith(showAllInHome: 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
|
|
||||||
.watch(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.",
|
description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.",
|
||||||
|
|
@ -285,19 +252,12 @@ 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);
|
await ref
|
||||||
|
.watch(UnifiedPushController.provider.notifier)
|
||||||
final route = ModalRoute.of(context)!;
|
|
||||||
Navigator.of(context).popUntil((r) => r.isFirst);
|
|
||||||
await route.completed;
|
|
||||||
|
|
||||||
await container
|
|
||||||
.read(UnifiedPushController.provider.notifier)
|
|
||||||
.deregister()
|
.deregister()
|
||||||
.onError(showError);
|
.onError(showError);
|
||||||
container.invalidate(InitCompleteController.provider);
|
await ref
|
||||||
await container
|
.watch(ClientController.provider.notifier)
|
||||||
.read(ClientController.provider.notifier)
|
|
||||||
.logout()
|
.logout()
|
||||||
.onError(showError);
|
.onError(showError);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,15 @@ 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/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);
|
||||||
|
|
@ -78,10 +77,10 @@ class SpacesController extends AsyncNotifier<IList<Space>> {
|
||||||
|
|
||||||
final spaces = topLevelSpaceIds.map(buildSpace).toIList();
|
final spaces = topLevelSpaceIds.map(buildSpace).toIList();
|
||||||
|
|
||||||
final usedRooms = {
|
final usedRoomIds = {
|
||||||
for (final space in spaces) ...[
|
for (final space in spaces) ...[
|
||||||
...space.children,
|
...space.children.map((r) => r.metadata?.id),
|
||||||
...space.subSpaces.expand((s) => s.children),
|
...space.subSpaces.expand((s) => s.children.map((r) => r.metadata?.id)),
|
||||||
],
|
],
|
||||||
}.nonNulls.toISet();
|
}.nonNulls.toISet();
|
||||||
|
|
||||||
|
|
@ -90,9 +89,7 @@ class SpacesController extends AsyncNotifier<IList<Space>> {
|
||||||
final otherRooms = rooms.entries
|
final otherRooms = rooms.entries
|
||||||
.where(
|
.where(
|
||||||
(e) =>
|
(e) =>
|
||||||
!usedRooms
|
!usedRoomIds.contains(e.key) &&
|
||||||
.map((element) => element.metadata?.id)
|
|
||||||
.contains(e.key) &&
|
|
||||||
!topLevelSpaceIds.contains(e.key) &&
|
!topLevelSpaceIds.contains(e.key) &&
|
||||||
!childrenById.containsKey(e.key),
|
!childrenById.containsKey(e.key),
|
||||||
)
|
)
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
@ -155,7 +143,7 @@ class SpacesController extends AsyncNotifier<IList<Space>> {
|
||||||
.toIList();
|
.toIList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider<SpacesController, IList<Space>>(
|
static final provider = NotifierProvider<SpacesController, IList<Space>>(
|
||||||
SpacesController.new,
|
SpacesController.new,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import "package:nexus/models/spec_versions_response.dart";
|
||||||
class SpecVersionsController extends AsyncNotifier<SpecVersionsResponse> {
|
class SpecVersionsController extends AsyncNotifier<SpecVersionsResponse> {
|
||||||
@override
|
@override
|
||||||
Future<SpecVersionsResponse> build() =>
|
Future<SpecVersionsResponse> build() =>
|
||||||
ref.read(ClientController.provider.notifier).getSpecVersions();
|
ref.watch(ClientController.provider.notifier).getSpecVersions();
|
||||||
|
|
||||||
static final provider =
|
static final provider =
|
||||||
AsyncNotifierProvider.autoDispose<
|
AsyncNotifierProvider.autoDispose<
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import "package:intl/intl.dart";
|
||||||
import "package:nexus/controllers/key.dart";
|
import "package:nexus/controllers/key.dart";
|
||||||
import "package:nexus/controllers/notification.dart";
|
import "package:nexus/controllers/notification.dart";
|
||||||
import "package:nexus/controllers/push_key.dart";
|
import "package:nexus/controllers/push_key.dart";
|
||||||
|
import "package:nexus/controllers/rooms.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.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";
|
||||||
|
|
@ -18,8 +19,6 @@ import "package:unifiedpush_storage_shared_preferences/storage.dart";
|
||||||
import "package:window_manager/window_manager.dart";
|
import "package:window_manager/window_manager.dart";
|
||||||
|
|
||||||
class UnifiedPushController extends AsyncNotifier<bool> {
|
class UnifiedPushController extends AsyncNotifier<bool> {
|
||||||
Completer<void>? _endpoint;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> build() async {
|
Future<bool> build() async {
|
||||||
if (!Platform.isLinux && !Platform.isAndroid) return false;
|
if (!Platform.isLinux && !Platform.isAndroid) return false;
|
||||||
|
|
@ -32,7 +31,6 @@ class UnifiedPushController extends AsyncNotifier<bool> {
|
||||||
shouldWriteService: false,
|
shouldWriteService: false,
|
||||||
),
|
),
|
||||||
onNewEndpoint: (endpoint, instance) async {
|
onNewEndpoint: (endpoint, instance) async {
|
||||||
final task = Future(() async {
|
|
||||||
final pushKey = endpoint.pubKeySet!.pubKey;
|
final pushKey = endpoint.pubKeySet!.pubKey;
|
||||||
await ref
|
await ref
|
||||||
.read(PushKeyController.provider(instance).notifier)
|
.read(PushKeyController.provider(instance).notifier)
|
||||||
|
|
@ -57,20 +55,6 @@ class UnifiedPushController extends AsyncNotifier<bool> {
|
||||||
);
|
);
|
||||||
|
|
||||||
state = .data(true);
|
state = .data(true);
|
||||||
});
|
|
||||||
|
|
||||||
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 {
|
onMessage: (message, instance) async {
|
||||||
debugPrint("UP message received for $instance");
|
debugPrint("UP message received for $instance");
|
||||||
|
|
@ -79,15 +63,12 @@ class UnifiedPushController extends AsyncNotifier<bool> {
|
||||||
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
|
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final pushResponse = await ref
|
final event = await ref
|
||||||
.read(ClientController.provider.notifier)
|
.read(ClientController.provider.notifier)
|
||||||
.handlePush(json.decode(String.fromCharCodes(message.content)));
|
.handlePush(json.decode(String.fromCharCodes(message.content)));
|
||||||
|
|
||||||
if (pushResponse == null) return;
|
if (event == null ||
|
||||||
|
event.unreadType?.shouldNotify() != true ||
|
||||||
final (event, roomMetadata) = pushResponse;
|
|
||||||
|
|
||||||
if (event.unreadType?.has(.notify) != true ||
|
|
||||||
(!isInBackground &&
|
(!isInBackground &&
|
||||||
await windowManager.isFocused().onError((_, _) => true) &&
|
await windowManager.isFocused().onError((_, _) => true) &&
|
||||||
await ref.read(
|
await ref.read(
|
||||||
|
|
@ -98,21 +79,43 @@ class UnifiedPushController extends AsyncNotifier<bool> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final icon = roomMetadata.avatar == null
|
final provider = RoomsController.provider.select(
|
||||||
|
(rooms) => rooms[event.roomId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ref.read(provider)?.metadata == null) {
|
||||||
|
final completer = Completer<void>();
|
||||||
|
|
||||||
|
final subscription = ref.listen(provider, (previous, next) {
|
||||||
|
if (next?.metadata != null && !completer.isCompleted) {
|
||||||
|
completer.complete();
|
||||||
|
}
|
||||||
|
}, fireImmediately: true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await completer.future.timeout(.new(seconds: 10));
|
||||||
|
} on TimeoutException {
|
||||||
|
// metadata didn't show up in time
|
||||||
|
} finally {
|
||||||
|
subscription.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final room = ref.read(provider);
|
||||||
|
final avatar = room?.metadata?.avatar;
|
||||||
|
final icon = avatar == null
|
||||||
? null
|
? null
|
||||||
: await ref
|
: await ref
|
||||||
.read(ClientController.provider.notifier)
|
.read(ClientController.provider.notifier)
|
||||||
.downloadMedia(
|
.downloadMedia(.new(mxc: avatar, isAvatar: true));
|
||||||
.new(mxc: roomMetadata.avatar!, isAvatar: true),
|
|
||||||
);
|
|
||||||
|
|
||||||
await ref
|
await ref
|
||||||
.read(NotificationController.provider.notifier)
|
.read(NotificationController.provider.notifier)
|
||||||
.send(
|
.send(
|
||||||
id: event.eventId.hashCode & 0x7fffffff,
|
id: event.eventId.hashCode & 0x7fffffff,
|
||||||
title: roomMetadata.name ?? "New Event",
|
title: room?.metadata?.name ?? "New Event",
|
||||||
icon: icon,
|
icon: icon,
|
||||||
payload: event.rowId.toString(),
|
payload: event.eventId,
|
||||||
body: switch (event.content) {
|
body: switch (event.content) {
|
||||||
MessageContent(:final body?) ||
|
MessageContent(:final body?) ||
|
||||||
StickerContent(:final body) => body,
|
StickerContent(:final body) => body,
|
||||||
|
|
@ -125,34 +128,27 @@ class UnifiedPushController extends AsyncNotifier<bool> {
|
||||||
onUnregistered: deregister,
|
onUnregistered: deregister,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!registered) return false;
|
ref.listen(
|
||||||
|
ClientStateController.provider.select((value) => value?.deviceId),
|
||||||
|
(_, _) => register(),
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
if (registered) {
|
||||||
return await register();
|
// Needs to be registered every startup
|
||||||
} catch (error, stackTrace) {
|
await register();
|
||||||
showError(error, stackTrace);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> register() async {
|
return registered;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> register() async {
|
||||||
state = .loading();
|
state = .loading();
|
||||||
try {
|
try {
|
||||||
final deviceIdCompleter = Completer<String>();
|
final deviceId = ref.read(
|
||||||
final subscription = ref.listen(
|
|
||||||
ClientStateController.provider.select((value) => value?.deviceId),
|
ClientStateController.provider.select((value) => value?.deviceId),
|
||||||
(_, next) {
|
|
||||||
if (next != null && !deviceIdCompleter.isCompleted) {
|
|
||||||
deviceIdCompleter.complete(next);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fireImmediately: true,
|
|
||||||
);
|
|
||||||
final deviceId = await deviceIdCompleter.future.whenComplete(
|
|
||||||
subscription.close,
|
|
||||||
);
|
);
|
||||||
|
if (deviceId == null) return;
|
||||||
|
|
||||||
await Future(() async {
|
|
||||||
final capabilities = await ref
|
final capabilities = await ref
|
||||||
.read(ClientController.provider.notifier)
|
.read(ClientController.provider.notifier)
|
||||||
.getCapabilities();
|
.getCapabilities();
|
||||||
|
|
@ -167,26 +163,10 @@ class UnifiedPushController extends AsyncNotifier<bool> {
|
||||||
throw Exception("No UnifiedPush distributors found.");
|
throw Exception("No UnifiedPush distributors found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
final endpoint = _endpoint = Completer<void>();
|
await UnifiedPush.register(
|
||||||
|
|
||||||
try {
|
|
||||||
await Future.wait([
|
|
||||||
UnifiedPush.register(
|
|
||||||
instance: deviceId,
|
instance: deviceId,
|
||||||
vapid: capabilities.webpush?.vapid,
|
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."),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
state = .data(true);
|
|
||||||
return true;
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
state = .data(false);
|
state = .data(false);
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,7 @@ import "package:unifiedpush/unifiedpush.dart";
|
||||||
class UnifiedPushAllowedController extends AsyncNotifier<String?> {
|
class UnifiedPushAllowedController extends AsyncNotifier<String?> {
|
||||||
@override
|
@override
|
||||||
Future<String?> build() async {
|
Future<String?> build() async {
|
||||||
if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor().timeout(
|
if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) {
|
||||||
.new(seconds: 10),
|
|
||||||
onTimeout: () =>
|
|
||||||
throw Exception("UnifiedPush distributor check timed out."),
|
|
||||||
)) {
|
|
||||||
return "No valid distributors found. ${Platform.isLinux
|
return "No valid distributors found. ${Platform.isLinux
|
||||||
? "Try installing KUnifiedPush"
|
? "Try installing KUnifiedPush"
|
||||||
: Platform.isAndroid
|
: Platform.isAndroid
|
||||||
|
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:material_ui/material_ui.dart";
|
|
||||||
import "package:flutter/services.dart";
|
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
|
||||||
import "package:nexus/controllers/client.dart";
|
|
||||||
import "package:nexus/controllers/pinned_ids.dart";
|
|
||||||
import "package:nexus/controllers/power_level.dart";
|
|
||||||
import "package:nexus/controllers/recent_emoji.dart";
|
|
||||||
import "package:nexus/controllers/rooms.dart";
|
|
||||||
import "package:nexus/controllers/room_chat.dart";
|
|
||||||
import "package:nexus/controllers/via.dart";
|
|
||||||
import "package:nexus/helpers/extensions/event_id_is_fake.dart";
|
|
||||||
import "package:nexus/models/content/message.dart";
|
|
||||||
import "package:nexus/models/event.dart";
|
|
||||||
import "package:nexus/models/relation_type.dart";
|
|
||||||
import "package:nexus/widgets/emoji_picker.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 notifier = RoomChatController.provider((
|
|
||||||
roomId: roomId,
|
|
||||||
contextualEvent: null,
|
|
||||||
)).notifier;
|
|
||||||
|
|
||||||
final isPinned = ref
|
|
||||||
.watch(PinnedIdsController.provider(roomId))
|
|
||||||
.contains(eventId);
|
|
||||||
|
|
||||||
void showReasonDialog({
|
|
||||||
required String title,
|
|
||||||
required String description,
|
|
||||||
required String action,
|
|
||||||
required Future<void> Function(String reason) onConfirm,
|
|
||||||
}) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => HookBuilder(
|
|
||||||
builder: (context) {
|
|
||||||
final reasonController = useTextEditingController();
|
|
||||||
|
|
||||||
return AlertDialog(
|
|
||||||
title: Text(title),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: .min,
|
|
||||||
crossAxisAlignment: .start,
|
|
||||||
children: [
|
|
||||||
Text(description),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
controller: reasonController,
|
|
||||||
textCapitalization: .sentences,
|
|
||||||
decoration: .new(labelText: "Reason (optional)"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return .new([
|
|
||||||
if (ref.watch(
|
|
||||||
PowerLevelController.provider(
|
|
||||||
.new(eventType: .reaction, roomId: roomId),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
PopupMenuItem(
|
|
||||||
enabled: false,
|
|
||||||
child: IconTheme(
|
|
||||||
data: theme.iconTheme,
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: .center,
|
|
||||||
children: [
|
|
||||||
for (final emoji in {
|
|
||||||
...ref
|
|
||||||
.watch(RecentEmojiController.provider)
|
|
||||||
.map((entry) => entry.emoji),
|
|
||||||
"👍",
|
|
||||||
"🤣",
|
|
||||||
"😭",
|
|
||||||
"🤔",
|
|
||||||
}.take(3))
|
|
||||||
IconButton(
|
|
||||||
icon: Text(emoji),
|
|
||||||
onPressed: () async {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
await ref
|
|
||||||
.watch(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();
|
|
||||||
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (context) => EmojiPicker(
|
|
||||||
allowFreeText: true,
|
|
||||||
onSelection: (emoji) {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
ref
|
|
||||||
.watch(notifier)
|
|
||||||
.sendReaction(emoji, this)
|
|
||||||
.onError(showError);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (ref.watch(
|
|
||||||
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.watch(
|
|
||||||
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.watch(
|
|
||||||
PowerLevelController.provider(
|
|
||||||
.redaction(targetUser: sender, roomId: roomId),
|
|
||||||
),
|
|
||||||
) &&
|
|
||||||
!eventId.isFake)
|
|
||||||
PopupMenuItem(
|
|
||||||
onTap: () => showReasonDialog(
|
|
||||||
title: "Delete Message",
|
|
||||||
description:
|
|
||||||
"Are you sure you want to delete this message? "
|
|
||||||
"This cannot be reversed.",
|
|
||||||
action: "Delete",
|
|
||||||
onConfirm: (reason) =>
|
|
||||||
ref.watch(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.watch(notifier).hideClientSticky(rowId),
|
|
||||||
child: ListTile(
|
|
||||||
leading: Icon(Icons.delete, color: danger),
|
|
||||||
title: Text("Hide", style: .new(color: danger)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
PopupMenuItem(
|
|
||||||
onTap: () => showReasonDialog(
|
|
||||||
title: "Report",
|
|
||||||
description:
|
|
||||||
"Report this this to your server administrators, "
|
|
||||||
"who can take action like banning this server or room.",
|
|
||||||
action: "Report",
|
|
||||||
onConfirm: (reason) => ref
|
|
||||||
.read(ClientController.provider.notifier)
|
|
||||||
.reportEvent(
|
|
||||||
.new(
|
|
||||||
roomId: roomId,
|
|
||||||
eventId: eventId,
|
|
||||||
reason: reason.isEmpty ? null : reason,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: ListTile(
|
|
||||||
leading: Icon(Icons.report, color: danger),
|
|
||||||
title: Text("Report", style: .new(color: danger)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
extension EventIdIsFake on String {
|
|
||||||
bool get isFake => startsWith("~") || startsWith("\$gomuks");
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import "package:collection/collection.dart";
|
|
||||||
import "package:flutter_riverpod/experimental/mutation.dart";
|
|
||||||
import "package:nexus/controllers/jump_request.dart";
|
|
||||||
import "package:nexus/controllers/key.dart";
|
|
||||||
import "package:nexus/controllers/spaces.dart";
|
|
||||||
import "package:nexus/models/space.dart";
|
|
||||||
|
|
||||||
extension FocusRoom on MutationTarget {
|
|
||||||
Future<bool> focusRoom(String roomId, [int? eventRowId]) async {
|
|
||||||
final spaces = await container.read(SpacesController.provider.future);
|
|
||||||
|
|
||||||
if (spaces.firstWhereOrNull((space) => space.id == roomId) case Space _?) {
|
|
||||||
await container
|
|
||||||
.read(KeyController.provider(KeyController.spaceKey).notifier)
|
|
||||||
.set(roomId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final parent = spaces.firstWhereOrNull(
|
|
||||||
(space) =>
|
|
||||||
space.children.any((room) => room.metadata?.id == roomId) ||
|
|
||||||
space.subSpaces.any(
|
|
||||||
(sub) =>
|
|
||||||
sub.room.metadata?.id == roomId ||
|
|
||||||
sub.children.any((room) => room.metadata?.id == roomId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (parent == null) return false;
|
|
||||||
|
|
||||||
await container
|
|
||||||
.read(KeyController.provider(KeyController.spaceKey).notifier)
|
|
||||||
.set(parent.id);
|
|
||||||
|
|
||||||
if (eventRowId != null) {
|
|
||||||
container
|
|
||||||
.read(JumpRequestController.provider.notifier)
|
|
||||||
.request(roomId, eventRowId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await container
|
|
||||||
.read(KeyController.provider(KeyController.roomKey).notifier)
|
|
||||||
.set(roomId);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:nexus/models/room.dart";
|
|
||||||
import "package:nexus/models/space.dart";
|
|
||||||
|
|
||||||
extension GetAllChildRooms on Space {
|
|
||||||
IList<Room> get allChildRooms =>
|
|
||||||
children.addAll(subSpaces.expand((s) => s.children));
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import "package:nexus/models/content/message.dart";
|
|
||||||
|
|
||||||
extension GetFilename on MessageContent {
|
|
||||||
String? get filename => switch (this) {
|
|
||||||
ImageMessageContent(:final filename) ||
|
|
||||||
VideoMessageContent(:final filename) ||
|
|
||||||
AudioMessageContent(:final filename) ||
|
|
||||||
FileMessageContent(:final filename) => filename,
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
|
|
||||||
/// A parsed Matrix link.
|
|
||||||
///
|
|
||||||
/// [identifier] is the room ID, room alias or user ID (e.g. "#room:matrix.org"),
|
|
||||||
/// [eventId] is set for event permalinks (e.g. "$abc123"), and [via] holds any
|
|
||||||
/// `via` servers from the link.
|
|
||||||
typedef LinkInfo = ({String identifier, String? eventId, IList<String> via});
|
|
||||||
|
|
||||||
extension GetLinkInfo on String {
|
|
||||||
/// Parses this string as a Matrix link.
|
|
||||||
///
|
|
||||||
/// Supports:
|
|
||||||
/// - https://matrix.to/#/{id}[/{eventId}][?via=...]
|
|
||||||
/// - matrix:roomid/{id}[/e/{eventId}][?via=...]
|
|
||||||
/// - matrix:r/{alias}[/e/{eventId}][?via=...]
|
|
||||||
/// - matrix:u/{userId}
|
|
||||||
///
|
|
||||||
/// Returns null if this is not a Matrix link.
|
|
||||||
LinkInfo? get linkInfo {
|
|
||||||
final uri = Uri.tryParse(trim());
|
|
||||||
if (uri == null) return null;
|
|
||||||
|
|
||||||
if ((uri.scheme == "http" || uri.scheme == "https") &&
|
|
||||||
uri.host == "matrix.to") {
|
|
||||||
// matrix.to keeps everything, including the query, in the fragment,
|
|
||||||
// so parse the fragment as its own URI.
|
|
||||||
final inner = Uri.tryParse(uri.fragment);
|
|
||||||
final segments = inner?.pathSegments.where((s) => s.isNotEmpty).toList();
|
|
||||||
if (inner == null || segments == null || segments.isEmpty) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
identifier: segments.first,
|
|
||||||
eventId: segments.length > 1 && segments[1].startsWith(r"$")
|
|
||||||
? segments[1]
|
|
||||||
: null,
|
|
||||||
via: .new(inner.queryParametersAll["via"] ?? []),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uri.scheme == "matrix") {
|
|
||||||
final segments = uri.pathSegments;
|
|
||||||
if (segments.length < 2 || segments[1].isEmpty) return null;
|
|
||||||
|
|
||||||
final sigil = switch (segments[0].toLowerCase()) {
|
|
||||||
"r" => "#",
|
|
||||||
"roomid" => "!",
|
|
||||||
"u" => "@",
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
if (sigil == null) return null;
|
|
||||||
|
|
||||||
// Event segment is "e/{eventId}" (without the "$" sigil) and is
|
|
||||||
// only valid on room links.
|
|
||||||
final hasEvent =
|
|
||||||
sigil != "@" &&
|
|
||||||
segments.length >= 4 &&
|
|
||||||
segments[2].toLowerCase() == "e" &&
|
|
||||||
segments[3].isNotEmpty;
|
|
||||||
|
|
||||||
return (
|
|
||||||
identifier: "$sigil${segments[1]}",
|
|
||||||
eventId: hasEvent ? "\$${segments[3]}" : null,
|
|
||||||
via: .new(uri.queryParametersAll["via"] ?? []),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +1,20 @@
|
||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
import "dart:ffi";
|
import "dart:ffi";
|
||||||
import "dart:typed_data";
|
import "dart:typed_data";
|
||||||
|
|
||||||
import "package:ffi/ffi.dart";
|
import "package:ffi/ffi.dart";
|
||||||
import "package:nexus/src/third_party/gomuks.g.dart";
|
import "package:nexus/src/third_party/gomuks.g.dart";
|
||||||
|
|
||||||
extension GomuksOwnedBufferToX on GomuksOwnedBuffer {
|
extension GomuksOwnedBufferToX on GomuksOwnedBuffer {
|
||||||
Uint8List toBytesAndFree() {
|
Uint8List toBytes() {
|
||||||
try {
|
try {
|
||||||
if (base == nullptr || length <= 0) return .new(0);
|
if (base == nullptr || length <= 0) return .new(0);
|
||||||
return .fromList(base.asTypedList(length));
|
return .fromList(base.asTypedList(length));
|
||||||
} finally {
|
} finally {
|
||||||
GomuksFreeBuffer(this);
|
calloc.free(base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dynamic toJsonAndFree() => jsonDecode(utf8.decode(toBytesAndFree()));
|
dynamic toJson() => jsonDecode(utf8.decode(toBytes()));
|
||||||
}
|
}
|
||||||
|
|
||||||
extension JsonToGomuksBuffer on Map<String, dynamic> {
|
extension JsonToGomuksBuffer on Map<String, dynamic> {
|
||||||
|
|
|
||||||
46
lib/helpers/extensions/link_to_mention.dart
Normal file
46
lib/helpers/extensions/link_to_mention.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
extension LinkToMention on String {
|
||||||
|
/// Extracts a Matrix identifier from this string.
|
||||||
|
///
|
||||||
|
/// Supports:
|
||||||
|
/// - https://matrix.to/#/...
|
||||||
|
/// - matrix:roomid/...
|
||||||
|
/// - matrix:r/...
|
||||||
|
/// - matrix:u/...
|
||||||
|
///
|
||||||
|
/// Returns the decoded identifier (e.g. "#room:matrix.org")
|
||||||
|
/// or null if this is not a Matrix link.
|
||||||
|
/// TODO: Needs to be reworked to handle event links. Might be worth just rewriting, I don't like this code.
|
||||||
|
String? get mention {
|
||||||
|
final trimmed = trim();
|
||||||
|
|
||||||
|
final matrixTo = RegExp(
|
||||||
|
r"^https?://matrix\.to/#/(.[^/?#]+)",
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final matrixToMatch = matrixTo.firstMatch(trimmed);
|
||||||
|
if (matrixToMatch != null) {
|
||||||
|
return Uri.decodeComponent(matrixToMatch.group(1)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.toLowerCase().startsWith("matrix:")) {
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse(trimmed);
|
||||||
|
|
||||||
|
if (uri.pathSegments.isNotEmpty) {
|
||||||
|
final identifier = uri.pathSegments.last;
|
||||||
|
if (identifier.isNotEmpty) {
|
||||||
|
return "${switch (uri.pathSegments.firstOrNull) {
|
||||||
|
"r" => "#",
|
||||||
|
"roomid" => "!",
|
||||||
|
"u" => "@",
|
||||||
|
_ => "",
|
||||||
|
}}${Uri.decodeComponent(identifier)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,7 @@ import "package:material_ui/material_ui.dart";
|
||||||
extension SchemeToTheme on ColorScheme {
|
extension SchemeToTheme on ColorScheme {
|
||||||
ThemeData get theme {
|
ThemeData get theme {
|
||||||
final textTheme = ThemeData(
|
final textTheme = ThemeData(
|
||||||
fontFamily: "bundled-sans",
|
fontFamilyFallback: ["sans", "emoji", "fallback-sans", "fallback-emoji"],
|
||||||
fontFamilyFallback: ["bundled-emoji"],
|
|
||||||
brightness: brightness,
|
brightness: brightness,
|
||||||
).textTheme;
|
).textTheme;
|
||||||
return .from(colorScheme: this).copyWith(
|
return .from(colorScheme: this).copyWith(
|
||||||
|
|
@ -28,7 +27,6 @@ extension SchemeToTheme on ColorScheme {
|
||||||
borderRadius: .circular(8),
|
borderRadius: .circular(8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
iconButtonTheme: .new(variant: StyleVariant.material3Expressive),
|
|
||||||
textTheme: textTheme,
|
textTheme: textTheme,
|
||||||
inputDecorationTheme: const InputDecorationTheme(
|
inputDecorationTheme: const InputDecorationTheme(
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,7 @@ extension ShowAboutDialog on BuildContext {
|
||||||
"Nexus",
|
"Nexus",
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
),
|
),
|
||||||
Text(
|
Text("(${packageInfo.version})"),
|
||||||
"(${packageInfo.version}+${const String.fromEnvironment("GIT_COMMIT", defaultValue: "dev")})",
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import "package:flutter/foundation.dart";
|
import "package:flutter/foundation.dart";
|
||||||
|
|
||||||
const sil = """This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
const fontLicenses = [
|
||||||
|
LicenseEntryWithLineBreaks(
|
||||||
|
["Noto Color Emoji"],
|
||||||
|
"""Copyright 2021 Google Inc. All Rights Reserved.
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
This license is copied below, and is also available with a FAQ at:
|
This license is copied below, and is also available with a FAQ at:
|
||||||
https://openfontlicense.org
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
@ -90,426 +95,102 @@ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.""";
|
OTHER DEALINGS IN THE FONT SOFTWARE.""",
|
||||||
|
|
||||||
const List<LicenseEntryWithLineBreaks> fontLicenses = [
|
|
||||||
.new(
|
|
||||||
["Twitter Color Emoji"],
|
|
||||||
"""Twitter Color Emoji is built from Twemoji (https://github.com/jdecked/twemoji)
|
|
||||||
using the Noto Emoji as fallback.
|
|
||||||
Its parts are under different licenses:
|
|
||||||
|
|
||||||
1) Emoji artwork: Copyright Twitter, Inc and other contributors.
|
|
||||||
Attribution 4.0 International
|
|
||||||
|
|
||||||
=======================================================================
|
|
||||||
|
|
||||||
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
|
||||||
does not provide legal services or legal advice. Distribution of
|
|
||||||
Creative Commons public licenses does not create a lawyer-client or
|
|
||||||
other relationship. Creative Commons makes its licenses and related
|
|
||||||
information available on an "as-is" basis. Creative Commons gives no
|
|
||||||
warranties regarding its licenses, any material licensed under their
|
|
||||||
terms and conditions, or any related information. Creative Commons
|
|
||||||
disclaims all liability for damages resulting from their use to the
|
|
||||||
fullest extent possible.
|
|
||||||
|
|
||||||
Using Creative Commons Public Licenses
|
|
||||||
|
|
||||||
Creative Commons public licenses provide a standard set of terms and
|
|
||||||
conditions that creators and other rights holders may use to share
|
|
||||||
original works of authorship and other material subject to copyright
|
|
||||||
and certain other rights specified in the public license below. The
|
|
||||||
following considerations are for informational purposes only, are not
|
|
||||||
exhaustive, and do not form part of our licenses.
|
|
||||||
|
|
||||||
Considerations for licensors: Our public licenses are
|
|
||||||
intended for use by those authorized to give the public
|
|
||||||
permission to use material in ways otherwise restricted by
|
|
||||||
copyright and certain other rights. Our licenses are
|
|
||||||
irrevocable. Licensors should read and understand the terms
|
|
||||||
and conditions of the license they choose before applying it.
|
|
||||||
Licensors should also secure all rights necessary before
|
|
||||||
applying our licenses so that the public can reuse the
|
|
||||||
material as expected. Licensors should clearly mark any
|
|
||||||
material not subject to the license. This includes other CC-
|
|
||||||
licensed material, or material used under an exception or
|
|
||||||
limitation to copyright. More considerations for licensors:
|
|
||||||
wiki.creativecommons.org/Considerations_for_licensors
|
|
||||||
|
|
||||||
Considerations for the public: By using one of our public
|
|
||||||
licenses, a licensor grants the public permission to use the
|
|
||||||
licensed material under specified terms and conditions. If
|
|
||||||
the licensor's permission is not necessary for any reason--for
|
|
||||||
example, because of any applicable exception or limitation to
|
|
||||||
copyright--then that use is not regulated by the license. Our
|
|
||||||
licenses grant only permissions under copyright and certain
|
|
||||||
other rights that a licensor has authority to grant. Use of
|
|
||||||
the licensed material may still be restricted for other
|
|
||||||
reasons, including because others have copyright or other
|
|
||||||
rights in the material. A licensor may make special requests,
|
|
||||||
such as asking that all changes be marked or described.
|
|
||||||
Although not required by our licenses, you are encouraged to
|
|
||||||
respect those requests where reasonable. More_considerations
|
|
||||||
for the public:
|
|
||||||
wiki.creativecommons.org/Considerations_for_licensees
|
|
||||||
|
|
||||||
=======================================================================
|
|
||||||
|
|
||||||
Creative Commons Attribution 4.0 International Public License
|
|
||||||
|
|
||||||
By exercising the Licensed Rights (defined below), You accept and agree
|
|
||||||
to be bound by the terms and conditions of this Creative Commons
|
|
||||||
Attribution 4.0 International Public License ("Public License"). To the
|
|
||||||
extent this Public License may be interpreted as a contract, You are
|
|
||||||
granted the Licensed Rights in consideration of Your acceptance of
|
|
||||||
these terms and conditions, and the Licensor grants You such rights in
|
|
||||||
consideration of benefits the Licensor receives from making the
|
|
||||||
Licensed Material available under these terms and conditions.
|
|
||||||
|
|
||||||
|
|
||||||
Section 1 -- Definitions.
|
|
||||||
|
|
||||||
a. Adapted Material means material subject to Copyright and Similar
|
|
||||||
Rights that is derived from or based upon the Licensed Material
|
|
||||||
and in which the Licensed Material is translated, altered,
|
|
||||||
arranged, transformed, or otherwise modified in a manner requiring
|
|
||||||
permission under the Copyright and Similar Rights held by the
|
|
||||||
Licensor. For purposes of this Public License, where the Licensed
|
|
||||||
Material is a musical work, performance, or sound recording,
|
|
||||||
Adapted Material is always produced where the Licensed Material is
|
|
||||||
synched in timed relation with a moving image.
|
|
||||||
|
|
||||||
b. Adapter's License means the license You apply to Your Copyright
|
|
||||||
and Similar Rights in Your contributions to Adapted Material in
|
|
||||||
accordance with the terms and conditions of this Public License.
|
|
||||||
|
|
||||||
c. Copyright and Similar Rights means copyright and/or similar rights
|
|
||||||
closely related to copyright including, without limitation,
|
|
||||||
performance, broadcast, sound recording, and Sui Generis Database
|
|
||||||
Rights, without regard to how the rights are labeled or
|
|
||||||
categorized. For purposes of this Public License, the rights
|
|
||||||
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
|
||||||
Rights.
|
|
||||||
|
|
||||||
d. Effective Technological Measures means those measures that, in the
|
|
||||||
absence of proper authority, may not be circumvented under laws
|
|
||||||
fulfilling obligations under Article 11 of the WIPO Copyright
|
|
||||||
Treaty adopted on December 20, 1996, and/or similar international
|
|
||||||
agreements.
|
|
||||||
|
|
||||||
e. Exceptions and Limitations means fair use, fair dealing, and/or
|
|
||||||
any other exception or limitation to Copyright and Similar Rights
|
|
||||||
that applies to Your use of the Licensed Material.
|
|
||||||
|
|
||||||
f. Licensed Material means the artistic or literary work, database,
|
|
||||||
or other material to which the Licensor applied this Public
|
|
||||||
License.
|
|
||||||
|
|
||||||
g. Licensed Rights means the rights granted to You subject to the
|
|
||||||
terms and conditions of this Public License, which are limited to
|
|
||||||
all Copyright and Similar Rights that apply to Your use of the
|
|
||||||
Licensed Material and that the Licensor has authority to license.
|
|
||||||
|
|
||||||
h. Licensor means the individual(s) or entity(ies) granting rights
|
|
||||||
under this Public License.
|
|
||||||
|
|
||||||
i. Share means to provide material to the public by any means or
|
|
||||||
process that requires permission under the Licensed Rights, such
|
|
||||||
as reproduction, public display, public performance, distribution,
|
|
||||||
dissemination, communication, or importation, and to make material
|
|
||||||
available to the public including in ways that members of the
|
|
||||||
public may access the material from a place and at a time
|
|
||||||
individually chosen by them.
|
|
||||||
|
|
||||||
j. Sui Generis Database Rights means rights other than copyright
|
|
||||||
resulting from Directive 96/9/EC of the European Parliament and of
|
|
||||||
the Council of 11 March 1996 on the legal protection of databases,
|
|
||||||
as amended and/or succeeded, as well as other essentially
|
|
||||||
equivalent rights anywhere in the world.
|
|
||||||
|
|
||||||
k. You means the individual or entity exercising the Licensed Rights
|
|
||||||
under this Public License. Your has a corresponding meaning.
|
|
||||||
|
|
||||||
|
|
||||||
Section 2 -- Scope.
|
|
||||||
|
|
||||||
a. License grant.
|
|
||||||
|
|
||||||
1. Subject to the terms and conditions of this Public License,
|
|
||||||
the Licensor hereby grants You a worldwide, royalty-free,
|
|
||||||
non-sublicensable, non-exclusive, irrevocable license to
|
|
||||||
exercise the Licensed Rights in the Licensed Material to:
|
|
||||||
|
|
||||||
a. reproduce and Share the Licensed Material, in whole or
|
|
||||||
in part; and
|
|
||||||
|
|
||||||
b. produce, reproduce, and Share Adapted Material.
|
|
||||||
|
|
||||||
2. Exceptions and Limitations. For the avoidance of doubt, where
|
|
||||||
Exceptions and Limitations apply to Your use, this Public
|
|
||||||
License does not apply, and You do not need to comply with
|
|
||||||
its terms and conditions.
|
|
||||||
|
|
||||||
3. Term. The term of this Public License is specified in Section
|
|
||||||
6(a).
|
|
||||||
|
|
||||||
4. Media and formats; technical modifications allowed. The
|
|
||||||
Licensor authorizes You to exercise the Licensed Rights in
|
|
||||||
all media and formats whether now known or hereafter created,
|
|
||||||
and to make technical modifications necessary to do so. The
|
|
||||||
Licensor waives and/or agrees not to assert any right or
|
|
||||||
authority to forbid You from making technical modifications
|
|
||||||
necessary to exercise the Licensed Rights, including
|
|
||||||
technical modifications necessary to circumvent Effective
|
|
||||||
Technological Measures. For purposes of this Public License,
|
|
||||||
simply making modifications authorized by this Section 2(a)
|
|
||||||
(4) never produces Adapted Material.
|
|
||||||
|
|
||||||
5. Downstream recipients.
|
|
||||||
|
|
||||||
a. Offer from the Licensor -- Licensed Material. Every
|
|
||||||
recipient of the Licensed Material automatically
|
|
||||||
receives an offer from the Licensor to exercise the
|
|
||||||
Licensed Rights under the terms and conditions of this
|
|
||||||
Public License.
|
|
||||||
|
|
||||||
b. No downstream restrictions. You may not offer or impose
|
|
||||||
any additional or different terms or conditions on, or
|
|
||||||
apply any Effective Technological Measures to, the
|
|
||||||
Licensed Material if doing so restricts exercise of the
|
|
||||||
Licensed Rights by any recipient of the Licensed
|
|
||||||
Material.
|
|
||||||
|
|
||||||
6. No endorsement. Nothing in this Public License constitutes or
|
|
||||||
may be construed as permission to assert or imply that You
|
|
||||||
are, or that Your use of the Licensed Material is, connected
|
|
||||||
with, or sponsored, endorsed, or granted official status by,
|
|
||||||
the Licensor or others designated to receive attribution as
|
|
||||||
provided in Section 3(a)(1)(A)(i).
|
|
||||||
|
|
||||||
b. Other rights.
|
|
||||||
|
|
||||||
1. Moral rights, such as the right of integrity, are not
|
|
||||||
licensed under this Public License, nor are publicity,
|
|
||||||
privacy, and/or other similar personality rights; however, to
|
|
||||||
the extent possible, the Licensor waives and/or agrees not to
|
|
||||||
assert any such rights held by the Licensor to the limited
|
|
||||||
extent necessary to allow You to exercise the Licensed
|
|
||||||
Rights, but not otherwise.
|
|
||||||
|
|
||||||
2. Patent and trademark rights are not licensed under this
|
|
||||||
Public License.
|
|
||||||
|
|
||||||
3. To the extent possible, the Licensor waives any right to
|
|
||||||
collect royalties from You for the exercise of the Licensed
|
|
||||||
Rights, whether directly or through a collecting society
|
|
||||||
under any voluntary or waivable statutory or compulsory
|
|
||||||
licensing scheme. In all other cases the Licensor expressly
|
|
||||||
reserves any right to collect such royalties.
|
|
||||||
|
|
||||||
|
|
||||||
Section 3 -- License Conditions.
|
|
||||||
|
|
||||||
Your exercise of the Licensed Rights is expressly made subject to the
|
|
||||||
following conditions.
|
|
||||||
|
|
||||||
a. Attribution.
|
|
||||||
|
|
||||||
1. If You Share the Licensed Material (including in modified
|
|
||||||
form), You must:
|
|
||||||
|
|
||||||
a. retain the following if it is supplied by the Licensor
|
|
||||||
with the Licensed Material:
|
|
||||||
|
|
||||||
i. identification of the creator(s) of the Licensed
|
|
||||||
Material and any others designated to receive
|
|
||||||
attribution, in any reasonable manner requested by
|
|
||||||
the Licensor (including by pseudonym if
|
|
||||||
designated);
|
|
||||||
|
|
||||||
ii. a copyright notice;
|
|
||||||
|
|
||||||
iii. a notice that refers to this Public License;
|
|
||||||
|
|
||||||
iv. a notice that refers to the disclaimer of
|
|
||||||
warranties;
|
|
||||||
|
|
||||||
v. a URI or hyperlink to the Licensed Material to the
|
|
||||||
extent reasonably practicable;
|
|
||||||
|
|
||||||
b. indicate if You modified the Licensed Material and
|
|
||||||
retain an indication of any previous modifications; and
|
|
||||||
|
|
||||||
c. indicate the Licensed Material is licensed under this
|
|
||||||
Public License, and include the text of, or the URI or
|
|
||||||
hyperlink to, this Public License.
|
|
||||||
|
|
||||||
2. You may satisfy the conditions in Section 3(a)(1) in any
|
|
||||||
reasonable manner based on the medium, means, and context in
|
|
||||||
which You Share the Licensed Material. For example, it may be
|
|
||||||
reasonable to satisfy the conditions by providing a URI or
|
|
||||||
hyperlink to a resource that includes the required
|
|
||||||
information.
|
|
||||||
|
|
||||||
3. If requested by the Licensor, You must remove any of the
|
|
||||||
information required by Section 3(a)(1)(A) to the extent
|
|
||||||
reasonably practicable.
|
|
||||||
|
|
||||||
4. If You Share Adapted Material You produce, the Adapter's
|
|
||||||
License You apply must not prevent recipients of the Adapted
|
|
||||||
Material from complying with this Public License.
|
|
||||||
|
|
||||||
|
|
||||||
Section 4 -- Sui Generis Database Rights.
|
|
||||||
|
|
||||||
Where the Licensed Rights include Sui Generis Database Rights that
|
|
||||||
apply to Your use of the Licensed Material:
|
|
||||||
|
|
||||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
|
||||||
to extract, reuse, reproduce, and Share all or a substantial
|
|
||||||
portion of the contents of the database;
|
|
||||||
|
|
||||||
b. if You include all or a substantial portion of the database
|
|
||||||
contents in a database in which You have Sui Generis Database
|
|
||||||
Rights, then the database in which You have Sui Generis Database
|
|
||||||
Rights (but not its individual contents) is Adapted Material; and
|
|
||||||
|
|
||||||
c. You must comply with the conditions in Section 3(a) if You Share
|
|
||||||
all or a substantial portion of the contents of the database.
|
|
||||||
|
|
||||||
For the avoidance of doubt, this Section 4 supplements and does not
|
|
||||||
replace Your obligations under this Public License where the Licensed
|
|
||||||
Rights include other Copyright and Similar Rights.
|
|
||||||
|
|
||||||
|
|
||||||
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
|
||||||
|
|
||||||
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
|
||||||
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
|
||||||
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
|
||||||
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
|
||||||
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
|
||||||
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
|
||||||
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
|
||||||
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
|
||||||
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
|
||||||
|
|
||||||
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
|
||||||
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
|
||||||
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
|
||||||
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
|
||||||
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
|
||||||
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
|
||||||
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
|
||||||
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
|
||||||
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
|
||||||
|
|
||||||
c. The disclaimer of warranties and limitation of liability provided
|
|
||||||
above shall be interpreted in a manner that, to the extent
|
|
||||||
possible, most closely approximates an absolute disclaimer and
|
|
||||||
waiver of all liability.
|
|
||||||
|
|
||||||
|
|
||||||
Section 6 -- Term and Termination.
|
|
||||||
|
|
||||||
a. This Public License applies for the term of the Copyright and
|
|
||||||
Similar Rights licensed here. However, if You fail to comply with
|
|
||||||
this Public License, then Your rights under this Public License
|
|
||||||
terminate automatically.
|
|
||||||
|
|
||||||
b. Where Your right to use the Licensed Material has terminated under
|
|
||||||
Section 6(a), it reinstates:
|
|
||||||
|
|
||||||
1. automatically as of the date the violation is cured, provided
|
|
||||||
it is cured within 30 days of Your discovery of the
|
|
||||||
violation; or
|
|
||||||
|
|
||||||
2. upon express reinstatement by the Licensor.
|
|
||||||
|
|
||||||
For the avoidance of doubt, this Section 6(b) does not affect any
|
|
||||||
right the Licensor may have to seek remedies for Your violations
|
|
||||||
of this Public License.
|
|
||||||
|
|
||||||
c. For the avoidance of doubt, the Licensor may also offer the
|
|
||||||
Licensed Material under separate terms or conditions or stop
|
|
||||||
distributing the Licensed Material at any time; however, doing so
|
|
||||||
will not terminate this Public License.
|
|
||||||
|
|
||||||
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
|
||||||
License.
|
|
||||||
|
|
||||||
|
|
||||||
Section 7 -- Other Terms and Conditions.
|
|
||||||
|
|
||||||
a. The Licensor shall not be bound by any additional or different
|
|
||||||
terms or conditions communicated by You unless expressly agreed.
|
|
||||||
|
|
||||||
b. Any arrangements, understandings, or agreements regarding the
|
|
||||||
Licensed Material not stated herein are separate from and
|
|
||||||
independent of the terms and conditions of this Public License.
|
|
||||||
|
|
||||||
|
|
||||||
Section 8 -- Interpretation.
|
|
||||||
|
|
||||||
a. For the avoidance of doubt, this Public License does not, and
|
|
||||||
shall not be interpreted to, reduce, limit, restrict, or impose
|
|
||||||
conditions on any use of the Licensed Material that could lawfully
|
|
||||||
be made without permission under this Public License.
|
|
||||||
|
|
||||||
b. To the extent possible, if any provision of this Public License is
|
|
||||||
deemed unenforceable, it shall be automatically reformed to the
|
|
||||||
minimum extent necessary to make it enforceable. If the provision
|
|
||||||
cannot be reformed, it shall be severed from this Public License
|
|
||||||
without affecting the enforceability of the remaining terms and
|
|
||||||
conditions.
|
|
||||||
|
|
||||||
c. No term or condition of this Public License will be waived and no
|
|
||||||
failure to comply consented to unless expressly agreed to by the
|
|
||||||
Licensor.
|
|
||||||
|
|
||||||
d. Nothing in this Public License constitutes or may be interpreted
|
|
||||||
as a limitation upon, or waiver of, any privileges and immunities
|
|
||||||
that apply to the Licensor or You, including from the legal
|
|
||||||
processes of any jurisdiction or authority.
|
|
||||||
|
|
||||||
|
|
||||||
=======================================================================
|
|
||||||
|
|
||||||
Creative Commons is not a party to its public licenses.
|
|
||||||
Notwithstanding, Creative Commons may elect to apply one of its public
|
|
||||||
licenses to material it publishes and in those instances will be
|
|
||||||
considered the "Licensor." Except for the limited purpose of indicating
|
|
||||||
that material is shared under a Creative Commons public license or as
|
|
||||||
otherwise permitted by the Creative Commons policies published at
|
|
||||||
creativecommons.org/policies, Creative Commons does not authorize the
|
|
||||||
use of the trademark "Creative Commons" or any other trademark or logo
|
|
||||||
of Creative Commons without its prior written consent including,
|
|
||||||
without limitation, in connection with any unauthorized modifications
|
|
||||||
to any of its public licenses or any other arrangements,
|
|
||||||
understandings, or agreements concerning use of licensed material. For
|
|
||||||
the avoidance of doubt, this paragraph does not form part of the public
|
|
||||||
licenses.
|
|
||||||
|
|
||||||
Creative Commons may be contacted at creativecommons.org.
|
|
||||||
|
|
||||||
2) Font files and font tooling (Noto Emoji): SIL Open Font License, Version 1.1.
|
|
||||||
Copyright 2021 Google Inc. All Rights Reserved
|
|
||||||
$sil
|
|
||||||
""",
|
|
||||||
),
|
),
|
||||||
.new(
|
LicenseEntryWithLineBreaks(
|
||||||
["Inter"],
|
["Roboto"],
|
||||||
"""Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
"""Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
|
||||||
Modified for use in this application by removing selected Unicode character mappings.
|
|
||||||
|
|
||||||
$sil""",
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
),
|
This license is copied below, and is also available with a FAQ at:
|
||||||
.new(
|
https://openfontlicense.org
|
||||||
["Atkinson Hyperlegible Mono"],
|
|
||||||
"""Copyright 2020-2024 The Atkinson Hyperlegible Mono Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next-mono)
|
|
||||||
|
|
||||||
$sil""",
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.""",
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,207 +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/models/direction.dart";
|
|
||||||
import "package:nexus/models/room_chat.dart";
|
|
||||||
|
|
||||||
typedef JumpToEvent = Future<void> Function(int rowId);
|
|
||||||
|
|
||||||
final class ChatScroll({
|
|
||||||
required final IList<int> historyRows,
|
|
||||||
required final IList<int> liveRows,
|
|
||||||
required final GlobalKey centerKey,
|
|
||||||
required final ScrollController scrollController,
|
|
||||||
required final bool atBottom,
|
|
||||||
required final JumpToEvent jumpToEvent,
|
|
||||||
required final Future<void> Function() jumpToBottom,
|
|
||||||
required final GlobalKey Function(int eventRowId) keyFor,
|
|
||||||
}) {
|
|
||||||
factory use({
|
|
||||||
required AsyncValue<RoomChat?> controllerData,
|
|
||||||
required Future<void> Function(Direction direction) paginate,
|
|
||||||
required Future<void> Function() markRead,
|
|
||||||
required ValueNotifier<int?> contextualEvent,
|
|
||||||
}) {
|
|
||||||
final anchorId = useState<int?>(null);
|
|
||||||
final atBottom = useState(true);
|
|
||||||
final pendingJump = useState<int?>(null);
|
|
||||||
final scrollController = useScrollController();
|
|
||||||
final centerKey = useMemoized(GlobalKey.new);
|
|
||||||
|
|
||||||
final itemKeys = useMemoized(() => <int, GlobalKey>{}, []);
|
|
||||||
GlobalKey keyFor(int eventRowId) =>
|
|
||||||
itemKeys.putIfAbsent(eventRowId, GlobalKey.new);
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
if (anchorId.value == null) {
|
|
||||||
if (controllerData case AsyncData(:final value?)
|
|
||||||
when value.timeline.isNotEmpty) {
|
|
||||||
anchorId.value = value.timeline.contains(contextualEvent.value)
|
|
||||||
? contextualEvent.value
|
|
||||||
: value.timeline.last;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [controllerData, contextualEvent.value]);
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
final rowId = pendingJump.value;
|
|
||||||
if (rowId == null || anchorId.value != rowId) return null;
|
|
||||||
pendingJump.value = null;
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
||||||
if (!scrollController.hasClients) return;
|
|
||||||
|
|
||||||
final position = scrollController.position;
|
|
||||||
scrollController.jumpTo(
|
|
||||||
(-position.viewportDimension)
|
|
||||||
.clamp(position.minScrollExtent, position.maxScrollExtent)
|
|
||||||
.toDouble(),
|
|
||||||
);
|
|
||||||
await WidgetsBinding.instance.endOfFrame;
|
|
||||||
|
|
||||||
final context = keyFor(rowId).currentContext;
|
|
||||||
if (context?.mounted == true) {
|
|
||||||
await Scrollable.ensureVisible(
|
|
||||||
context!,
|
|
||||||
alignment: 0.5,
|
|
||||||
duration: const .new(milliseconds: 700),
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [anchorId.value, pendingJump.value]);
|
|
||||||
|
|
||||||
final ({IList<int> history, IList<int> live}) split = useMemoized(() {
|
|
||||||
final items = controllerData.value?.timeline;
|
|
||||||
final anchor = anchorId.value;
|
|
||||||
|
|
||||||
if (items == null || anchor == null) {
|
|
||||||
return (history: const .empty(), live: const .empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
final anchorIndex = items.indexOf(anchor);
|
|
||||||
|
|
||||||
if (anchorIndex == -1) return (history: const .empty(), live: items);
|
|
||||||
|
|
||||||
return (
|
|
||||||
history: items.take(anchorIndex).toIList().reversed.toIList(),
|
|
||||||
live: items.skip(anchorIndex).toIList(),
|
|
||||||
);
|
|
||||||
}, [controllerData, anchorId.value]);
|
|
||||||
|
|
||||||
final lastRowId = split.live.lastOrNull;
|
|
||||||
final previousLastRowId = useRef<int?>(null);
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
final previous = previousLastRowId.value;
|
|
||||||
previousLastRowId.value = lastRowId;
|
|
||||||
|
|
||||||
if (previous == null || lastRowId == null || previous == lastRowId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!atBottom.value || contextualEvent.value != null) return null;
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!scrollController.hasClients) return;
|
|
||||||
|
|
||||||
scrollController.jumpTo(scrollController.position.minScrollExtent);
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [lastRowId]);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() {
|
|
||||||
const loadThreshold = 500.0;
|
|
||||||
|
|
||||||
Future<void> checkPosition() async {
|
|
||||||
if (!scrollController.hasClients) return;
|
|
||||||
|
|
||||||
final position = scrollController.position;
|
|
||||||
|
|
||||||
final isAtBottom = position.extentBefore <= 0;
|
|
||||||
if (isAtBottom != atBottom.value) atBottom.value = isAtBottom;
|
|
||||||
|
|
||||||
if (position.extentAfter <= loadThreshold) {
|
|
||||||
await paginate(.backward);
|
|
||||||
} else if (contextualEvent.value != null &&
|
|
||||||
position.extentBefore <= loadThreshold) {
|
|
||||||
await paginate(.forward);
|
|
||||||
} else if (position.extentBefore <= 0) {
|
|
||||||
await markRead();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (controllerData.isLoading == false &&
|
|
||||||
position.extentBefore <= 0 &&
|
|
||||||
controllerData.value?.hasMoreForward == false &&
|
|
||||||
contextualEvent.value != null) {
|
|
||||||
anchorId.value = null;
|
|
||||||
contextualEvent.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollController.addListener(checkPosition);
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition());
|
|
||||||
|
|
||||||
return () => scrollController.removeListener(checkPosition);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
scrollController,
|
|
||||||
controllerData,
|
|
||||||
paginate,
|
|
||||||
markRead,
|
|
||||||
contextualEvent.value,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return .new(
|
|
||||||
historyRows: split.history,
|
|
||||||
liveRows: split.live,
|
|
||||||
centerKey: centerKey,
|
|
||||||
scrollController: scrollController,
|
|
||||||
atBottom: atBottom.value,
|
|
||||||
keyFor: keyFor,
|
|
||||||
jumpToEvent: (int rowId) async {
|
|
||||||
if (!scrollController.hasClients) return;
|
|
||||||
|
|
||||||
final context = keyFor(rowId).currentContext;
|
|
||||||
if (context != null) {
|
|
||||||
await Scrollable.ensureVisible(
|
|
||||||
context,
|
|
||||||
alignment: 0.5,
|
|
||||||
duration: const .new(milliseconds: 700),
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingJump.value = rowId;
|
|
||||||
if (controllerData.value?.timeline.contains(rowId) ?? false) {
|
|
||||||
anchorId.value = rowId;
|
|
||||||
} else {
|
|
||||||
anchorId.value = null;
|
|
||||||
contextualEvent.value = rowId;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
jumpToBottom: () async {
|
|
||||||
if (contextualEvent.value != null) {
|
|
||||||
anchorId.value = null;
|
|
||||||
contextualEvent.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!scrollController.hasClients) return;
|
|
||||||
|
|
||||||
await scrollController.animateTo(
|
|
||||||
scrollController.position.minScrollExtent,
|
|
||||||
duration: const .new(milliseconds: 700),
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,10 +3,9 @@ import "dart:io";
|
||||||
import "package:dynamic_color/dynamic_color.dart";
|
import "package:dynamic_color/dynamic_color.dart";
|
||||||
import "package:flutter/foundation.dart";
|
import "package:flutter/foundation.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:material_emoji_picker/material_emoji_picker.dart";
|
|
||||||
import "package:media_kit/media_kit.dart";
|
import "package:media_kit/media_kit.dart";
|
||||||
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/controllers/client_state.dart";
|
import "package:nexus/controllers/client_state.dart";
|
||||||
import "package:nexus/controllers/gomuks_listener.dart";
|
|
||||||
import "package:nexus/controllers/key.dart";
|
import "package:nexus/controllers/key.dart";
|
||||||
import "package:nexus/controllers/member_list_opened.dart";
|
import "package:nexus/controllers/member_list_opened.dart";
|
||||||
import "package:nexus/controllers/multi_provider.dart";
|
import "package:nexus/controllers/multi_provider.dart";
|
||||||
|
|
@ -15,10 +14,10 @@ import "package:nexus/controllers/settings.dart";
|
||||||
import "package:nexus/controllers/unified_push.dart";
|
import "package:nexus/controllers/unified_push.dart";
|
||||||
import "package:nexus/helpers/extensions/scheme_to_theme.dart";
|
import "package:nexus/helpers/extensions/scheme_to_theme.dart";
|
||||||
import "package:nexus/helpers/font_licenses.dart";
|
import "package:nexus/helpers/font_licenses.dart";
|
||||||
import "package:nexus/widgets/pages/chat.dart";
|
import "package:nexus/pages/chat.dart";
|
||||||
import "package:nexus/widgets/pages/select_server.dart";
|
import "package:nexus/pages/select_server.dart";
|
||||||
import "package:nexus/widgets/pages/settings.dart";
|
import "package:nexus/pages/settings.dart";
|
||||||
import "package:nexus/widgets/pages/verify.dart";
|
import "package:nexus/pages/verify.dart";
|
||||||
import "package:nexus/widgets/appbar.dart";
|
import "package:nexus/widgets/appbar.dart";
|
||||||
import "package:nexus/widgets/error_dialog.dart";
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
import "package:nexus/widgets/loading.dart";
|
import "package:nexus/widgets/loading.dart";
|
||||||
|
|
@ -43,7 +42,15 @@ New Value: ${newValue is AsyncData ? newValue.value : newValue}
|
||||||
}
|
}
|
||||||
|
|
||||||
void showError(Object error, [StackTrace? stackTrace]) {
|
void showError(Object error, [StackTrace? stackTrace]) {
|
||||||
if (error.toString().contains("'_nextFrame != null': is not true.")) {
|
if (error.toString().contains("DioException") ||
|
||||||
|
error.toString().contains(
|
||||||
|
"setState() or markNeedsBuild() called during build.",
|
||||||
|
) ||
|
||||||
|
error.toString().contains("Invalid source") ||
|
||||||
|
error.toString().contains("UTF-16") ||
|
||||||
|
error.toString().contains("HTTP request failed") ||
|
||||||
|
error.toString().contains("'_nextFrame != null': is not true.") ||
|
||||||
|
error.toString().contains("Invalid image data")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,23 +71,6 @@ void main(List<String> args) async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
|
|
||||||
LicenseRegistry.addLicense(() => .fromIterable(fontLicenses));
|
|
||||||
|
|
||||||
FlutterError.onError = (FlutterErrorDetails details) =>
|
|
||||||
showError(details.exception.toString(), details.stack);
|
|
||||||
|
|
||||||
isInBackground =
|
|
||||||
Platform.environment["FLUTTER_HEADLESS"] != null ||
|
|
||||||
args.contains("--unifiedpush-bg");
|
|
||||||
|
|
||||||
if (isInBackground) {
|
|
||||||
await ProviderContainer()
|
|
||||||
.read(UnifiedPushController.provider.future)
|
|
||||||
.timeout(Duration(seconds: 5));
|
|
||||||
|
|
||||||
await Future.delayed(Duration(seconds: 10));
|
|
||||||
exit(0);
|
|
||||||
} else {
|
|
||||||
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
|
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
|
||||||
await windowManager.ensureInitialized();
|
await windowManager.ensureInitialized();
|
||||||
await windowManager.waitUntilReadyToShow(
|
await windowManager.waitUntilReadyToShow(
|
||||||
|
|
@ -92,6 +82,22 @@ void main(List<String> args) async {
|
||||||
await windowManager.setMinimumSize(Size.square(500));
|
await windowManager.setMinimumSize(Size.square(500));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isInBackground =
|
||||||
|
Platform.environment["FLUTTER_HEADLESS"] != null ||
|
||||||
|
args.contains("--unifiedpush-bg");
|
||||||
|
|
||||||
|
LicenseRegistry.addLicense(() => .fromIterable(fontLicenses));
|
||||||
|
|
||||||
|
FlutterError.onError = (FlutterErrorDetails details) =>
|
||||||
|
showError(details.exception.toString(), details.stack);
|
||||||
|
|
||||||
|
if (isInBackground) {
|
||||||
|
await ProviderContainer().read(UnifiedPushController.provider.future);
|
||||||
|
|
||||||
|
// In case it didn't exit for some reason
|
||||||
|
await Future.delayed(Duration(seconds: 20));
|
||||||
|
exit(0);
|
||||||
|
} else {
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
retry: (_, _) => null,
|
retry: (_, _) => null,
|
||||||
|
|
@ -141,20 +147,17 @@ class const App({super.key}) extends StatelessWidget {
|
||||||
),
|
),
|
||||||
home: child,
|
home: child,
|
||||||
),
|
),
|
||||||
child: DefaultTextHeightBehavior(
|
|
||||||
textHeightBehavior: .new(leadingDistribution: .even),
|
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: Consumer(
|
body: Consumer(
|
||||||
builder: (context, ref, _) => switch (ref.watch(
|
builder: (_, ref, _) => switch (ref.watch(
|
||||||
MultiProviderController.provider(
|
MultiProviderController.provider(
|
||||||
.new([
|
.new([
|
||||||
GomuksListenerController.provider,
|
ClientController.provider,
|
||||||
NotificationController.provider,
|
NotificationController.provider,
|
||||||
UnifiedPushController.provider,
|
UnifiedPushController.provider,
|
||||||
MemberListOpenedController.provider,
|
MemberListOpenedController.provider,
|
||||||
KeyController.provider(KeyController.roomKey),
|
KeyController.provider(KeyController.roomKey),
|
||||||
KeyController.provider(KeyController.spaceKey),
|
KeyController.provider(KeyController.spaceKey),
|
||||||
EmojiController.provider,
|
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
)) {
|
)) {
|
||||||
|
|
@ -198,6 +201,5 @@ class const App({super.key}) extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
|
||||||
|
|
||||||
enum Direction {
|
|
||||||
@JsonValue("f")
|
|
||||||
forward,
|
|
||||||
@JsonValue("b")
|
|
||||||
backward,
|
|
||||||
}
|
|
||||||
|
|
@ -102,6 +102,9 @@ class const UnreadType(final int value) with _$UnreadType {
|
||||||
static const highlight = UnreadType(4);
|
static const highlight = UnreadType(4);
|
||||||
static const sound = UnreadType(8);
|
static const sound = UnreadType(8);
|
||||||
|
|
||||||
bool has(UnreadType flag) => value & flag.value != 0;
|
bool isNone() => value == 0;
|
||||||
UnreadType operator |(UnreadType other) => UnreadType(value | other.value);
|
bool isNormal() => (value & 1) != 0;
|
||||||
|
bool shouldNotify() => (value & 2) != 0;
|
||||||
|
bool isHighlighted() => (value & 4) != 0;
|
||||||
|
bool playsSound() => (value & 8) != 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
|
||||||
import "package:nexus/models/event.dart";
|
|
||||||
|
|
||||||
part "event_context.freezed.dart";
|
|
||||||
part "event_context.g.dart";
|
|
||||||
|
|
||||||
@Freezed(toJson: false, fromJson: false)
|
|
||||||
@JsonSerializable()
|
|
||||||
class const EventContext({
|
|
||||||
required final Event event,
|
|
||||||
required final IList<Event> before,
|
|
||||||
required final IList<Event> after,
|
|
||||||
required final String start,
|
|
||||||
required final String end,
|
|
||||||
final IList<Event> relatedEvents = const IList.empty(),
|
|
||||||
}) with _$EventContext {
|
|
||||||
Map<String, Object?> toJson() => _$EventContextToJson(this);
|
|
||||||
|
|
||||||
factory EventContext.fromJson(Map<String, Object?> json) =>
|
|
||||||
_$EventContextFromJson(json);
|
|
||||||
}
|
|
||||||
|
|
@ -7,9 +7,8 @@ part "lazy_load_summary.g.dart";
|
||||||
@Freezed(toJson: false, fromJson: false)
|
@Freezed(toJson: false, fromJson: false)
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class const LazyLoadSummary({
|
class const LazyLoadSummary({
|
||||||
@JsonKey(name: "m.heroes") required final IList<String>? heroes,
|
required final IList<String>? heroes,
|
||||||
@JsonKey(name: "m.joined_member_count") required final int? joinedMemberCount,
|
required final int? joinedMemberCount,
|
||||||
@JsonKey(name: "m.invited_member_count")
|
|
||||||
required final int? invitedMemberCount,
|
required final int? invitedMemberCount,
|
||||||
}) with _$LazyLoadSummary {
|
}) with _$LazyLoadSummary {
|
||||||
Map<String, Object?> toJson() => _$LazyLoadSummaryToJson(this);
|
Map<String, Object?> toJson() => _$LazyLoadSummaryToJson(this);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import "package:flutter/widgets.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
|
|
||||||
abstract class const NavPage({super.key}) extends Widget {
|
|
||||||
String get title;
|
|
||||||
IconData get icon;
|
|
||||||
Future<int>? badgeBuilder(WidgetRef ref) => null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
|
||||||
import "package:nexus/models/event.dart";
|
|
||||||
|
|
||||||
part "paginate_manual.freezed.dart";
|
|
||||||
part "paginate_manual.g.dart";
|
|
||||||
|
|
||||||
@Freezed(toJson: false, fromJson: false)
|
|
||||||
@JsonSerializable()
|
|
||||||
class const PaginateManual({
|
|
||||||
required final IList<Event> events,
|
|
||||||
final IList<Event> relatedEvents = const IList.empty(),
|
|
||||||
required final String? nextBatch,
|
|
||||||
}) with _$PaginateManual {
|
|
||||||
Map<String, Object?> toJson() => _$PaginateManualToJson(this);
|
|
||||||
|
|
||||||
factory PaginateManual.fromJson(Map<String, Object?> json) =>
|
|
||||||
_$PaginateManualFromJson(json);
|
|
||||||
}
|
|
||||||
|
|
@ -57,7 +57,7 @@ class const Profile({
|
||||||
try {
|
try {
|
||||||
return Profile.fromJson(json);
|
return Profile.fromJson(json);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return Profile(parseError: error.toString());
|
return _Profile(parseError: error.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
|
||||||
|
|
||||||
part "get_event_context.freezed.dart";
|
|
||||||
part "get_event_context.g.dart";
|
|
||||||
|
|
||||||
@Freezed(toJson: false, fromJson: false)
|
|
||||||
@JsonSerializable()
|
|
||||||
class const GetEventContextRequest({
|
|
||||||
required final String roomId,
|
|
||||||
required final String eventId,
|
|
||||||
final int limit = 20,
|
|
||||||
}) with _$GetEventContextRequest {
|
|
||||||
Map<String, Object?> toJson() => _$GetEventContextRequestToJson(this);
|
|
||||||
|
|
||||||
factory GetEventContextRequest.fromJson(Map<String, Object?> json) =>
|
|
||||||
_$GetEventContextRequestFromJson(json);
|
|
||||||
}
|
|
||||||
|
|
@ -9,7 +9,7 @@ part "get_mentions.g.dart";
|
||||||
class GetMentionsRequest({
|
class GetMentionsRequest({
|
||||||
@EpochDateTimeConverter() required final DateTime maxTimestamp,
|
@EpochDateTimeConverter() required final DateTime maxTimestamp,
|
||||||
@JsonKey(name: "type") required final UnreadType unreadType,
|
@JsonKey(name: "type") required final UnreadType unreadType,
|
||||||
final int limit = 20,
|
required final int limit,
|
||||||
final String? roomId,
|
final String? roomId,
|
||||||
}) with _$GetMentionsRequest {
|
}) with _$GetMentionsRequest {
|
||||||
Map<String, Object?> toJson() => _$GetMentionsRequestToJson(this);
|
Map<String, Object?> toJson() => _$GetMentionsRequestToJson(this);
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
|
||||||
import "package:nexus/models/direction.dart";
|
|
||||||
|
|
||||||
part "paginate_manual.freezed.dart";
|
|
||||||
part "paginate_manual.g.dart";
|
|
||||||
|
|
||||||
@Freezed(toJson: false, fromJson: false)
|
|
||||||
@JsonSerializable()
|
|
||||||
class const PaginateManualRequest({
|
|
||||||
required final String roomId,
|
|
||||||
// Root event ID of a thread to paginate
|
|
||||||
final String? threadRoot,
|
|
||||||
// Can be null for starting pagination of a thread
|
|
||||||
final String? since,
|
|
||||||
required final Direction direction,
|
|
||||||
final int limit = 20,
|
|
||||||
}) with _$PaginateManualRequest {
|
|
||||||
Map<String, Object?> toJson() => _$PaginateManualRequestToJson(this);
|
|
||||||
|
|
||||||
factory PaginateManualRequest.fromJson(Map<String, Object?> json) =>
|
|
||||||
_$PaginateManualRequestFromJson(json);
|
|
||||||
}
|
|
||||||
|
|
@ -33,8 +33,7 @@ class const Mentions({
|
||||||
_$MentionsFromJson(json);
|
_$MentionsFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Freezed(toJson: false, fromJson: false)
|
@Freezed(toJson: false)
|
||||||
@JsonSerializable(createToJson: false)
|
|
||||||
class const Relation({
|
class const Relation({
|
||||||
required final String eventId,
|
required final String eventId,
|
||||||
required final RelationType relationType,
|
required final RelationType relationType,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ class const SetMembershipRequest({
|
||||||
required final String userId,
|
required final String userId,
|
||||||
required final String roomId,
|
required final String roomId,
|
||||||
final String? reason,
|
final String? reason,
|
||||||
required final MembershipAction action,
|
@JsonKey(name: "action") required final MembershipAction action,
|
||||||
@JsonKey(name: "msc4293_redact_events") final bool redact = false,
|
@JsonKey(name: "msc4293_redact_events") final bool redact = false,
|
||||||
}) with _$SetMembershipRequest {
|
}) with _$SetMembershipRequest {
|
||||||
Map<String, Object?> toJson() => _$SetMembershipRequestToJson(this);
|
Map<String, Object?> toJson() => _$SetMembershipRequestToJson(this);
|
||||||
|
|
|
||||||
|
|
@ -12,28 +12,17 @@ part "room.g.dart";
|
||||||
class const Room({
|
class const Room({
|
||||||
@JsonKey(name: "meta") final RoomMetadata? metadata,
|
@JsonKey(name: "meta") final RoomMetadata? metadata,
|
||||||
|
|
||||||
/// [timeline] is an IMap of timelineRowId to eventRowId
|
|
||||||
@JsonKey(fromJson: Room.timelineTupleJsonToIMap)
|
@JsonKey(fromJson: Room.timelineTupleJsonToIMap)
|
||||||
final IMap<int, int?> timeline = const IMap.empty(),
|
final IMap<int, int?> timeline = const IMap.empty(),
|
||||||
|
|
||||||
/// [clientSticky] is an ISet of eventRowId
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
|
||||||
final ISet<int> clientSticky = const ISet.empty(),
|
final ISet<int> clientSticky = const ISet.empty(),
|
||||||
|
|
||||||
/// [events] is an IMap of eventRowId to event
|
|
||||||
@JsonKey(fromJson: Room.eventsJsonToIMap)
|
@JsonKey(fromJson: Room.eventsJsonToIMap)
|
||||||
final IMap<int, Event> events = const IMap.empty(),
|
final IMap<int, Event> events = const IMap.empty(),
|
||||||
|
|
||||||
final bool reset = false,
|
final bool reset = false,
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
|
||||||
final bool resetClientSticky = false,
|
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
|
||||||
final bool hasFetchedState = false,
|
final bool hasFetchedState = false,
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
|
||||||
final bool hasFetchedMembers = false,
|
final bool hasFetchedMembers = false,
|
||||||
|
|
||||||
final IMap<String, IMap<String, int>> state = const IMap.empty(),
|
final IMap<String, IMap<String, int>> state = const IMap.empty(),
|
||||||
|
|
||||||
final IMap<String, IList<ReadReceipt>> receipts = const IMap.empty(),
|
final IMap<String, IList<ReadReceipt>> receipts = const IMap.empty(),
|
||||||
|
|
@ -43,6 +32,9 @@ class const Room({
|
||||||
// IMap<String, AccountData> accountData,
|
// IMap<String, AccountData> accountData,
|
||||||
// IList<Notification> notifications,
|
// IList<Notification> notifications,
|
||||||
}) with _$Room {
|
}) with _$Room {
|
||||||
|
/// [timeline] is an IMap of timelineRowId to eventRowId
|
||||||
|
/// [events] is an IMap of eventRowId to event
|
||||||
|
/// [clientSticky] is an ISet of eventRowId
|
||||||
static IMap<int, int?> timelineTupleJsonToIMap(List<dynamic> json) =>
|
static IMap<int, int?> timelineTupleJsonToIMap(List<dynamic> json) =>
|
||||||
IMap.fromEntries(
|
IMap.fromEntries(
|
||||||
json.map(
|
json.map(
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
|
||||||
|
|
||||||
part "room_chat.freezed.dart";
|
|
||||||
part "room_chat.g.dart";
|
|
||||||
|
|
||||||
@Freezed(toJson: false, fromJson: false)
|
|
||||||
@JsonSerializable()
|
|
||||||
class const RoomChat({
|
|
||||||
/// List of eventRowId
|
|
||||||
required final IList<int> timeline,
|
|
||||||
required final bool hasMoreForward,
|
|
||||||
required final bool hasMoreBackward,
|
|
||||||
final HistoricalData? historicalData,
|
|
||||||
}) with _$RoomChat {
|
|
||||||
Map<String, Object?> toJson() => _$RoomChatToJson(this);
|
|
||||||
|
|
||||||
factory RoomChat.fromJson(Map<String, Object?> json) =>
|
|
||||||
_$RoomChatFromJson(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Freezed(toJson: false, fromJson: false)
|
|
||||||
@JsonSerializable()
|
|
||||||
class const HistoricalData({
|
|
||||||
required final String start,
|
|
||||||
required final String end,
|
|
||||||
}) with _$HistoricalData {
|
|
||||||
Map<String, Object?> toJson() => _$HistoricalDataToJson(this);
|
|
||||||
|
|
||||||
factory HistoricalData.fromJson(Map<String, Object?> json) =>
|
|
||||||
_$HistoricalDataFromJson(json);
|
|
||||||
}
|
|
||||||
|
|
@ -9,6 +9,9 @@ part "room_metadata.g.dart";
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class const RoomMetadata({
|
class const RoomMetadata({
|
||||||
@JsonKey(name: "room_id") required final String id,
|
@JsonKey(name: "room_id") required final String id,
|
||||||
|
|
||||||
|
// CreateEventContent creationContent,
|
||||||
|
// TombstoneEventContent tombstoneEventContent,
|
||||||
final String? name,
|
final String? name,
|
||||||
final Uri? avatar,
|
final Uri? avatar,
|
||||||
final String? dmUserId,
|
final String? dmUserId,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ part "room_summary.g.dart";
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class const RoomSummary({
|
class const RoomSummary({
|
||||||
required final String roomId,
|
required final String roomId,
|
||||||
@JsonKey(name: "num_joined_members") final int? joinedMembers,
|
@JsonKey(name: "num_joined_members") required final int joinedMembers,
|
||||||
final JoinRule? joinRule,
|
final JoinRule? joinRule,
|
||||||
final String? name,
|
final String? name,
|
||||||
final Uri? avatarUrl,
|
final Uri? avatarUrl,
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ class const Settings({
|
||||||
final ThemeMode theme = ThemeMode.system,
|
final ThemeMode theme = ThemeMode.system,
|
||||||
final bool useDynamicTheming = true,
|
final bool useDynamicTheming = true,
|
||||||
final bool linuxMobileMode = false,
|
final bool linuxMobileMode = false,
|
||||||
final bool showAllInHome = false,
|
|
||||||
final bool showMinimizeButton = false,
|
|
||||||
}) with _$Settings {
|
}) with _$Settings {
|
||||||
Map<String, Object?> toJson() => _$SettingsToJson(this);
|
Map<String, Object?> toJson() => _$SettingsToJson(this);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ import "package:nexus/controllers/init_complete.dart";
|
||||||
import "package:nexus/controllers/key.dart";
|
import "package:nexus/controllers/key.dart";
|
||||||
import "package:nexus/widgets/appbar.dart";
|
import "package:nexus/widgets/appbar.dart";
|
||||||
import "package:nexus/widgets/sidebar.dart";
|
import "package:nexus/widgets/sidebar.dart";
|
||||||
import "package:nexus/widgets/room_chat/room_chat.dart";
|
import "package:nexus/widgets/room_chat.dart";
|
||||||
import "package:nexus/widgets/loading.dart";
|
import "package:nexus/widgets/loading.dart";
|
||||||
|
|
||||||
class const ChatPage({super.key}) extends HookConsumerWidget {
|
class const ChatPage({super.key}) extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
|
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
|
@ -24,10 +24,9 @@ class const ChatPage({super.key}) extends HookConsumerWidget {
|
||||||
body: initComplete
|
body: initComplete
|
||||||
? Row(
|
? Row(
|
||||||
children: [
|
children: [
|
||||||
if (isDesktop) Sidebar(),
|
if (isDesktop) Sidebar(isDesktop: isDesktop),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: RoomChat(
|
child: RoomChat(
|
||||||
key: ValueKey(roomId),
|
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
isDesktop: isDesktop,
|
isDesktop: isDesktop,
|
||||||
showMembersByDefault: showMembersByDefault,
|
showMembersByDefault: showMembersByDefault,
|
||||||
|
|
@ -41,7 +40,9 @@ class const ChatPage({super.key}) extends HookConsumerWidget {
|
||||||
children: [Loading(), Text("Syncing...")],
|
children: [Loading(), Text("Syncing...")],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
drawer: isDesktop || !initComplete ? null : Sidebar(),
|
drawer: isDesktop || !initComplete
|
||||||
|
? null
|
||||||
|
: Sidebar(isDesktop: isDesktop),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
164
lib/pages/notifications.dart
Normal file
164
lib/pages/notifications.dart
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:m3e_buttons/m3e_buttons.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/notifications.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
|
import "package:nexus/widgets/appbar.dart";
|
||||||
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
|
import "package:nexus/widgets/highlight_wrapper.dart";
|
||||||
|
import "package:nexus/widgets/loading.dart";
|
||||||
|
import "package:nexus/widgets/renderers/event.dart";
|
||||||
|
import "package:super_sliver_list/super_sliver_list.dart";
|
||||||
|
|
||||||
|
class const NotificationsPage({
|
||||||
|
final String? highlightedEventId,
|
||||||
|
final bool defaultToAllNotifications = false,
|
||||||
|
super.key,
|
||||||
|
}) extends HookConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final options = <M3EToggleButtonGroupAction, UnreadType>{
|
||||||
|
M3EToggleButtonGroupAction(
|
||||||
|
checkedLabel: Text("Mentions"),
|
||||||
|
icon: Icon(Icons.alternate_email),
|
||||||
|
): .highlight,
|
||||||
|
M3EToggleButtonGroupAction(
|
||||||
|
checkedLabel: Text("All Notifications"),
|
||||||
|
icon: Icon(Icons.notifications),
|
||||||
|
): .notify,
|
||||||
|
};
|
||||||
|
final unreadTypeIndex = useState(defaultToAllNotifications ? 1 : 0);
|
||||||
|
|
||||||
|
final highlightedId = useState(highlightedEventId);
|
||||||
|
final listController = useRef(ListController());
|
||||||
|
final scrollController = useScrollController();
|
||||||
|
|
||||||
|
final provider = NotificationsController.provider((
|
||||||
|
options.values.toList()[unreadTypeIndex.value],
|
||||||
|
null,
|
||||||
|
));
|
||||||
|
final notifications = ref.watch(provider);
|
||||||
|
final notifier = ref.watch(provider.notifier);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (highlightedId.value == null) return null;
|
||||||
|
Timer? timer;
|
||||||
|
|
||||||
|
void listener() =>
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
if (!listController.value.isAttached) return;
|
||||||
|
|
||||||
|
final notifications = await ref.watch(provider.future);
|
||||||
|
|
||||||
|
final index = notifications.indexWhere(
|
||||||
|
(element) => element.eventId == highlightedId.value!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (index == -1) return;
|
||||||
|
|
||||||
|
listController.value.animateToItem(
|
||||||
|
index: index,
|
||||||
|
scrollController: scrollController,
|
||||||
|
alignment: 0.5,
|
||||||
|
duration: (_) => .new(milliseconds: 700),
|
||||||
|
curve: (_) => Curves.easeInOut,
|
||||||
|
);
|
||||||
|
timer = Timer(.new(seconds: 1), () {
|
||||||
|
highlightedId.value = null;
|
||||||
|
});
|
||||||
|
listController.value.removeListener(listener);
|
||||||
|
});
|
||||||
|
|
||||||
|
listController.value.addListener(listener);
|
||||||
|
return timer?.cancel;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
Future<void> listener() async {
|
||||||
|
if (!scrollController.hasClients || notifications.isLoading) return;
|
||||||
|
|
||||||
|
if (scrollController.position.pixels >=
|
||||||
|
scrollController.position.maxScrollExtent) {
|
||||||
|
await notifier.loadOlder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollController.addListener(listener);
|
||||||
|
return () => scrollController.removeListener(listener);
|
||||||
|
}, [scrollController, notifications]);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: Appbar(title: Text("Notifications")),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
if (notifications is AsyncLoading && notifications.value != null)
|
||||||
|
const LinearProgressIndicator(minHeight: 2),
|
||||||
|
Expanded(
|
||||||
|
child: switch (notifications) {
|
||||||
|
AsyncData(:final value) || AsyncLoading(:final value?) =>
|
||||||
|
value.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
"No notifications yet",
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: SuperListView.builder(
|
||||||
|
listController: listController.value,
|
||||||
|
controller: scrollController,
|
||||||
|
itemCount: value.length,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
reverse: true,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final event = value[index];
|
||||||
|
final isHighlighted =
|
||||||
|
event.eventId == highlightedId.value;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: .only(top: 8),
|
||||||
|
child: HighlightWrapper(
|
||||||
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
// TODO: Jump to event
|
||||||
|
},
|
||||||
|
child: EventRenderer(event),
|
||||||
|
),
|
||||||
|
isHighlighted: isHighlighted,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
AsyncLoading() => const Loading(),
|
||||||
|
AsyncError(:final error, :final stackTrace) => ErrorDialog(
|
||||||
|
error,
|
||||||
|
stackTrace,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: .topRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: .all(16),
|
||||||
|
child: M3EToggleButtonGroup(
|
||||||
|
selectedIndex: unreadTypeIndex.value,
|
||||||
|
onSelectedIndexChanged: (index) =>
|
||||||
|
unreadTypeIndex.value = index ?? unreadTypeIndex.value,
|
||||||
|
actions: options.keys.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ import "package:nexus/controllers/client_id.dart";
|
||||||
import "package:nexus/helpers/launch_helper.dart";
|
import "package:nexus/helpers/launch_helper.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:nexus/models/homeserver.dart";
|
import "package:nexus/models/homeserver.dart";
|
||||||
import "package:nexus/widgets/pages/settings.dart";
|
import "package:nexus/pages/settings.dart";
|
||||||
import "package:nexus/widgets/appbar.dart";
|
import "package:nexus/widgets/appbar.dart";
|
||||||
import "package:nexus/widgets/divider_text.dart";
|
import "package:nexus/widgets/divider_text.dart";
|
||||||
|
|
||||||
|
|
@ -134,9 +134,9 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
|
||||||
padding: .symmetric(vertical: 8, horizontal: 12),
|
padding: .symmetric(vertical: 8, horizontal: 12),
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
spacing: 12,
|
|
||||||
children: [
|
children: [
|
||||||
SvgPicture.asset("assets/bundled/icon.svg", width: 128),
|
SvgPicture.asset("assets/bundled/icon.svg", width: 128),
|
||||||
|
SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: .start,
|
crossAxisAlignment: .start,
|
||||||
|
|
@ -144,7 +144,7 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
|
||||||
Text("Nexus", style: theme.textTheme.displayMedium),
|
Text("Nexus", style: theme.textTheme.displayMedium),
|
||||||
Text(
|
Text(
|
||||||
"A Simple Matrix Client",
|
"A Simple Matrix Client",
|
||||||
style: theme.textTheme.titleLarge,
|
style: theme.textTheme.headlineMedium,
|
||||||
overflow: .ellipsis,
|
overflow: .ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -161,7 +161,6 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
|
||||||
child: TextField(
|
child: TextField(
|
||||||
textInputAction: .done,
|
textInputAction: .done,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
keyboardType: .url,
|
|
||||||
onSubmitted: (text) => setHomeserver(.tryParse(text)),
|
onSubmitted: (text) => setHomeserver(.tryParse(text)),
|
||||||
controller: homeserverUrl,
|
controller: homeserverUrl,
|
||||||
decoration: .new(
|
decoration: .new(
|
||||||
|
|
@ -194,13 +193,12 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget {
|
||||||
url: .https("federated.nexus"),
|
url: .https("federated.nexus"),
|
||||||
iconUrl: "https://federated.nexus/images/icon.png",
|
iconUrl: "https://federated.nexus/images/icon.png",
|
||||||
),
|
),
|
||||||
// TODO: Add back unredacted when they have MAS, find where new favicon is
|
.new(
|
||||||
// .new(
|
name: "Unredacted",
|
||||||
// name: "Unredacted",
|
description: "Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.",
|
||||||
// description: "Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.",
|
url: .https("unredacted.org", "services/si/matrix"),
|
||||||
// url: .https("unredacted.org", "services/si/matrix"),
|
iconUrl: "https://unredacted.org/favicon.ico",
|
||||||
// iconUrl: "https://unredacted.org/favicon.ico",
|
),
|
||||||
// ),
|
|
||||||
].map(
|
].map(
|
||||||
(homeserver) => Card(
|
(homeserver) => Card(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
|
|
@ -8,7 +8,7 @@ import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
|
||||||
import "package:nexus/controllers/settings_sections.dart";
|
import "package:nexus/controllers/settings_sections.dart";
|
||||||
import "package:nexus/helpers/extensions/better_when.dart";
|
import "package:nexus/helpers/extensions/better_when.dart";
|
||||||
import "package:nexus/helpers/extensions/show_about_dialog.dart";
|
import "package:nexus/helpers/extensions/show_about_dialog.dart";
|
||||||
import "package:nexus/widgets/pages/settings_category.dart";
|
import "package:nexus/pages/settings_category.dart";
|
||||||
import "package:nexus/widgets/divider_text.dart";
|
import "package:nexus/widgets/divider_text.dart";
|
||||||
import "package:nexus/widgets/highlight_wrapper.dart";
|
import "package:nexus/widgets/highlight_wrapper.dart";
|
||||||
import "package:super_sliver_list/super_sliver_list.dart";
|
import "package:super_sliver_list/super_sliver_list.dart";
|
||||||
|
|
@ -2,7 +2,7 @@ import "package:material_ui/material_ui.dart";
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:nexus/controllers/client.dart";
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/widgets/pages/settings.dart";
|
import "package:nexus/pages/settings.dart";
|
||||||
import "package:nexus/widgets/appbar.dart";
|
import "package:nexus/widgets/appbar.dart";
|
||||||
import "package:nexus/helpers/required_validator_helper.dart";
|
import "package:nexus/helpers/required_validator_helper.dart";
|
||||||
|
|
||||||
|
|
@ -41,8 +41,6 @@ class const VerifyPage({super.key}) extends HookConsumerWidget {
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
body: AlertDialog(
|
body: AlertDialog(
|
||||||
insetPadding: .symmetric(vertical: 12, horizontal: 32),
|
|
||||||
scrollable: true,
|
|
||||||
title: Text("Verify"),
|
title: Text("Verify"),
|
||||||
content: Form(
|
content: Form(
|
||||||
key: formKey.value,
|
key: formKey.value,
|
||||||
|
|
@ -51,17 +51,6 @@ final class const Appbar({
|
||||||
actions: [
|
actions: [
|
||||||
...actions,
|
...actions,
|
||||||
if (!(Platform.isAndroid || Platform.isIOS)) ...[
|
if (!(Platform.isAndroid || Platform.isIOS)) ...[
|
||||||
if (ref
|
|
||||||
.watch(SettingsController.provider)
|
|
||||||
.whenOrNull(
|
|
||||||
data: (settings) => settings.showMinimizeButton,
|
|
||||||
) ??
|
|
||||||
false)
|
|
||||||
IconButton(
|
|
||||||
tooltip: "Minimize window",
|
|
||||||
onPressed: windowManager.minimize,
|
|
||||||
icon: const Icon(Icons.minimize),
|
|
||||||
),
|
|
||||||
if (!Platform.isLinux)
|
if (!Platform.isLinux)
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: "Maximize window",
|
tooltip: "Maximize window",
|
||||||
|
|
|
||||||
|
|
@ -7,24 +7,25 @@ final class const AvatarOrHash(
|
||||||
final Uri? avatar,
|
final Uri? avatar,
|
||||||
final String title, {
|
final String title, {
|
||||||
final Widget? fallback,
|
final Widget? fallback,
|
||||||
final double dimension = 24,
|
final double height = 24,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends ConsumerWidget {
|
}) extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final box = ColoredBox(
|
final box = ColoredBox(
|
||||||
color: ColorHash(title).color,
|
color: ColorHash(title).color,
|
||||||
child: Center(child: Icon(Icons.person, size: dimension / 2)),
|
child: Center(child: Icon(Icons.person, size: height / 2)),
|
||||||
);
|
);
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: dimension,
|
width: height,
|
||||||
height: dimension,
|
height: height,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: .all(.circular((dimension - 8) / 2.5)),
|
borderRadius: .all(.circular((height - 8) / 2.5)),
|
||||||
child: SizedBox.square(
|
child: SizedBox(
|
||||||
dimension: dimension,
|
width: height,
|
||||||
|
height: height,
|
||||||
child: avatar == null
|
child: avatar == null
|
||||||
? fallback ?? box
|
? fallback ?? box
|
||||||
: Image(
|
: Image(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import "dart:io";
|
||||||
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:file_selector/file_selector.dart";
|
import "package:file_selector/file_selector.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
|
||||||
import "package:nexus/widgets/emoji_picker.dart";
|
import "package:nexus/widgets/emoji_picker.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:flutter/services.dart";
|
import "package:flutter/services.dart";
|
||||||
|
|
@ -13,6 +12,7 @@ import "package:nexus/controllers/attachment.dart";
|
||||||
import "package:nexus/controllers/image_picker.dart";
|
import "package:nexus/controllers/image_picker.dart";
|
||||||
import "package:nexus/controllers/power_level.dart";
|
import "package:nexus/controllers/power_level.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/relation_type.dart";
|
import "package:nexus/models/relation_type.dart";
|
||||||
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
|
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
|
||||||
import "package:nexus/widgets/composer/relation_preview.dart";
|
import "package:nexus/widgets/composer/relation_preview.dart";
|
||||||
|
|
@ -20,7 +20,7 @@ import "package:nexus/main.dart";
|
||||||
|
|
||||||
class const Composer(
|
class const Composer(
|
||||||
final String roomId, {
|
final String roomId, {
|
||||||
required final int? relatedEvent,
|
required final Event? relatedEvent,
|
||||||
required final RelationType relationType,
|
required final RelationType relationType,
|
||||||
required final VoidCallback onDismiss,
|
required final VoidCallback onDismiss,
|
||||||
required final Future<void> Function(
|
required final Future<void> Function(
|
||||||
|
|
@ -35,58 +35,20 @@ class const Composer(
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final controller = useMemoized(FlutterTaggerController.new);
|
final controller = useRef(FlutterTaggerController());
|
||||||
final triggerCharacter = useState("");
|
final triggerCharacter = useState("");
|
||||||
final shouldMention = useState(true);
|
final shouldMention = useState(true);
|
||||||
final query = useState("");
|
final query = useState("");
|
||||||
final relatedEvent = this.relatedEvent == null
|
|
||||||
? null
|
|
||||||
: ref.watch(
|
|
||||||
RoomsController.provider.select((rooms) {
|
|
||||||
final event = rooms[roomId]?.events[this.relatedEvent!];
|
|
||||||
if (event == null) return null;
|
|
||||||
|
|
||||||
final edited = event.lastEditRowId == 0
|
if (relationType == .edit && controller.value.text.isEmpty) {
|
||||||
? null
|
controller.value.text =
|
||||||
: rooms[roomId]?.events[event.lastEditRowId];
|
relatedEvent?.localContent?.editSource ??
|
||||||
|
switch (relatedEvent?.content) {
|
||||||
return edited == null
|
|
||||||
? event
|
|
||||||
: event.copyWith(
|
|
||||||
content: edited.content,
|
|
||||||
localContent: edited.localContent,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
final textBefore = useState<String?>(null);
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
if (relationType == .edit && relatedEvent != null) {
|
|
||||||
textBefore.value ??= controller.text;
|
|
||||||
|
|
||||||
final text =
|
|
||||||
relatedEvent.localContent?.editSource ??
|
|
||||||
switch (relatedEvent.content) {
|
|
||||||
TextMessageContent(:final body) => body,
|
TextMessageContent(:final body) => body,
|
||||||
_ => "",
|
_ => "",
|
||||||
};
|
};
|
||||||
|
|
||||||
controller.value = .new(
|
|
||||||
text: text,
|
|
||||||
selection: .collapsed(offset: text.length),
|
|
||||||
);
|
|
||||||
} else if (textBefore.value case final draft?) {
|
|
||||||
controller.value = .new(
|
|
||||||
text: draft,
|
|
||||||
selection: .collapsed(offset: draft.length),
|
|
||||||
);
|
|
||||||
textBefore.value = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [relationType, relatedEvent == null]);
|
|
||||||
|
|
||||||
final attachment = ref.watch(AttachmentController.provider(roomId));
|
final attachment = ref.watch(AttachmentController.provider(roomId));
|
||||||
|
|
||||||
void send() {
|
void send() {
|
||||||
|
|
@ -95,13 +57,13 @@ class const Composer(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSend(
|
onSend(
|
||||||
controller.formattedText,
|
controller.value.formattedText,
|
||||||
shouldMention: shouldMention.value,
|
shouldMention: shouldMention.value,
|
||||||
tags: .new(controller.tags),
|
tags: .new(controller.value.tags),
|
||||||
);
|
);
|
||||||
|
|
||||||
onDismiss();
|
onDismiss();
|
||||||
controller.text = "";
|
controller.value.text = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
final style = TextStyle(
|
final style = TextStyle(
|
||||||
|
|
@ -146,7 +108,9 @@ class const Composer(
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
color: theme.colorScheme.surfaceContainerHighest,
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
padding: .symmetric(horizontal: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
spacing: 8,
|
||||||
mainAxisAlignment: .center,
|
mainAxisAlignment: .center,
|
||||||
children:
|
children:
|
||||||
ref.watch(
|
ref.watch(
|
||||||
|
|
@ -162,7 +126,7 @@ class const Composer(
|
||||||
builder: (context) => EmojiPicker(
|
builder: (context) => EmojiPicker(
|
||||||
onSelection: (value) {
|
onSelection: (value) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
controller.text += value;
|
controller.value.text += value;
|
||||||
node?.requestFocus();
|
node?.requestFocus();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -180,7 +144,7 @@ class const Composer(
|
||||||
leading: Icon(Icons.add_a_photo),
|
leading: Icon(Icons.add_a_photo),
|
||||||
),
|
),
|
||||||
onTap: () async => ref
|
onTap: () async => ref
|
||||||
.read(
|
.watch(
|
||||||
AttachmentController.provider(roomId)
|
AttachmentController.provider(roomId)
|
||||||
.notifier,
|
.notifier,
|
||||||
)
|
)
|
||||||
|
|
@ -199,7 +163,7 @@ class const Composer(
|
||||||
leading: Icon(Icons.add_photo_alternate),
|
leading: Icon(Icons.add_photo_alternate),
|
||||||
),
|
),
|
||||||
onTap: () async => ref
|
onTap: () async => ref
|
||||||
.read(
|
.watch(
|
||||||
AttachmentController.provider(roomId)
|
AttachmentController.provider(roomId)
|
||||||
.notifier,
|
.notifier,
|
||||||
)
|
)
|
||||||
|
|
@ -228,7 +192,6 @@ class const Composer(
|
||||||
],
|
],
|
||||||
icon: Icon(Icons.add),
|
icon: Icon(Icons.add),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FlutterTagger(
|
child: FlutterTagger(
|
||||||
triggerStrategy: .eager,
|
triggerStrategy: .eager,
|
||||||
|
|
@ -237,11 +200,11 @@ class const Composer(
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
triggerCharacter: triggerCharacter.value,
|
triggerCharacter: triggerCharacter.value,
|
||||||
addTag: ({required id, required name}) {
|
addTag: ({required id, required name}) {
|
||||||
controller.addTag(id: id, name: name);
|
controller.value.addTag(id: id, name: name);
|
||||||
node?.requestFocus();
|
node?.requestFocus();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
controller: controller,
|
controller: controller.value,
|
||||||
onSearch: (newQuery, newTriggerCharacter) {
|
onSearch: (newQuery, newTriggerCharacter) {
|
||||||
triggerCharacter.value = newTriggerCharacter;
|
triggerCharacter.value = newTriggerCharacter;
|
||||||
query.value = newQuery;
|
query.value = newQuery;
|
||||||
|
|
@ -271,16 +234,17 @@ class const Composer(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
maxLines: 12,
|
maxLines: 12,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
textCapitalization: .sentences,
|
|
||||||
autofocus:
|
autofocus:
|
||||||
(Platform.isLinux ||
|
(Platform.isLinux ||
|
||||||
Platform.isMacOS ||
|
Platform.isMacOS ||
|
||||||
Platform.isWindows),
|
Platform.isWindows)
|
||||||
|
? true
|
||||||
|
: false,
|
||||||
decoration: .new(
|
decoration: .new(
|
||||||
hintText: "Your message here...",
|
hintText: "Your message here...",
|
||||||
border: .none,
|
border: .none,
|
||||||
),
|
),
|
||||||
controller: controller,
|
controller: controller.value,
|
||||||
key: key,
|
key: key,
|
||||||
focusNode: node,
|
focusNode: node,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,12 @@ class const EmojiPicker({
|
||||||
.watch(upstream.EmojiController.provider)
|
.watch(upstream.EmojiController.provider)
|
||||||
.betterWhen(
|
.betterWhen(
|
||||||
data: (categories) => upstream.EmojiPicker(
|
data: (categories) => upstream.EmojiPicker(
|
||||||
onSelection: (emoji, isFreeText) async {
|
onSelection: (emoji) async {
|
||||||
await onSelection(emoji);
|
await onSelection(emoji);
|
||||||
|
|
||||||
if (!isFreeText) {
|
|
||||||
await ref
|
await ref
|
||||||
.watch(RecentEmojiController.provider.notifier)
|
.watch(RecentEmojiController.provider.notifier)
|
||||||
.add(emoji)
|
.add(emoji)
|
||||||
.onError(showError);
|
.onError(showError);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
allowFreeText: allowFreeText,
|
allowFreeText: allowFreeText,
|
||||||
prependCategories: .new([
|
prependCategories: .new([
|
||||||
|
|
|
||||||
|
|
@ -13,23 +13,18 @@ class const EventPreview(final Event event, {super.key})
|
||||||
padding: .symmetric(vertical: 4),
|
padding: .symmetric(vertical: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: .min,
|
mainAxisSize: .min,
|
||||||
spacing: 8,
|
spacing: 12,
|
||||||
children: [
|
children: [
|
||||||
if (event.content is MessageContent) MessageAvatar(event, height: 36),
|
if (event.content is MessageContent) MessageAvatar(event),
|
||||||
|
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Column(
|
child: Wrap(
|
||||||
mainAxisAlignment: .center,
|
crossAxisAlignment: .center,
|
||||||
crossAxisAlignment: .start,
|
spacing: 8,
|
||||||
|
runSpacing: 2,
|
||||||
children: [
|
children: [
|
||||||
if (event.content is MessageContent)
|
if (event.content is MessageContent) MessageDisplayname(event),
|
||||||
MessageDisplayname(event, shouldWrap: false),
|
EventRenderer(event, textOnly: true, maxLines: 1),
|
||||||
EventRenderer(
|
|
||||||
event.rowId,
|
|
||||||
roomId: event.roomId,
|
|
||||||
textOnly: true,
|
|
||||||
maxLines: 1,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class const CodeBlock(
|
||||||
padding: .symmetric(horizontal: 8),
|
padding: .symmetric(horizontal: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
lang.substring(0, min(lang.length, 15)),
|
lang.substring(0, min(lang.length, 15)),
|
||||||
style: .new(fontFamily: "bundled-mono"),
|
style: .new(fontFamily: "monospace"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
|
|
@ -44,7 +44,7 @@ class const CodeBlock(
|
||||||
code,
|
code,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
maxLines: 99,
|
maxLines: 99,
|
||||||
style: .new(fontFamily: "bundled-mono"),
|
style: .new(fontFamily: "monospace"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart";
|
import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart";
|
||||||
import "package:nexus/helpers/extensions/get_link_info.dart";
|
import "package:nexus/helpers/extensions/link_to_mention.dart";
|
||||||
import "package:nexus/helpers/launch_helper.dart";
|
import "package:nexus/helpers/launch_helper.dart";
|
||||||
import "package:nexus/helpers/mxc_image.dart";
|
import "package:nexus/helpers/mxc_image.dart";
|
||||||
import "package:nexus/widgets/expandable_image.dart";
|
import "package:nexus/widgets/expandable_image.dart";
|
||||||
|
|
@ -51,28 +51,14 @@ class const Html(
|
||||||
element.text,
|
element.text,
|
||||||
lang: element.className.replaceAll("language-", ""),
|
lang: element.className.replaceAll("language-", ""),
|
||||||
)
|
)
|
||||||
: InlineCustomWidget(
|
: null,
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: .all(.circular(4)),
|
|
||||||
child: Container(
|
|
||||||
padding: .all(8),
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.surfaceContainerHighest,
|
|
||||||
child: SelectableText(
|
|
||||||
element.text,
|
|
||||||
style: .new(fontFamily: "bundled-mono"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
"blockquote" => Quoted(
|
"blockquote" => Quoted(
|
||||||
Html(element.innerHtml, textStyle: textStyle, roomId: roomId),
|
Html(element.innerHtml, textStyle: textStyle, roomId: roomId),
|
||||||
),
|
),
|
||||||
|
|
||||||
"a" =>
|
"a" =>
|
||||||
element.attributes["href"]?.linkInfo == null
|
element.attributes["href"]?.mention == null
|
||||||
? null
|
? null
|
||||||
: InlineCustomWidget(
|
: InlineCustomWidget(
|
||||||
child: MentionChip(element.attributes["href"]!, roomId),
|
child: MentionChip(element.attributes["href"]!, roomId),
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,17 @@
|
||||||
import "package:material_ui/material_ui.dart";
|
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/event.dart";
|
|
||||||
import "package:nexus/controllers/room_summary.dart";
|
import "package:nexus/controllers/room_summary.dart";
|
||||||
import "package:nexus/controllers/user.dart";
|
import "package:nexus/controllers/user.dart";
|
||||||
import "package:nexus/helpers/extensions/focus_room.dart";
|
import "package:nexus/helpers/extensions/link_to_mention.dart";
|
||||||
import "package:nexus/helpers/extensions/get_link_info.dart";
|
|
||||||
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||||
import "package:nexus/models/content/membership.dart";
|
import "package:nexus/models/content/membership.dart";
|
||||||
import "package:nexus/models/room_summary.dart";
|
import "package:nexus/models/room_summary.dart";
|
||||||
import "package:nexus/widgets/room_summary_dialog.dart";
|
|
||||||
|
|
||||||
class const MentionChip(final String content, final String? roomId, {super.key})
|
class const MentionChip(final String content, final String? roomId, {super.key})
|
||||||
extends ConsumerWidget {
|
extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final linkInfo = content.linkInfo;
|
final mention = content.mention;
|
||||||
final mention = linkInfo?.identifier;
|
|
||||||
final data = switch (mention?.characters.firstOrNull) {
|
final data = switch (mention?.characters.firstOrNull) {
|
||||||
"@" =>
|
"@" =>
|
||||||
ref
|
ref
|
||||||
|
|
@ -29,8 +25,7 @@ class const MentionChip(final String content, final String? roomId, {super.key})
|
||||||
.watch(
|
.watch(
|
||||||
RoomSummaryController.provider(.new(roomIdOrAlias: mention!)),
|
RoomSummaryController.provider(.new(roomIdOrAlias: mention!)),
|
||||||
)
|
)
|
||||||
.whenOrNull(data: (data) => data) ??
|
.whenOrNull(data: (data) => data),
|
||||||
RoomSummary(roomId: mention),
|
|
||||||
|
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
@ -38,49 +33,19 @@ class const MentionChip(final String content, final String? roomId, {super.key})
|
||||||
return mention == null
|
return mention == null
|
||||||
? SizedBox.shrink()
|
? SizedBox.shrink()
|
||||||
: InkWell(
|
: InkWell(
|
||||||
onTap: () async {
|
onTap: () {
|
||||||
if (data case MembershipContent membership) {
|
if (data case MembershipContent membership) {
|
||||||
context.showUserPopover(membership, mention, roomId: roomId);
|
context.showUserPopover(membership, mention, roomId: roomId);
|
||||||
} else if (data case RoomSummary summary) {
|
} else if (data case RoomSummary summary) {
|
||||||
// TODO: This is an extra call that isn't truly needed, get rid of it
|
// TODO: Handle summary
|
||||||
final eventRowId = linkInfo?.eventId == null
|
|
||||||
? null
|
|
||||||
: await ref
|
|
||||||
.read(
|
|
||||||
EventController.provider(
|
|
||||||
.new(
|
|
||||||
roomId: summary.roomId,
|
|
||||||
eventId: linkInfo!.eventId!,
|
|
||||||
),
|
|
||||||
).selectAsync((data) => data?.rowId),
|
|
||||||
)
|
|
||||||
.onError((_, _) => null);
|
|
||||||
|
|
||||||
if (!await ref.focusRoom(summary.roomId, eventRowId) &&
|
|
||||||
context.mounted) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => Consumer(
|
|
||||||
builder: (context, ref, _) => RoomSummaryDialog(
|
|
||||||
ref
|
|
||||||
.watch(
|
|
||||||
RoomSummaryController.provider(
|
|
||||||
.new(roomIdOrAlias: mention),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.whenOrNull(data: (data) => data) ??
|
|
||||||
summary,
|
|
||||||
via: linkInfo?.via,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
switch (data) {
|
switch (data) {
|
||||||
RoomSummary summary =>
|
RoomSummary summary =>
|
||||||
"${(summary.name == null ? null : "#${summary.name}") ?? summary.canonicalAlias ?? summary.roomId}${linkInfo?.eventId == null ? "" : " > ${linkInfo?.eventId}"}",
|
(summary.name == null ? null : "#${summary.name}") ??
|
||||||
|
summary.canonicalAlias ??
|
||||||
|
summary.roomId,
|
||||||
MembershipContent membership =>
|
MembershipContent membership =>
|
||||||
membership.displayName == null
|
membership.displayName == null
|
||||||
? mention
|
? mention
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:material_ui/material_ui.dart";
|
|
||||||
import "package:nexus/models/nav_page.dart";
|
|
||||||
|
|
||||||
class const InvitesList({super.key})
|
|
||||||
extends StatelessWidget
|
|
||||||
implements NavPage {
|
|
||||||
@override
|
|
||||||
String get title => "Invites";
|
|
||||||
|
|
||||||
@override
|
|
||||||
IconData get icon => Icons.inbox;
|
|
||||||
|
|
||||||
// TODO: Show notification badge here
|
|
||||||
@override
|
|
||||||
Future<int>? badgeBuilder(WidgetRef ref) => null;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return const Placeholder();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +1,16 @@
|
||||||
|
import "package:collection/collection.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:nexus/controllers/room_summary.dart";
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/helpers/extensions/get_link_info.dart";
|
import "package:nexus/controllers/key.dart";
|
||||||
import "package:nexus/widgets/room_summary_dialog.dart";
|
import "package:nexus/controllers/spaces.dart";
|
||||||
|
import "package:nexus/helpers/extensions/link_to_mention.dart";
|
||||||
|
|
||||||
class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget {
|
class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final roomAlias = useTextEditingController();
|
final roomAlias = useTextEditingController();
|
||||||
|
|
||||||
Future<void> onJoin() async {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
|
|
||||||
final linkInfo = roomAlias.text.linkInfo;
|
|
||||||
final via = linkInfo?.via ?? const .empty();
|
|
||||||
final roomIdOrAlias = linkInfo?.identifier ?? roomAlias.text;
|
|
||||||
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => Consumer(
|
|
||||||
builder: (context, ref, _) => switch (ref.watch(
|
|
||||||
RoomSummaryController.provider(
|
|
||||||
.new(roomIdOrAlias: roomIdOrAlias, via: via),
|
|
||||||
),
|
|
||||||
)) {
|
|
||||||
AsyncData(:final value) => RoomSummaryDialog(value, via: via),
|
|
||||||
AsyncError _ || AsyncLoading _ => RoomSummaryDialog(
|
|
||||||
.new(roomId: roomIdOrAlias),
|
|
||||||
via: via,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text("Join a Room"),
|
title: Text("Join a Room"),
|
||||||
content: Column(
|
content: Column(
|
||||||
|
|
@ -45,14 +21,111 @@ class const JoinDialog(final WidgetRef ref, {super.key}) extends HookWidget {
|
||||||
SizedBox(height: 12),
|
SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: roomAlias,
|
controller: roomAlias,
|
||||||
decoration: .new(hintText: "#room:server.name"),
|
decoration: .new(hintText: "#room:server"),
|
||||||
onEditingComplete: onJoin,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
||||||
TextButton(onPressed: onJoin, child: Text("Join")),
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
final roomIdOrAlias = roomAlias.text.mention ?? roomAlias.text;
|
||||||
|
|
||||||
|
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||||
|
|
||||||
|
final snackbar = scaffoldMessenger.showSnackBar(
|
||||||
|
.new(
|
||||||
|
content: Text("Joining room $roomIdOrAlias."),
|
||||||
|
duration: Duration(days: 999),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final id = await ref
|
||||||
|
.watch(ClientController.provider.notifier)
|
||||||
|
.joinRoom(
|
||||||
|
.new(
|
||||||
|
roomIdOrAlias: roomIdOrAlias,
|
||||||
|
via: .new(
|
||||||
|
Uri.tryParse(roomAlias.text.replaceAll("/#", ""))
|
||||||
|
?.queryParametersAll["via"] ??
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
snackbar.close();
|
||||||
|
|
||||||
|
scaffoldMessenger.showSnackBar(
|
||||||
|
.new(
|
||||||
|
content: Text("Room $roomIdOrAlias successfully joined."),
|
||||||
|
action: .new(
|
||||||
|
label: "Open",
|
||||||
|
onPressed: () async {
|
||||||
|
final spaces = ref.watch(SpacesController.provider);
|
||||||
|
final space = spaces.firstWhereOrNull(
|
||||||
|
(space) => space.id == id,
|
||||||
|
);
|
||||||
|
|
||||||
|
await ref
|
||||||
|
.watch(
|
||||||
|
KeyController.provider(KeyController.spaceKey)
|
||||||
|
.notifier,
|
||||||
|
)
|
||||||
|
.set(
|
||||||
|
space?.id ??
|
||||||
|
spaces
|
||||||
|
.firstWhere(
|
||||||
|
(space) =>
|
||||||
|
space.children.any(
|
||||||
|
(child) =>
|
||||||
|
child.metadata?.id == id,
|
||||||
|
) ||
|
||||||
|
space.subSpaces.any(
|
||||||
|
(child) =>
|
||||||
|
child.room.metadata?.id == id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (space == null) {
|
||||||
|
await ref
|
||||||
|
.watch(
|
||||||
|
KeyController.provider(KeyController.roomKey)
|
||||||
|
.notifier,
|
||||||
|
)
|
||||||
|
.set(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
snackbar.close();
|
||||||
|
if (context.mounted) {
|
||||||
|
scaffoldMessenger.showSnackBar(
|
||||||
|
.new(
|
||||||
|
backgroundColor: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.errorContainer,
|
||||||
|
content: Text(
|
||||||
|
error.toString(),
|
||||||
|
style: .new(
|
||||||
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text("Join"),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ class const MessageAvatar(
|
||||||
child: AvatarOrHash(
|
child: AvatarOrHash(
|
||||||
value.avatarUrl,
|
value.avatarUrl,
|
||||||
value.displayName ?? event.sender.localpart,
|
value.displayName ?? event.sender.localpart,
|
||||||
dimension: height,
|
height: height,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_ => AvatarOrHash(null, event.sender.localpart, dimension: height),
|
_ => AvatarOrHash(null, event.sender.localpart, height: height),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,32 +5,31 @@ import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||||
import "package:nexus/helpers/extensions/string_to_color.dart";
|
import "package:nexus/helpers/extensions/string_to_color.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/content/membership.dart";
|
|
||||||
|
|
||||||
class const MessageDisplayname(
|
class const MessageDisplayname(
|
||||||
final Event event, {
|
final Event event, {
|
||||||
final TextStyle? style,
|
final TextStyle? style,
|
||||||
final bool clickable = true,
|
final bool clickable = true,
|
||||||
final bool shouldWrap = true,
|
|
||||||
super.key,
|
super.key,
|
||||||
}) extends ConsumerWidget {
|
}) extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch(
|
Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch(
|
||||||
AuthorController.provider(event),
|
AuthorController.provider(event),
|
||||||
)) {
|
)) {
|
||||||
AsyncData(:final MembershipContent? value) ||
|
AsyncData(:final value) || AsyncLoading(:final value?) => InkWell(
|
||||||
AsyncLoading(:final value) ||
|
onTap: clickable
|
||||||
AsyncError(:final value) => InkWell(
|
|
||||||
onTap: clickable && value != null
|
|
||||||
? () => context.showUserPopover(
|
? () => context.showUserPopover(
|
||||||
value,
|
value,
|
||||||
event.sender,
|
event.sender,
|
||||||
roomId: event.roomId,
|
roomId: event.roomId,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
child: switch ([
|
child: Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
crossAxisAlignment: .center,
|
||||||
|
children: [
|
||||||
Text(
|
Text(
|
||||||
value?.displayName ?? event.sender.localpart,
|
value.displayName ?? event.sender.localpart,
|
||||||
style:
|
style:
|
||||||
style ?? .new(color: event.sender.colorHash, fontWeight: .bold),
|
style ?? .new(color: event.sender.colorHash, fontWeight: .bold),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
|
|
@ -44,18 +43,12 @@ class const MessageDisplayname(
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: .ellipsis,
|
overflow: .ellipsis,
|
||||||
),
|
),
|
||||||
]) {
|
],
|
||||||
final children when shouldWrap => Wrap(
|
|
||||||
spacing: 4,
|
|
||||||
crossAxisAlignment: .center,
|
|
||||||
children: children,
|
|
||||||
),
|
),
|
||||||
final children => Row(
|
|
||||||
spacing: 4,
|
|
||||||
mainAxisSize: .min,
|
|
||||||
children: [for (final child in children) Flexible(child: child)],
|
|
||||||
),
|
),
|
||||||
},
|
_ => Text(
|
||||||
|
event.sender.localpart,
|
||||||
|
style: .new(color: event.sender.colorHash, fontWeight: .bold),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
|
|
||||||
class const Loading({super.key, final double? dimension})
|
class const Loading({super.key, final double? height}) extends StatelessWidget {
|
||||||
extends StatelessWidget {
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Center(
|
Widget build(BuildContext context) => Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: .all(16),
|
padding: .all(16),
|
||||||
child: SizedBox.square(
|
child: SizedBox(height: height, child: CircularProgressIndicator()),
|
||||||
dimension: dimension,
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import "package:m3e_card_list/m3e_card_list.dart";
|
||||||
import "package:nexus/controllers/members_by_status.dart";
|
import "package:nexus/controllers/members_by_status.dart";
|
||||||
import "package:nexus/controllers/members_grouped.dart";
|
import "package:nexus/controllers/members_grouped.dart";
|
||||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
|
||||||
import "package:nexus/helpers/extensions/string_to_color.dart";
|
import "package:nexus/helpers/extensions/string_to_color.dart";
|
||||||
import "package:nexus/models/content/membership.dart";
|
import "package:nexus/models/content/membership.dart";
|
||||||
import "package:nexus/models/membership_status.dart";
|
import "package:nexus/models/membership_status.dart";
|
||||||
|
|
@ -15,6 +14,7 @@ import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
import "package:nexus/widgets/divider_text.dart";
|
import "package:nexus/widgets/divider_text.dart";
|
||||||
import "package:nexus/widgets/error_dialog.dart";
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
import "package:nexus/widgets/loading.dart";
|
import "package:nexus/widgets/loading.dart";
|
||||||
|
import "package:nexus/widgets/user_bottom_sheet.dart";
|
||||||
|
|
||||||
class const MemberList(final String roomId, {super.key})
|
class const MemberList(final String roomId, {super.key})
|
||||||
extends HookConsumerWidget {
|
extends HookConsumerWidget {
|
||||||
|
|
@ -123,18 +123,15 @@ class const MemberList(final String roomId, {super.key})
|
||||||
members[index].stateKey!,
|
members[index].stateKey!,
|
||||||
overflow: .ellipsis,
|
overflow: .ellipsis,
|
||||||
),
|
),
|
||||||
leading: Transform.translate(
|
leading: AvatarOrHash(
|
||||||
offset: .new(0, 2),
|
|
||||||
child: AvatarOrHash(
|
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
dimension: 40,
|
height: 36,
|
||||||
displayName ??
|
displayName ??
|
||||||
members[index]
|
members[index]
|
||||||
.stateKey!
|
.stateKey!
|
||||||
.localpart,
|
.localpart,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
_ => throw Exception(
|
_ => throw Exception(
|
||||||
"Member content was not MembershipContent",
|
"Member content was not MembershipContent",
|
||||||
),
|
),
|
||||||
|
|
@ -143,10 +140,20 @@ class const MemberList(final String roomId, {super.key})
|
||||||
final member = members[index];
|
final member = members[index];
|
||||||
if (member.content
|
if (member.content
|
||||||
case MembershipContent content) {
|
case MembershipContent content) {
|
||||||
context.showUserPopover(
|
showModalBottomSheet(
|
||||||
|
constraints: .loose(
|
||||||
|
.new(
|
||||||
|
500,
|
||||||
|
(context.size?.height ?? 1000) - 80,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
isScrollControlled: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) => UserBottomSheet(
|
||||||
content,
|
content,
|
||||||
member.stateKey!,
|
member.stateKey!,
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
import "dart:async";
|
|
||||||
|
|
||||||
import "package:m3e_buttons/m3e_buttons.dart";
|
|
||||||
import "package:material_ui/material_ui.dart";
|
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
|
||||||
import "package:nexus/controllers/notifications.dart";
|
|
||||||
import "package:nexus/helpers/extensions/focus_room.dart";
|
|
||||||
import "package:nexus/models/event.dart";
|
|
||||||
import "package:nexus/models/nav_page.dart";
|
|
||||||
import "package:nexus/widgets/error_dialog.dart";
|
|
||||||
import "package:nexus/widgets/highlight_wrapper.dart";
|
|
||||||
import "package:nexus/widgets/loading.dart";
|
|
||||||
import "package:nexus/widgets/renderers/event.dart";
|
|
||||||
import "package:super_sliver_list/super_sliver_list.dart";
|
|
||||||
|
|
||||||
class const NotificationsList({
|
|
||||||
final int? highlightedEvent,
|
|
||||||
final bool defaultToAllNotifications = false,
|
|
||||||
super.key,
|
|
||||||
}) extends HookConsumerWidget implements NavPage {
|
|
||||||
@override
|
|
||||||
String get title => "Notifications";
|
|
||||||
|
|
||||||
@override
|
|
||||||
IconData get icon => Icons.notifications;
|
|
||||||
|
|
||||||
// TODO: Show notification badge here
|
|
||||||
@override
|
|
||||||
Future<int>? badgeBuilder(WidgetRef ref) => null;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final options = <M3EToggleButtonGroupAction, UnreadType>{
|
|
||||||
M3EToggleButtonGroupAction(
|
|
||||||
checkedLabel: Text("Mentions"),
|
|
||||||
icon: Icon(Icons.alternate_email),
|
|
||||||
): .highlight,
|
|
||||||
M3EToggleButtonGroupAction(
|
|
||||||
checkedLabel: Text("All Notifications"),
|
|
||||||
icon: Icon(Icons.notifications),
|
|
||||||
): .notify,
|
|
||||||
};
|
|
||||||
final unreadTypeIndex = useState(defaultToAllNotifications ? 1 : 0);
|
|
||||||
|
|
||||||
final highlightedId = useState(highlightedEvent);
|
|
||||||
final listController = useRef(ListController());
|
|
||||||
final scrollController = useScrollController();
|
|
||||||
|
|
||||||
final provider = NotificationsController.provider((
|
|
||||||
options.values.toList()[unreadTypeIndex.value],
|
|
||||||
null,
|
|
||||||
));
|
|
||||||
final notifications = ref.watch(provider);
|
|
||||||
final notifier = ref.watch(provider.notifier);
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
if (highlightedId.value == null) return null;
|
|
||||||
Timer? timer;
|
|
||||||
|
|
||||||
void listener() =>
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
||||||
if (!listController.value.isAttached) return;
|
|
||||||
|
|
||||||
final notifications = await ref.watch(provider.future);
|
|
||||||
|
|
||||||
final index = notifications.indexWhere(
|
|
||||||
(tuple) => tuple.$1 == highlightedId.value!,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (index == -1) return;
|
|
||||||
|
|
||||||
listController.value.animateToItem(
|
|
||||||
index: index,
|
|
||||||
scrollController: scrollController,
|
|
||||||
alignment: 0.5,
|
|
||||||
duration: (_) => .new(milliseconds: 700),
|
|
||||||
curve: (_) => Curves.easeInOut,
|
|
||||||
);
|
|
||||||
timer = Timer(.new(seconds: 1), () {
|
|
||||||
highlightedId.value = null;
|
|
||||||
});
|
|
||||||
listController.value.removeListener(listener);
|
|
||||||
});
|
|
||||||
|
|
||||||
listController.value.addListener(listener);
|
|
||||||
return timer?.cancel;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
Future<void> listener() async {
|
|
||||||
if (!scrollController.hasClients || notifications.isLoading) return;
|
|
||||||
|
|
||||||
if (scrollController.position.pixels >=
|
|
||||||
scrollController.position.maxScrollExtent) {
|
|
||||||
await notifier.loadOlder();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollController.addListener(listener);
|
|
||||||
return () => scrollController.removeListener(listener);
|
|
||||||
}, [scrollController, notifications]);
|
|
||||||
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
Column(
|
|
||||||
children: [
|
|
||||||
if (notifications is AsyncLoading && notifications.value != null)
|
|
||||||
const LinearProgressIndicator(minHeight: 2),
|
|
||||||
Expanded(
|
|
||||||
child: switch (notifications) {
|
|
||||||
AsyncData(:final value) || AsyncLoading(:final value?) =>
|
|
||||||
value.isEmpty
|
|
||||||
? Center(
|
|
||||||
child: Text(
|
|
||||||
"No notifications yet",
|
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: SuperListView.builder(
|
|
||||||
listController: listController.value,
|
|
||||||
controller: scrollController,
|
|
||||||
itemCount: value.length,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
reverse: true,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final (rowId, roomId) = value[index];
|
|
||||||
final isHighlighted = rowId == highlightedId.value;
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: .only(top: 8),
|
|
||||||
child: HighlightWrapper(
|
|
||||||
InkWell(
|
|
||||||
onTap: () async {
|
|
||||||
final navigator = Navigator.of(context);
|
|
||||||
if (navigator.canPop()) navigator.pop();
|
|
||||||
|
|
||||||
await ref.focusRoom(roomId, rowId);
|
|
||||||
},
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: EventRenderer(rowId, roomId: roomId),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
isHighlighted: isHighlighted,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
AsyncLoading() => const Loading(),
|
|
||||||
AsyncError(:final error, :final stackTrace) => ErrorDialog(
|
|
||||||
error,
|
|
||||||
stackTrace,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: .topRight,
|
|
||||||
child: Padding(
|
|
||||||
padding: .all(16),
|
|
||||||
child: M3EToggleButtonGroup(
|
|
||||||
selectedIndex: unreadTypeIndex.value,
|
|
||||||
onSelectedIndexChanged: (index) =>
|
|
||||||
unreadTypeIndex.value = index ?? unreadTypeIndex.value,
|
|
||||||
actions: options.keys.toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:nexus/controllers/pinned_events.dart";
|
import "package:nexus/controllers/pinned_events.dart";
|
||||||
import "package:nexus/helpers/hooks/chat_scroll.dart";
|
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/widgets/error_dialog.dart";
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
import "package:nexus/widgets/loading.dart";
|
import "package:nexus/widgets/loading.dart";
|
||||||
|
|
@ -11,7 +10,7 @@ import "package:nexus/widgets/renderers/event.dart";
|
||||||
final class const PinnedEventsDrawer(
|
final class const PinnedEventsDrawer(
|
||||||
final String roomId, {
|
final String roomId, {
|
||||||
required final IList<PopupMenuEntry> Function(Event event) getEventOptions,
|
required final IList<PopupMenuEntry> Function(Event event) getEventOptions,
|
||||||
required final JumpToEvent jumpToEvent,
|
required final Future<void> Function(String eventId) jumpToId,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends HookConsumerWidget {
|
}) extends HookConsumerWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -57,25 +56,23 @@ final class const PinnedEventsDrawer(
|
||||||
reverse: true,
|
reverse: true,
|
||||||
itemCount: value.length,
|
itemCount: value.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final rowId = value.reversed[index];
|
final event = value.reversed[index];
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: .circular(12),
|
borderRadius: .circular(12),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
jumpToEvent(rowId);
|
jumpToId(event.eventId);
|
||||||
},
|
},
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: .symmetric(vertical: 4),
|
padding: .symmetric(vertical: 4),
|
||||||
child: IgnorePointer(
|
|
||||||
child: EventRenderer(
|
child: EventRenderer(
|
||||||
rowId,
|
event,
|
||||||
roomId: roomId,
|
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
|
isGrouped: false,
|
||||||
getEventOptions: getEventOptions,
|
getEventOptions: getEventOptions,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -64,10 +64,8 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget {
|
||||||
enabled.value = false;
|
enabled.value = false;
|
||||||
try {
|
try {
|
||||||
final controller = ref.watch(
|
final controller = ref.watch(
|
||||||
RoomChatController.provider((
|
RoomChatController.provider(event.roomId)
|
||||||
roomId: event.roomId,
|
.notifier,
|
||||||
contextualEvent: null,
|
|
||||||
)).notifier,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selected) {
|
if (selected) {
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,7 @@ import "package:flutter/gestures.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
import "package:flutter_hooks/flutter_hooks.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/room_chat.dart";
|
|
||||||
import "package:nexus/controllers/rooms.dart";
|
|
||||||
import "package:nexus/helpers/extensions/show_context_menu.dart";
|
import "package:nexus/helpers/extensions/show_context_menu.dart";
|
||||||
import "package:nexus/helpers/hooks/chat_scroll.dart";
|
|
||||||
import "package:nexus/models/content/avatar.dart";
|
import "package:nexus/models/content/avatar.dart";
|
||||||
import "package:nexus/models/content/canonical_alias.dart";
|
import "package:nexus/models/content/canonical_alias.dart";
|
||||||
import "package:nexus/models/content/content.dart";
|
import "package:nexus/models/content/content.dart";
|
||||||
|
|
@ -32,12 +28,11 @@ import "package:nexus/widgets/renderers/generic_event.dart";
|
||||||
import "package:nexus/widgets/timestamp.dart";
|
import "package:nexus/widgets/timestamp.dart";
|
||||||
|
|
||||||
class const EventRenderer(
|
class const EventRenderer(
|
||||||
final int eventRowId, {
|
final Event event, {
|
||||||
required final String roomId,
|
|
||||||
final int? previousEventRowId,
|
|
||||||
final bool textOnly = false,
|
final bool textOnly = false,
|
||||||
|
final bool isGrouped = false,
|
||||||
final int? maxLines,
|
final int? maxLines,
|
||||||
final JumpToEvent? jumpToEvent,
|
final VoidCallback? onTapReply,
|
||||||
final IList<PopupMenuEntry> Function(Event event)? getEventOptions,
|
final IList<PopupMenuEntry> Function(Event event)? getEventOptions,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends HookConsumerWidget {
|
}) extends HookConsumerWidget {
|
||||||
|
|
@ -49,38 +44,6 @@ class const EventRenderer(
|
||||||
final focusNode = useFocusNode();
|
final focusNode = useFocusNode();
|
||||||
useListenable(focusNode);
|
useListenable(focusNode);
|
||||||
|
|
||||||
final (event, previousEvent) = ref.watch(
|
|
||||||
RoomsController.provider.select((rooms) {
|
|
||||||
final event = rooms[roomId]?.events[eventRowId];
|
|
||||||
if (event == null) return (null, null);
|
|
||||||
|
|
||||||
final edited = event.lastEditRowId == 0
|
|
||||||
? null
|
|
||||||
: rooms[roomId]?.events[event.lastEditRowId];
|
|
||||||
|
|
||||||
return (
|
|
||||||
edited == null
|
|
||||||
? event
|
|
||||||
: event.copyWith(
|
|
||||||
content: edited.content,
|
|
||||||
localContent: edited.localContent,
|
|
||||||
),
|
|
||||||
previousEventRowId == null
|
|
||||||
? null
|
|
||||||
: rooms[roomId]?.events[previousEventRowId!],
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (event == null) return SizedBox.shrink();
|
|
||||||
|
|
||||||
final isGrouped =
|
|
||||||
previousEvent?.content is MessageContent &&
|
|
||||||
previousEvent?.redactedBy == null &&
|
|
||||||
previousEvent?.relationType != "m.replace" &&
|
|
||||||
event.sender == previousEvent?.sender &&
|
|
||||||
event.pmp?.id == previousEvent?.pmp?.id;
|
|
||||||
|
|
||||||
final child = event.redactedBy != null || event.relationType == "m.replace"
|
final child = event.redactedBy != null || event.relationType == "m.replace"
|
||||||
? null
|
? null
|
||||||
: switch (event.content) {
|
: switch (event.content) {
|
||||||
|
|
@ -97,18 +60,7 @@ class const EventRenderer(
|
||||||
EncryptedContent() ||
|
EncryptedContent() ||
|
||||||
StickerContent() => MessageRenderer(
|
StickerContent() => MessageRenderer(
|
||||||
event,
|
event,
|
||||||
onTapReply: jumpToEvent == null
|
onTapReply: onTapReply,
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
final replyEvent = await ref.read(
|
|
||||||
EventController.provider(
|
|
||||||
.new(roomId: roomId, eventId: event.replyTo!),
|
|
||||||
).future,
|
|
||||||
);
|
|
||||||
if (replyEvent != null) {
|
|
||||||
await jumpToEvent!(replyEvent.rowId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isGrouped: isGrouped,
|
isGrouped: isGrouped,
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
textOnly: textOnly,
|
textOnly: textOnly,
|
||||||
|
|
@ -116,98 +68,39 @@ class const EventRenderer(
|
||||||
|
|
||||||
MembershipContent content => switch (event.previousContent) {
|
MembershipContent content => switch (event.previousContent) {
|
||||||
MembershipContent(:final status) =>
|
MembershipContent(:final status) =>
|
||||||
status == content.status
|
status == content.status ? null : MembershipRenderer(event),
|
||||||
? null
|
_ => MembershipRenderer(event),
|
||||||
: MembershipRenderer(event, maxLines: maxLines),
|
|
||||||
_ => MembershipRenderer(event, maxLines: maxLines),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
AvatarContent() => GenericEventRenderer(
|
AvatarContent() => GenericEventRenderer(Icons.interests, [
|
||||||
Icons.interests,
|
MessageDisplayname(event),
|
||||||
maxLines: maxLines,
|
Text("changed the room avatar"),
|
||||||
[
|
]),
|
||||||
WidgetSpan(
|
|
||||||
alignment: .middle,
|
|
||||||
child: MessageDisplayname(
|
|
||||||
event,
|
|
||||||
shouldWrap: maxLines == null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextSpan(text: "changed the room avatar"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
CreateContent() => GenericEventRenderer(
|
CreateContent() => GenericEventRenderer(Icons.add, [
|
||||||
Icons.add,
|
MessageDisplayname(event),
|
||||||
maxLines: maxLines,
|
Text("created the room"),
|
||||||
[
|
]),
|
||||||
WidgetSpan(
|
|
||||||
alignment: .middle,
|
|
||||||
child: MessageDisplayname(
|
|
||||||
event,
|
|
||||||
shouldWrap: maxLines == null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextSpan(text: "created the room"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
PowerLevelsContent() => GenericEventRenderer(
|
PowerLevelsContent() => GenericEventRenderer(Icons.power, [
|
||||||
Icons.power,
|
MessageDisplayname(event),
|
||||||
maxLines: maxLines,
|
Text("changed the room's power levels"),
|
||||||
[
|
]),
|
||||||
WidgetSpan(
|
|
||||||
alignment: .middle,
|
|
||||||
child: MessageDisplayname(
|
|
||||||
event,
|
|
||||||
shouldWrap: maxLines == null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextSpan(text: "changed the room's power levels"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
JoinRulesContent() => GenericEventRenderer(
|
JoinRulesContent() => GenericEventRenderer(Icons.rule, [
|
||||||
Icons.rule,
|
MessageDisplayname(event),
|
||||||
maxLines: maxLines,
|
Text("changed the room's join rules"),
|
||||||
[
|
]),
|
||||||
WidgetSpan(
|
|
||||||
alignment: .middle,
|
|
||||||
child: MessageDisplayname(
|
|
||||||
event,
|
|
||||||
shouldWrap: maxLines == null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextSpan(text: "changed the room's join rules"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
TopicContent() => GenericEventRenderer(
|
TopicContent() => GenericEventRenderer(Icons.description, [
|
||||||
Icons.description,
|
MessageDisplayname(event),
|
||||||
maxLines: maxLines,
|
Text("updated the room topic"),
|
||||||
[
|
]),
|
||||||
WidgetSpan(
|
|
||||||
alignment: .middle,
|
|
||||||
child: MessageDisplayname(
|
|
||||||
event,
|
|
||||||
shouldWrap: maxLines == null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextSpan(text: "updated the room topic"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
HistoryVisibilityContent(:final historyVisibility) =>
|
HistoryVisibilityContent(:final historyVisibility) =>
|
||||||
GenericEventRenderer(Icons.history, maxLines: maxLines, [
|
GenericEventRenderer(Icons.history, [
|
||||||
WidgetSpan(
|
MessageDisplayname(event),
|
||||||
alignment: .middle,
|
Text(
|
||||||
child: MessageDisplayname(
|
|
||||||
event,
|
|
||||||
shouldWrap: maxLines == null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextSpan(
|
|
||||||
text:
|
|
||||||
"changed the room's history visibility to ${switch (historyVisibility) {
|
"changed the room's history visibility to ${switch (historyVisibility) {
|
||||||
.invited => "since invited",
|
.invited => "since invited",
|
||||||
.joined => "since joined",
|
.joined => "since joined",
|
||||||
|
|
@ -217,47 +110,20 @@ class const EventRenderer(
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
PinnedEventsContent() => GenericEventRenderer(
|
PinnedEventsContent() => GenericEventRenderer(Icons.push_pin, [
|
||||||
Icons.push_pin,
|
MessageDisplayname(event),
|
||||||
maxLines: maxLines,
|
Text("pinned/unpinned some events"),
|
||||||
[
|
]),
|
||||||
WidgetSpan(
|
|
||||||
alignment: .middle,
|
|
||||||
child: MessageDisplayname(
|
|
||||||
event,
|
|
||||||
shouldWrap: maxLines == null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextSpan(text: "pinned/unpinned some events"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
ServerACLContent() => GenericEventRenderer(
|
ServerACLContent() => GenericEventRenderer(Icons.list, [
|
||||||
Icons.list,
|
MessageDisplayname(event),
|
||||||
maxLines: maxLines,
|
Text("updated the server ban list"),
|
||||||
[
|
]),
|
||||||
WidgetSpan(
|
|
||||||
alignment: .middle,
|
|
||||||
child: MessageDisplayname(
|
|
||||||
event,
|
|
||||||
shouldWrap: maxLines == null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextSpan(text: "updated the server ban list"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
CanonicalAliasContent(:final alias, :final altAliases) =>
|
CanonicalAliasContent(:final alias, :final altAliases) =>
|
||||||
GenericEventRenderer(Icons.numbers, maxLines: maxLines, [
|
GenericEventRenderer(Icons.numbers, [
|
||||||
WidgetSpan(
|
MessageDisplayname(event),
|
||||||
alignment: .middle,
|
Text(switch ([
|
||||||
child: MessageDisplayname(
|
|
||||||
event,
|
|
||||||
shouldWrap: maxLines == null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextSpan(
|
|
||||||
text: switch ([
|
|
||||||
if (event.previousContent case CanonicalAliasContent(
|
if (event.previousContent case CanonicalAliasContent(
|
||||||
alias: final prevAlias,
|
alias: final prevAlias,
|
||||||
altAliases: final prevAltAliases,
|
altAliases: final prevAltAliases,
|
||||||
|
|
@ -271,9 +137,7 @@ class const EventRenderer(
|
||||||
if (prevAltAliases
|
if (prevAltAliases
|
||||||
.remove(alias ?? "")
|
.remove(alias ?? "")
|
||||||
.remove(prevAlias ?? "") !=
|
.remove(prevAlias ?? "") !=
|
||||||
altAliases
|
altAliases.remove(alias ?? "").remove(prevAlias ?? ""))
|
||||||
.remove(alias ?? "")
|
|
||||||
.remove(prevAlias ?? ""))
|
|
||||||
"changed the room's aliases",
|
"changed the room's aliases",
|
||||||
] else ...[
|
] else ...[
|
||||||
if (alias != null) "set the room's canonical alias",
|
if (alias != null) "set the room's canonical alias",
|
||||||
|
|
@ -282,8 +146,7 @@ class const EventRenderer(
|
||||||
]) {
|
]) {
|
||||||
[] => "did something related to room aliases",
|
[] => "did something related to room aliases",
|
||||||
List prev => prev.join(" and "),
|
List prev => prev.join(" and "),
|
||||||
},
|
}),
|
||||||
),
|
|
||||||
]),
|
]),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
@ -298,7 +161,7 @@ class const EventRenderer(
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: .start,
|
crossAxisAlignment: .start,
|
||||||
children: [
|
children: [
|
||||||
if (child != null)
|
if (child != null) ...[
|
||||||
if (textOnly)
|
if (textOnly)
|
||||||
child
|
child
|
||||||
else ...[
|
else ...[
|
||||||
|
|
@ -357,39 +220,16 @@ class const EventRenderer(
|
||||||
if (event.sendError != null && event.sendError != "not sent")
|
if (event.sendError != null && event.sendError != "not sent")
|
||||||
Padding(
|
Padding(
|
||||||
padding: .only(bottom: 4),
|
padding: .only(bottom: 4),
|
||||||
child: Row(
|
child: Text(
|
||||||
spacing: 4,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
event.sendError!,
|
event.sendError!,
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
color: theme.colorScheme.error,
|
color: theme.colorScheme.error,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
InkWell(
|
|
||||||
child: Text(
|
|
||||||
"Hide",
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
decoration: .underline,
|
|
||||||
decorationColor: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTap: () => ref
|
|
||||||
.read(
|
|
||||||
RoomChatController.provider((
|
|
||||||
roomId: roomId,
|
|
||||||
contextualEvent: null,
|
|
||||||
)).notifier,
|
|
||||||
)
|
|
||||||
.hideClientSticky(event.rowId),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
].map((child) => Padding(padding: .only(left: 4), child: child)),
|
].map((child) => Padding(padding: .only(left: 4), child: child)),
|
||||||
]
|
],
|
||||||
else if (textOnly)
|
] else if (textOnly)
|
||||||
Text("Unknown event type", style: errorStyle),
|
Text("Unknown event type", style: errorStyle),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ import "package:material_ui/material_ui.dart";
|
||||||
|
|
||||||
class const GenericEventRenderer(
|
class const GenericEventRenderer(
|
||||||
final IconData icon,
|
final IconData icon,
|
||||||
final List<InlineSpan> children, {
|
final List<Widget> children, {
|
||||||
final int? maxLines,
|
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -14,20 +13,7 @@ class const GenericEventRenderer(
|
||||||
mainAxisSize: .min,
|
mainAxisSize: .min,
|
||||||
children: [
|
children: [
|
||||||
Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)),
|
Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)),
|
||||||
Flexible(
|
Flexible(child: Wrap(spacing: 4, children: children)),
|
||||||
child: Text.rich(
|
|
||||||
TextSpan(
|
|
||||||
children: [
|
|
||||||
for (final (index, child) in children.indexed) ...[
|
|
||||||
if (index > 0) TextSpan(text: " "),
|
|
||||||
child,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
maxLines: maxLines ?? 999,
|
|
||||||
overflow: .ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,8 @@ import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
||||||
import "package:nexus/widgets/renderers/generic_event.dart";
|
import "package:nexus/widgets/renderers/generic_event.dart";
|
||||||
|
|
||||||
class const MembershipRenderer(
|
class const MembershipRenderer(final Event event, {super.key})
|
||||||
final Event event, {
|
extends StatelessWidget {
|
||||||
final int? maxLines,
|
|
||||||
super.key,
|
|
||||||
}) extends StatelessWidget {
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
assert(
|
assert(
|
||||||
|
|
@ -20,13 +17,8 @@ class const MembershipRenderer(
|
||||||
);
|
);
|
||||||
|
|
||||||
return switch (event.content) {
|
return switch (event.content) {
|
||||||
MembershipContent content => GenericEventRenderer(
|
MembershipContent content => GenericEventRenderer(Icons.people, [
|
||||||
Icons.people,
|
InkWell(
|
||||||
maxLines: maxLines,
|
|
||||||
[
|
|
||||||
WidgetSpan(
|
|
||||||
alignment: .middle,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () => context.showUserPopover(
|
onTap: () => context.showUserPopover(
|
||||||
content,
|
content,
|
||||||
event.stateKey!,
|
event.stateKey!,
|
||||||
|
|
@ -39,26 +31,20 @@ class const MembershipRenderer(
|
||||||
style: .new(color: event.sender.colorHash, fontWeight: .bold),
|
style: .new(color: event.sender.colorHash, fontWeight: .bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Text(
|
||||||
TextSpan(
|
overflow: .ellipsis,
|
||||||
text:
|
maxLines: 1,
|
||||||
"${switch (content.status) {
|
"${switch (content.status) {
|
||||||
.invite => "was invited to",
|
.invite => "was invited to",
|
||||||
.join => "joined",
|
.join => "joined",
|
||||||
.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"),
|
.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"),
|
||||||
.ban => "was banned from",
|
.ban => "was banned from",
|
||||||
.knock => "asked to join",
|
.knock => "asked to join",
|
||||||
}} the room${event.sender == event.stateKey ? "" : " by"}",
|
}} the room${event.sender == event.stateKey ? "" : " by "}",
|
||||||
),
|
|
||||||
if (event.sender != event.stateKey)
|
|
||||||
WidgetSpan(
|
|
||||||
alignment: .middle,
|
|
||||||
child: MessageDisplayname(event, shouldWrap: maxLines == null),
|
|
||||||
),
|
|
||||||
if (content.reason != null)
|
|
||||||
TextSpan(text: "for \"${content.reason}\""),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
if (event.sender != event.stateKey) MessageDisplayname(event),
|
||||||
|
if (content.reason != null) Text("for \"${content.reason}\""),
|
||||||
|
]),
|
||||||
_ => SizedBox.shrink(),
|
_ => SizedBox.shrink(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import "package:collection/collection.dart";
|
import "package:collection/collection.dart";
|
||||||
|
import "package:flutter/rendering.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:linkify/linkify.dart";
|
import "package:linkify/linkify.dart";
|
||||||
import "package:nexus/controllers/client_state.dart";
|
import "package:nexus/controllers/client_state.dart";
|
||||||
import "package:nexus/controllers/event.dart";
|
import "package:nexus/controllers/event.dart";
|
||||||
import "package:nexus/helpers/extensions/get_filename.dart";
|
|
||||||
import "package:nexus/models/content/encrypted.dart";
|
import "package:nexus/models/content/encrypted.dart";
|
||||||
import "package:nexus/models/content/message.dart";
|
import "package:nexus/models/content/message.dart";
|
||||||
import "package:nexus/models/content/sticker.dart";
|
import "package:nexus/models/content/sticker.dart";
|
||||||
|
|
@ -14,7 +14,6 @@ import "package:nexus/widgets/html/html.dart";
|
||||||
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
|
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
|
||||||
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
||||||
import "package:nexus/widgets/linkified_text.dart";
|
import "package:nexus/widgets/linkified_text.dart";
|
||||||
import "package:nexus/widgets/loading.dart";
|
|
||||||
import "package:nexus/widgets/message_image.dart";
|
import "package:nexus/widgets/message_image.dart";
|
||||||
import "package:nexus/widgets/reaction_row.dart";
|
import "package:nexus/widgets/reaction_row.dart";
|
||||||
import "package:nexus/widgets/timestamp.dart";
|
import "package:nexus/widgets/timestamp.dart";
|
||||||
|
|
@ -38,9 +37,7 @@ class const MessageRenderer(
|
||||||
final errorStyle = TextStyle(color: colorScheme.error);
|
final errorStyle = TextStyle(color: colorScheme.error);
|
||||||
|
|
||||||
final textStyle = TextStyle(
|
final textStyle = TextStyle(
|
||||||
fontSize: event.localContent?.bigEmoji == true && maxLines != 1
|
fontSize: event.localContent?.bigEmoji == true ? 32 : null,
|
||||||
? 32
|
|
||||||
: null,
|
|
||||||
fontStyle: event.content is EmoteMessageContent ? .italic : null,
|
fontStyle: event.content is EmoteMessageContent ? .italic : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -55,13 +52,8 @@ class const MessageRenderer(
|
||||||
),
|
),
|
||||||
// TODO: Handle locations
|
// TODO: Handle locations
|
||||||
// LocationMessageContent(:final body , :final geoUri) =>
|
// LocationMessageContent(:final body , :final geoUri) =>
|
||||||
MessageContent content => switch (content) {
|
|
||||||
TextMessageContent(:final body, :final formattedBody, :final format) ||
|
TextMessageContent(:final body, :final formattedBody, :final format) ||
|
||||||
NoticeMessageContent(
|
NoticeMessageContent(:final body, :final formattedBody, :final format) ||
|
||||||
:final body,
|
|
||||||
:final formattedBody,
|
|
||||||
:final format,
|
|
||||||
) ||
|
|
||||||
EmoteMessageContent(:final body, :final formattedBody, :final format) ||
|
EmoteMessageContent(:final body, :final formattedBody, :final format) ||
|
||||||
ImageMessageContent(:final body, :final formattedBody, :final format) ||
|
ImageMessageContent(:final body, :final formattedBody, :final format) ||
|
||||||
VideoMessageContent(:final body, :final formattedBody, :final format) ||
|
VideoMessageContent(:final body, :final formattedBody, :final format) ||
|
||||||
|
|
@ -95,11 +87,7 @@ class const MessageRenderer(
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: LinkifiedText(
|
: LinkifiedText(body, style: textStyle, maxLines: maxLines),
|
||||||
body.isEmpty ? content.filename ?? "" : body,
|
|
||||||
style: textStyle,
|
|
||||||
maxLines: maxLines,
|
|
||||||
),
|
|
||||||
|
|
||||||
if (!textOnly) ...[
|
if (!textOnly) ...[
|
||||||
if (event.content
|
if (event.content
|
||||||
|
|
@ -110,14 +98,27 @@ class const MessageRenderer(
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: .loose(.square(500)),
|
constraints: .loose(.square(500)),
|
||||||
child: switch (event.content) {
|
child: switch (event.content) {
|
||||||
VideoMessageContent(:final info, :final file) =>
|
VideoMessageContent(:final info, :final file) => VideoPlayer(
|
||||||
VideoPlayer(url, info, encrypted: file != null),
|
url,
|
||||||
AudioMessageContent(:final info, :final file) =>
|
info,
|
||||||
AudioPlayer(url, info, encrypted: file != null),
|
encrypted: file != null,
|
||||||
FileMessageContent(:final info, :final filename) =>
|
),
|
||||||
FileCard(url, info, filename: filename),
|
AudioMessageContent(:final info, :final file) => AudioPlayer(
|
||||||
ImageMessageContent(:final info, :final file) =>
|
url,
|
||||||
MessageImage(url, info: info, encrypted: file != null),
|
info,
|
||||||
|
|
||||||
|
encrypted: file != null,
|
||||||
|
),
|
||||||
|
FileMessageContent(:final info, :final filename) => FileCard(
|
||||||
|
url,
|
||||||
|
info,
|
||||||
|
filename: filename,
|
||||||
|
),
|
||||||
|
ImageMessageContent(:final info, :final file) => MessageImage(
|
||||||
|
url,
|
||||||
|
info: info,
|
||||||
|
encrypted: file != null,
|
||||||
|
),
|
||||||
_ => SizedBox.shrink(),
|
_ => SizedBox.shrink(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -132,8 +133,7 @@ class const MessageRenderer(
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
LocationMessageContent(:final String? body) ||
|
MessageContent(:final body) =>
|
||||||
UnknownMessageContent(:final body) =>
|
|
||||||
body == null
|
body == null
|
||||||
? Text("This message is redacted", style: errorStyle)
|
? Text("This message is redacted", style: errorStyle)
|
||||||
: Wrap(
|
: Wrap(
|
||||||
|
|
@ -143,14 +143,13 @@ class const MessageRenderer(
|
||||||
Text(body),
|
Text(body),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
},
|
|
||||||
_ => throw Exception("This is impossible"),
|
_ => throw Exception("This is impossible"),
|
||||||
};
|
};
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: .start,
|
crossAxisAlignment: .start,
|
||||||
mainAxisSize: .min,
|
mainAxisSize: .min,
|
||||||
spacing: 6,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
if (!textOnly)
|
if (!textOnly)
|
||||||
if (isGrouped)
|
if (isGrouped)
|
||||||
|
|
@ -159,7 +158,7 @@ class const MessageRenderer(
|
||||||
MessageAvatar(event, height: 40),
|
MessageAvatar(event, height: 40),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Column(
|
child: Column(
|
||||||
spacing: 2,
|
spacing: 4,
|
||||||
crossAxisAlignment: .start,
|
crossAxisAlignment: .start,
|
||||||
children: [
|
children: [
|
||||||
if (!isGrouped && !textOnly)
|
if (!isGrouped && !textOnly)
|
||||||
|
|
@ -199,8 +198,6 @@ class const MessageRenderer(
|
||||||
onTap: onTapReply,
|
onTap: onTapReply,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: .symmetric(vertical: 8, horizontal: 12),
|
padding: .symmetric(vertical: 8, horizontal: 12),
|
||||||
child: SizedBox(
|
|
||||||
height: 48,
|
|
||||||
child: switch (ref.watch(
|
child: switch (ref.watch(
|
||||||
EventController.provider(
|
EventController.provider(
|
||||||
.new(
|
.new(
|
||||||
|
|
@ -217,13 +214,18 @@ class const MessageRenderer(
|
||||||
"An error occurred while fetching the reply",
|
"An error occurred while fetching the reply",
|
||||||
style: errorStyle,
|
style: errorStyle,
|
||||||
),
|
),
|
||||||
_ => Loading(dimension: 12),
|
_ => Text("Fetching event..."),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
rendered,
|
RendererBinding.instance.mouseTracker.mouseIsConnected
|
||||||
|
? SelectableRegion(
|
||||||
|
selectionControls: materialTextSelectionControls,
|
||||||
|
child: rendered,
|
||||||
|
)
|
||||||
|
: rendered,
|
||||||
if (!textOnly) ReactionRow(event),
|
if (!textOnly) ReactionRow(event),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
import "package:nexus/controllers/rooms.dart";
|
||||||
import "package:nexus/widgets/appbar.dart";
|
import "package:nexus/widgets/appbar.dart";
|
||||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
import "package:nexus/widgets/room_menu_button.dart";
|
import "package:nexus/widgets/expandable_image.dart";
|
||||||
import "package:nexus/widgets/room_summary_dialog.dart";
|
import "package:nexus/widgets/linkified_text.dart";
|
||||||
|
import "package:nexus/widgets/room_menu.dart";
|
||||||
|
|
||||||
final class const RoomAppbar({
|
final class const RoomAppbar({
|
||||||
required final String? roomId,
|
required final String? roomId,
|
||||||
|
|
@ -24,19 +25,61 @@ final class const RoomAppbar({
|
||||||
: ref.watch(RoomsController.provider.select((value) => value[roomId!]));
|
: ref.watch(RoomsController.provider.select((value) => value[roomId!]));
|
||||||
|
|
||||||
return Appbar(
|
return Appbar(
|
||||||
onTap: room?.metadata == null
|
onTap: room == null
|
||||||
? null
|
? null
|
||||||
: () => showDialog(
|
: () => showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => RoomSummaryDialog(
|
builder: (context) => Dialog(
|
||||||
.new(
|
constraints: .loose(.fromWidth(400)),
|
||||||
roomId: room!.metadata!.id,
|
child: Padding(
|
||||||
joinedMembers:
|
padding: .all(24),
|
||||||
room.metadata!.lazyLoadSummary?.joinedMemberCount,
|
child: SingleChildScrollView(
|
||||||
avatarUrl: room.metadata?.avatar,
|
child: Column(
|
||||||
canonicalAlias: room.metadata?.canonicalAlias,
|
mainAxisSize: .min,
|
||||||
name: room.metadata?.name,
|
crossAxisAlignment: .start,
|
||||||
topic: room.metadata?.topic,
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
spacing: 12,
|
||||||
|
mainAxisSize: .min,
|
||||||
|
children: [
|
||||||
|
if (room.metadata?.avatar != null)
|
||||||
|
ExpandableImage(
|
||||||
|
room.metadata?.avatar == null
|
||||||
|
? null
|
||||||
|
: .new(mxc: room.metadata!.avatar!),
|
||||||
|
child: AvatarOrHash(
|
||||||
|
room.metadata?.avatar,
|
||||||
|
room.metadata?.name ?? "Unnamed Room",
|
||||||
|
height: 64,
|
||||||
|
fallback: Icon(Icons.numbers),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
room.metadata?.name ?? "Unnamed Room",
|
||||||
|
overflow: .ellipsis,
|
||||||
|
maxLines: 3,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.headlineSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (room.metadata?.topic?.isNotEmpty == true)
|
||||||
|
LinkifiedText(
|
||||||
|
room.metadata!.topic!,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge
|
||||||
|
?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -46,7 +89,7 @@ final class const RoomAppbar({
|
||||||
: AvatarOrHash(
|
: AvatarOrHash(
|
||||||
room.metadata?.avatar,
|
room.metadata?.avatar,
|
||||||
room.metadata?.name ?? "Unnamed Room",
|
room.metadata?.name ?? "Unnamed Room",
|
||||||
dimension: 24,
|
height: 24,
|
||||||
fallback: Icon(Icons.numbers),
|
fallback: Icon(Icons.numbers),
|
||||||
)
|
)
|
||||||
: DrawerButton(onPressed: onOpenDrawer),
|
: DrawerButton(onPressed: onOpenDrawer),
|
||||||
|
|
@ -72,10 +115,9 @@ final class const RoomAppbar({
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: .new(
|
actions: room == null
|
||||||
room == null
|
? .new()
|
||||||
? []
|
: .new([
|
||||||
: [
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: onOpenPinnedMessagesList?.call,
|
onPressed: onOpenPinnedMessagesList?.call,
|
||||||
icon: Icon(Icons.push_pin),
|
icon: Icon(Icons.push_pin),
|
||||||
|
|
@ -86,9 +128,8 @@ final class const RoomAppbar({
|
||||||
tooltip: "Open member list",
|
tooltip: "Open member list",
|
||||||
icon: Icon(Icons.people),
|
icon: Icon(Icons.people),
|
||||||
),
|
),
|
||||||
RoomMenuButton(room),
|
RoomMenu(room),
|
||||||
],
|
]),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
592
lib/widgets/room_chat.dart
Normal file
592
lib/widgets/room_chat.dart
Normal file
|
|
@ -0,0 +1,592 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:flutter/services.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:measure_size/measure_size.dart";
|
||||||
|
import "package:nexus/controllers/client.dart";
|
||||||
|
import "package:nexus/controllers/client_state.dart";
|
||||||
|
import "package:nexus/controllers/member_list_opened.dart";
|
||||||
|
import "package:nexus/controllers/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/models/content/message.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
|
import "package:nexus/models/relation_type.dart";
|
||||||
|
import "package:nexus/widgets/composer/composer.dart";
|
||||||
|
import "package:nexus/widgets/emoji_picker.dart";
|
||||||
|
import "package:nexus/widgets/pinned_events_drawer.dart";
|
||||||
|
import "package:nexus/widgets/renderers/event.dart";
|
||||||
|
import "package:nexus/widgets/member_list.dart";
|
||||||
|
import "package:nexus/widgets/room_appbar.dart";
|
||||||
|
import "package:nexus/widgets/highlight_wrapper.dart";
|
||||||
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
|
import "package:nexus/main.dart";
|
||||||
|
import "package:nexus/widgets/loading.dart";
|
||||||
|
import "package:super_sliver_list/super_sliver_list.dart";
|
||||||
|
|
||||||
|
final class const RoomChat({
|
||||||
|
required final String? roomId,
|
||||||
|
required final bool isDesktop,
|
||||||
|
required final bool showMembersByDefault,
|
||||||
|
super.key,
|
||||||
|
}) extends HookConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final relatedEvent = useState<Event?>(null);
|
||||||
|
final relationType = useState(RelationType.reply);
|
||||||
|
final highlightedEvent = useState<String?>(null);
|
||||||
|
|
||||||
|
final composerSize = useState<double>(64);
|
||||||
|
|
||||||
|
final userId = ref.watch(ClientStateController.provider)?.userId;
|
||||||
|
final memberListOpened = ref
|
||||||
|
.watch(MemberListOpenedController.provider)
|
||||||
|
.requireValue;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
final nothing = Center(
|
||||||
|
child: Text(
|
||||||
|
"Nothing to see here...",
|
||||||
|
style: theme.textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (userId == null || this.roomId == null) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: RoomAppbar(
|
||||||
|
roomId: this.roomId,
|
||||||
|
isDesktop: isDesktop,
|
||||||
|
onOpenDrawer: () => Scaffold.of(context).openDrawer(),
|
||||||
|
),
|
||||||
|
body: nothing,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final roomId = this.roomId!;
|
||||||
|
|
||||||
|
final controllerProvider = RoomChatController.provider(roomId);
|
||||||
|
final notifier = ref.watch(controllerProvider.notifier);
|
||||||
|
|
||||||
|
final client = ref.watch(ClientController.provider.notifier);
|
||||||
|
|
||||||
|
final listController = useRef(ListController());
|
||||||
|
final scrollController = useScrollController();
|
||||||
|
final controllerData = ref.watch(controllerProvider);
|
||||||
|
|
||||||
|
final topEventBeforeLoad = useState<String?>(null);
|
||||||
|
final hasMore = useState<bool>(true);
|
||||||
|
|
||||||
|
Future<void> jumpToId(String eventId) async {
|
||||||
|
final index =
|
||||||
|
controllerData.value?.indexWhere(
|
||||||
|
(element) => element.eventId == eventId,
|
||||||
|
) ??
|
||||||
|
-1;
|
||||||
|
if (index == -1) return;
|
||||||
|
|
||||||
|
listController.value.animateToItem(
|
||||||
|
index: index,
|
||||||
|
scrollController: scrollController,
|
||||||
|
alignment: 0.5,
|
||||||
|
duration: (_) => .new(milliseconds: 700),
|
||||||
|
curve: (_) => Curves.easeInOut,
|
||||||
|
);
|
||||||
|
highlightedEvent.value = eventId;
|
||||||
|
await Future.delayed(.new(seconds: 1), () {
|
||||||
|
if (highlightedEvent.value == eventId) {
|
||||||
|
highlightedEvent.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadOlder() async {
|
||||||
|
if (controllerData case AsyncData(:final value?)) {
|
||||||
|
topEventBeforeLoad.value = value.firstOrNull?.eventId;
|
||||||
|
hasMore.value = await notifier.loadOlder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
ref
|
||||||
|
.read(controllerProvider.future)
|
||||||
|
.then(
|
||||||
|
(_) => WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (scrollController.hasClients) {
|
||||||
|
scrollController.jumpTo(
|
||||||
|
scrollController.position.maxScrollExtent - .000001,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [scrollController.hasClients]);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (controllerData case AsyncData(:final value?)
|
||||||
|
when scrollController.hasClients) {
|
||||||
|
if (topEventBeforeLoad.value != null) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (scrollController.hasClients) {
|
||||||
|
final index = value.indexWhere(
|
||||||
|
(event) => event.eventId == topEventBeforeLoad.value,
|
||||||
|
);
|
||||||
|
if (index != -1) {
|
||||||
|
listController.value.jumpToItem(
|
||||||
|
index: index,
|
||||||
|
scrollController: scrollController,
|
||||||
|
alignment: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
topEventBeforeLoad.value = null;
|
||||||
|
});
|
||||||
|
} else if (scrollController.position.atEdge &&
|
||||||
|
scrollController.position.pixels != 0) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (scrollController.hasClients) {
|
||||||
|
scrollController.jumpTo(
|
||||||
|
scrollController.position.maxScrollExtent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [controllerData]);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
Future<void> listener() async {
|
||||||
|
if (!scrollController.hasClients || !scrollController.position.atEdge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final room = ref.watch(
|
||||||
|
RoomsController.provider.select((value) => value[roomId]),
|
||||||
|
);
|
||||||
|
if (room == null) return;
|
||||||
|
|
||||||
|
if (scrollController.position.pixels == 0) {
|
||||||
|
if (room.hasMore) {
|
||||||
|
await loadOlder();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await client.markRead(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollController.addListener(listener);
|
||||||
|
return () => scrollController.removeListener(listener);
|
||||||
|
}, [roomId, controllerData]);
|
||||||
|
|
||||||
|
final composerNode = useFocusNode(
|
||||||
|
onKeyEvent: (_, event) {
|
||||||
|
if (event is KeyDownEvent && event.logicalKey == .escape) {
|
||||||
|
relatedEvent.value = null;
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
IList<PopupMenuEntry> getEventOptions(Event event) {
|
||||||
|
final danger = theme.colorScheme.error;
|
||||||
|
final isSentByMe = event.sender == userId;
|
||||||
|
|
||||||
|
final recentEmoji = ref.watch(RecentEmojiController.provider);
|
||||||
|
|
||||||
|
return [
|
||||||
|
if (ref.watch(
|
||||||
|
PowerLevelController.provider(
|
||||||
|
.new(eventType: .reaction, roomId: roomId),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
PopupMenuItem(
|
||||||
|
enabled: false,
|
||||||
|
child: IconTheme(
|
||||||
|
data: theme.iconTheme,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
...{
|
||||||
|
...recentEmoji.map((entry) => entry.emoji),
|
||||||
|
"👍",
|
||||||
|
"🤣",
|
||||||
|
"😭",
|
||||||
|
"🤔",
|
||||||
|
}
|
||||||
|
.toIList()
|
||||||
|
.sublist(0, 4)
|
||||||
|
.map(
|
||||||
|
(emoji) => IconButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
await notifier
|
||||||
|
.sendReaction(emoji, event)
|
||||||
|
.onError(showError);
|
||||||
|
|
||||||
|
ref
|
||||||
|
.watch(RecentEmojiController.provider.notifier)
|
||||||
|
.add(emoji)
|
||||||
|
.onError(showError);
|
||||||
|
},
|
||||||
|
icon: Text(emoji),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.emoji_emotions),
|
||||||
|
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: More adaptive, e.g. dialog on desktop
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
showModalBottomSheet(
|
||||||
|
isScrollControlled: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) => EmojiPicker(
|
||||||
|
onSelection: (value) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
notifier
|
||||||
|
.sendReaction(value, event)
|
||||||
|
.onError(showError);
|
||||||
|
},
|
||||||
|
allowFreeText: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (ref.watch(
|
||||||
|
PowerLevelController.provider(
|
||||||
|
.new(eventType: .message, roomId: roomId),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () {
|
||||||
|
relatedEvent.value = event;
|
||||||
|
relationType.value = .reply;
|
||||||
|
composerNode.requestFocus();
|
||||||
|
},
|
||||||
|
child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")),
|
||||||
|
),
|
||||||
|
if (event.content is MessageContent && isSentByMe)
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () {
|
||||||
|
relatedEvent.value = event;
|
||||||
|
relationType.value = .edit;
|
||||||
|
composerNode.requestFocus();
|
||||||
|
},
|
||||||
|
child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
|
||||||
|
),
|
||||||
|
if (ref.watch(
|
||||||
|
PowerLevelController.provider(
|
||||||
|
.state(eventType: .pinnedEvents, roomId: roomId),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
switch (ref
|
||||||
|
.watch(PinnedIdsController.provider(roomId))
|
||||||
|
.contains(event.eventId)) {
|
||||||
|
bool isPinned => PopupMenuItem(
|
||||||
|
onTap: () async {
|
||||||
|
try {
|
||||||
|
final notifier = ref.read(
|
||||||
|
PinnedIdsController.provider(roomId).notifier,
|
||||||
|
);
|
||||||
|
if (isPinned) {
|
||||||
|
await notifier.removePin(event.eventId);
|
||||||
|
} else {
|
||||||
|
await notifier.addPin(event.eventId);
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
showError(error, stackTrace);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.push_pin),
|
||||||
|
title: Text(isPinned == true ? "Unpin Event" : "Pin Event"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () async {
|
||||||
|
final room = ref.watch(
|
||||||
|
RoomsController.provider.select((value) => value[roomId]),
|
||||||
|
);
|
||||||
|
if (room == null) return;
|
||||||
|
|
||||||
|
final vias = ref.watch(ViaController.provider(room));
|
||||||
|
|
||||||
|
await Clipboard.setData(
|
||||||
|
ClipboardData(
|
||||||
|
text:
|
||||||
|
"matrix:roomid/${room.metadata?.id.substring(1)}/e/${event.eventId}$vias",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")),
|
||||||
|
),
|
||||||
|
if (event.content case MessageContent(:final body?))
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () async {
|
||||||
|
await Clipboard.setData(ClipboardData(text: body));
|
||||||
|
},
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.copy),
|
||||||
|
title: Text("Copy Text"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (ref.watch(
|
||||||
|
PowerLevelController.provider(
|
||||||
|
.redaction(targetUser: event.sender, roomId: roomId),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () => showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => HookBuilder(
|
||||||
|
builder: (_) {
|
||||||
|
final deleteReasonController = useTextEditingController();
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text("Delete Message"),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: .min,
|
||||||
|
crossAxisAlignment: .start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Are you sure you want to delete this message? This can not be reversed.",
|
||||||
|
),
|
||||||
|
SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: deleteReasonController,
|
||||||
|
textCapitalization: .sentences,
|
||||||
|
decoration: .new(
|
||||||
|
labelText: "Reason for deletion (optional)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
|
child: Text("Cancel"),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
await notifier
|
||||||
|
.deleteMessage(
|
||||||
|
event,
|
||||||
|
reason: deleteReasonController.text,
|
||||||
|
)
|
||||||
|
.onError(showError);
|
||||||
|
},
|
||||||
|
child: Text("Delete"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.delete, color: danger),
|
||||||
|
title: Text("Delete", style: .new(color: danger)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () => showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => HookBuilder(
|
||||||
|
builder: (_) {
|
||||||
|
final reasonController = useTextEditingController();
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text("Report"),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: .min,
|
||||||
|
crossAxisAlignment: .start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Report this event to your server administrators, who can take action like banning this server or room.",
|
||||||
|
),
|
||||||
|
|
||||||
|
SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: reasonController,
|
||||||
|
textCapitalization: .sentences,
|
||||||
|
decoration: .new(
|
||||||
|
labelText: "Reason for report (optional)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
|
child: Text("Cancel"),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
client.reportEvent(
|
||||||
|
.new(
|
||||||
|
roomId: roomId,
|
||||||
|
eventId: event.eventId,
|
||||||
|
reason: reasonController.text.isEmpty
|
||||||
|
? null
|
||||||
|
: reasonController.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text("Report"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.report, color: danger),
|
||||||
|
title: Text("Report", style: .new(color: danger)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
].toIList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
endDrawer: PinnedEventsDrawer(
|
||||||
|
roomId,
|
||||||
|
getEventOptions: getEventOptions,
|
||||||
|
jumpToId: jumpToId,
|
||||||
|
),
|
||||||
|
body: Builder(
|
||||||
|
builder: (middleContext) => Scaffold(
|
||||||
|
endDrawer: showMembersByDefault ? null : MemberList(roomId),
|
||||||
|
appBar: RoomAppbar(
|
||||||
|
roomId: roomId,
|
||||||
|
isDesktop: isDesktop,
|
||||||
|
onOpenDrawer: Scaffold.of(context).openDrawer,
|
||||||
|
onOpenMemberList: (thisContext) {
|
||||||
|
ref
|
||||||
|
.watch(MemberListOpenedController.provider.notifier)
|
||||||
|
.set(!memberListOpened);
|
||||||
|
Scaffold.of(thisContext).openEndDrawer();
|
||||||
|
},
|
||||||
|
onOpenPinnedMessagesList: () {
|
||||||
|
Scaffold.of(middleContext).openEndDrawer();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
body: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: Padding(
|
||||||
|
padding: .symmetric(horizontal: 4),
|
||||||
|
child: switch (controllerData) {
|
||||||
|
AsyncData(:final value?) ||
|
||||||
|
AsyncLoading(:final value?) => CustomScrollView(
|
||||||
|
keyboardDismissBehavior:
|
||||||
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
|
controller: scrollController,
|
||||||
|
slivers: [
|
||||||
|
if (hasMore.value)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: .symmetric(vertical: 36),
|
||||||
|
child: Center(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: controllerData is AsyncData
|
||||||
|
? loadOlder
|
||||||
|
: null,
|
||||||
|
child: Text("Load More"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
SuperSliverList.builder(
|
||||||
|
listController: listController.value,
|
||||||
|
itemCount: value.length,
|
||||||
|
itemBuilder: (_, index) {
|
||||||
|
final event = value[index];
|
||||||
|
final previousEvent = value.getOrNull(
|
||||||
|
index - 1,
|
||||||
|
);
|
||||||
|
return HighlightWrapper(
|
||||||
|
EventRenderer(
|
||||||
|
event,
|
||||||
|
onTapReply: () =>
|
||||||
|
jumpToId(event.replyTo!),
|
||||||
|
getEventOptions: getEventOptions,
|
||||||
|
isGrouped:
|
||||||
|
previousEvent?.content
|
||||||
|
is MessageContent &&
|
||||||
|
previousEvent?.redactedBy == null &&
|
||||||
|
previousEvent?.relationType !=
|
||||||
|
"m.replace" &&
|
||||||
|
"${event.sender}${event.pmp?.id}" ==
|
||||||
|
"${previousEvent?.sender}${previousEvent?.pmp?.id}",
|
||||||
|
),
|
||||||
|
isHighlighted:
|
||||||
|
highlightedEvent.value == event.eventId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
SliverPadding(
|
||||||
|
padding: .only(bottom: composerSize.value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
AsyncData() => nothing,
|
||||||
|
AsyncLoading() => Loading(),
|
||||||
|
AsyncError(:final error, :final stackTrace) =>
|
||||||
|
ErrorDialog(error, stackTrace),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: MeasureSize(
|
||||||
|
onChange: (size) => composerSize.value = size.height,
|
||||||
|
child: Composer(
|
||||||
|
roomId,
|
||||||
|
node: composerNode,
|
||||||
|
onSend:
|
||||||
|
(text, {required shouldMention, required tags}) =>
|
||||||
|
notifier
|
||||||
|
.send(
|
||||||
|
text,
|
||||||
|
tags: tags,
|
||||||
|
relationType: relationType.value,
|
||||||
|
shouldMention: shouldMention,
|
||||||
|
relation: relatedEvent.value,
|
||||||
|
)
|
||||||
|
.onError(showError),
|
||||||
|
relationType: relationType.value,
|
||||||
|
relatedEvent: relatedEvent.value,
|
||||||
|
onDismiss: () => relatedEvent.value = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (memberListOpened == true && showMembersByDefault)
|
||||||
|
MemberList(roomId),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:material_ui/material_ui.dart";
|
|
||||||
import "package:nexus/helpers/hooks/chat_scroll.dart";
|
|
||||||
import "package:nexus/models/event.dart";
|
|
||||||
import "package:nexus/widgets/renderers/event.dart";
|
|
||||||
import "package:nexus/widgets/highlight_wrapper.dart";
|
|
||||||
import "package:super_sliver_list/super_sliver_list.dart";
|
|
||||||
|
|
||||||
class const ChatTimeline({
|
|
||||||
required final String roomId,
|
|
||||||
required final ChatScroll scroll,
|
|
||||||
required final JumpToEvent jumpToEvent,
|
|
||||||
required final IList<PopupMenuEntry> Function(Event) getEventOptions,
|
|
||||||
required final int? highlightedEvent,
|
|
||||||
required final double composerHeight,
|
|
||||||
super.key,
|
|
||||||
}) extends StatelessWidget {
|
|
||||||
Widget eventRow(
|
|
||||||
int eventRowId,
|
|
||||||
int? previousEventRowId, {
|
|
||||||
required Key key,
|
|
||||||
}) => HighlightWrapper(
|
|
||||||
EventRenderer(
|
|
||||||
eventRowId,
|
|
||||||
previousEventRowId: previousEventRowId,
|
|
||||||
roomId: roomId,
|
|
||||||
jumpToEvent: jumpToEvent,
|
|
||||||
getEventOptions: getEventOptions,
|
|
||||||
),
|
|
||||||
key: key,
|
|
||||||
isHighlighted: highlightedEvent == eventRowId,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => CustomScrollView(
|
|
||||||
reverse: true,
|
|
||||||
center: scroll.centerKey,
|
|
||||||
keyboardDismissBehavior: .onDrag,
|
|
||||||
controller: scroll.scrollController,
|
|
||||||
slivers: [
|
|
||||||
SliverToBoxAdapter(child: SizedBox(height: composerHeight)),
|
|
||||||
|
|
||||||
SuperSliverList.builder(
|
|
||||||
itemCount: scroll.liveRows.length,
|
|
||||||
itemBuilder: (_, index) => eventRow(
|
|
||||||
scroll.liveRows[index],
|
|
||||||
index > 0
|
|
||||||
? scroll.liveRows.getOrNull(index - 1)
|
|
||||||
: scroll.historyRows.firstOrNull,
|
|
||||||
key: scroll.keyFor(scroll.liveRows[index]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
SuperSliverList.builder(
|
|
||||||
key: scroll.centerKey,
|
|
||||||
itemCount: scroll.historyRows.length,
|
|
||||||
itemBuilder: (_, index) => eventRow(
|
|
||||||
scroll.historyRows[index],
|
|
||||||
scroll.historyRows.getOrNull(index + 1),
|
|
||||||
key: scroll.keyFor(scroll.historyRows[index]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,282 +0,0 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:material_ui/material_ui.dart";
|
|
||||||
import "package:flutter/services.dart";
|
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
|
||||||
import "package:measure_size/measure_size.dart";
|
|
||||||
import "package:nexus/controllers/client.dart";
|
|
||||||
import "package:nexus/controllers/client_state.dart";
|
|
||||||
import "package:nexus/controllers/member_list_opened.dart";
|
|
||||||
import "package:nexus/controllers/rooms.dart";
|
|
||||||
import "package:nexus/controllers/room_chat.dart";
|
|
||||||
import "package:nexus/helpers/hooks/chat_scroll.dart";
|
|
||||||
import "package:nexus/models/event.dart";
|
|
||||||
import "package:nexus/models/relation_type.dart";
|
|
||||||
import "package:nexus/widgets/composer/composer.dart";
|
|
||||||
import "package:nexus/widgets/error_dialog.dart";
|
|
||||||
import "package:nexus/widgets/loading.dart";
|
|
||||||
import "package:nexus/widgets/pinned_events_drawer.dart";
|
|
||||||
import "package:nexus/widgets/member_list.dart";
|
|
||||||
import "package:nexus/widgets/room_appbar.dart";
|
|
||||||
import "package:nexus/main.dart";
|
|
||||||
import "package:nexus/widgets/room_chat/chat_timeline.dart";
|
|
||||||
import "package:nexus/helpers/extensions/build_event_options.dart";
|
|
||||||
import "package:nexus/controllers/jump_request.dart";
|
|
||||||
|
|
||||||
final class const RoomChat({
|
|
||||||
required final String? roomId,
|
|
||||||
required final bool isDesktop,
|
|
||||||
required final bool showMembersByDefault,
|
|
||||||
super.key,
|
|
||||||
}) extends HookConsumerWidget {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final relatedEvent = useState<int?>(null);
|
|
||||||
final relationType = useState(RelationType.reply);
|
|
||||||
final pendingJump = ref.read(JumpRequestController.provider);
|
|
||||||
final contextualEvent = useState<int?>(
|
|
||||||
pendingJump != null && pendingJump.roomId == this.roomId
|
|
||||||
? pendingJump.rowId
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
final highlightedEvent = useState<int?>(null);
|
|
||||||
|
|
||||||
final composerSize = useState<double>(64);
|
|
||||||
|
|
||||||
final userId = ref.watch(ClientStateController.provider)?.userId;
|
|
||||||
final memberListOpened = ref
|
|
||||||
.watch(MemberListOpenedController.provider)
|
|
||||||
.requireValue;
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
final nothing = Center(
|
|
||||||
child: Text(
|
|
||||||
"Nothing to see here...",
|
|
||||||
style: theme.textTheme.headlineMedium,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (userId == null || this.roomId == null) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: RoomAppbar(
|
|
||||||
roomId: this.roomId,
|
|
||||||
isDesktop: isDesktop,
|
|
||||||
onOpenDrawer: Scaffold.of(context).openDrawer,
|
|
||||||
),
|
|
||||||
body: nothing,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final roomId = this.roomId!;
|
|
||||||
|
|
||||||
final jumpRowId = ref.watch(
|
|
||||||
JumpRequestController.provider.select(
|
|
||||||
(request) => request?.roomId == roomId ? request?.rowId : null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final controllerProvider = RoomChatController.provider((
|
|
||||||
roomId: roomId,
|
|
||||||
contextualEvent: contextualEvent.value,
|
|
||||||
));
|
|
||||||
final notifier = ref.watch(controllerProvider.notifier);
|
|
||||||
|
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
|
||||||
|
|
||||||
final controllerData = ref.watch(controllerProvider);
|
|
||||||
|
|
||||||
final scroll = ChatScroll.use(
|
|
||||||
controllerData: controllerData,
|
|
||||||
paginate: notifier.paginate,
|
|
||||||
contextualEvent: contextualEvent,
|
|
||||||
markRead: () async {
|
|
||||||
final room = ref.read(
|
|
||||||
RoomsController.provider.select((rooms) => rooms[roomId]),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (room != null) {
|
|
||||||
await client.markRead(room);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final lastComposerHeight = useRef(composerSize.value);
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
final delta = composerSize.value - lastComposerHeight.value;
|
|
||||||
lastComposerHeight.value = composerSize.value;
|
|
||||||
|
|
||||||
if (delta != 0) {
|
|
||||||
final wasAtBottom = scroll.atBottom;
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
final controller = scroll.scrollController;
|
|
||||||
if (!controller.hasClients) return;
|
|
||||||
|
|
||||||
final position = controller.position;
|
|
||||||
controller.jumpTo(
|
|
||||||
wasAtBottom ? position.minScrollExtent : position.pixels - delta,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [composerSize.value]);
|
|
||||||
|
|
||||||
final composerNode = useFocusNode(
|
|
||||||
onKeyEvent: (_, event) {
|
|
||||||
if (event is KeyDownEvent && event.logicalKey == .escape) {
|
|
||||||
relatedEvent.value = null;
|
|
||||||
return .handled;
|
|
||||||
}
|
|
||||||
|
|
||||||
return .ignored;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> jumpToEvent(int rowId) async {
|
|
||||||
highlightedEvent.value = rowId;
|
|
||||||
|
|
||||||
await scroll.jumpToEvent(rowId);
|
|
||||||
await Future.delayed(.new(milliseconds: 700), () {
|
|
||||||
if (highlightedEvent.value == rowId) {
|
|
||||||
highlightedEvent.value = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
if (jumpRowId == null) return null;
|
|
||||||
|
|
||||||
void check() {
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
if (scroll.scrollController.hasClients) {
|
|
||||||
jumpToEvent(jumpRowId);
|
|
||||||
Future.microtask(
|
|
||||||
() => ref.read(JumpRequestController.provider.notifier).consume(),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => check());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
check();
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [jumpRowId]);
|
|
||||||
|
|
||||||
IList<PopupMenuEntry> getEventOptions(Event event) =>
|
|
||||||
event.buildEventOptions(
|
|
||||||
context: context,
|
|
||||||
ref: ref,
|
|
||||||
roomId: roomId,
|
|
||||||
userId: userId,
|
|
||||||
onRelation: (rowId, type) {
|
|
||||||
relatedEvent.value = rowId;
|
|
||||||
relationType.value = type;
|
|
||||||
composerNode.requestFocus();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
endDrawer: PinnedEventsDrawer(
|
|
||||||
roomId,
|
|
||||||
getEventOptions: getEventOptions,
|
|
||||||
jumpToEvent: jumpToEvent,
|
|
||||||
),
|
|
||||||
body: Builder(
|
|
||||||
builder: (middleContext) => Scaffold(
|
|
||||||
endDrawer: showMembersByDefault ? null : MemberList(roomId),
|
|
||||||
appBar: RoomAppbar(
|
|
||||||
roomId: roomId,
|
|
||||||
isDesktop: isDesktop,
|
|
||||||
onOpenDrawer: Scaffold.of(context).openDrawer,
|
|
||||||
onOpenMemberList: (thisContext) {
|
|
||||||
ref
|
|
||||||
.read(MemberListOpenedController.provider.notifier)
|
|
||||||
.set(!memberListOpened);
|
|
||||||
Scaffold.of(thisContext).openEndDrawer();
|
|
||||||
},
|
|
||||||
onOpenPinnedMessagesList: Scaffold.of(middleContext).openEndDrawer,
|
|
||||||
),
|
|
||||||
body: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: Padding(
|
|
||||||
padding: .symmetric(horizontal: 4),
|
|
||||||
child: switch (controllerData) {
|
|
||||||
AsyncLoading(value: _?) ||
|
|
||||||
AsyncData(value: _) => ChatTimeline(
|
|
||||||
scroll: scroll,
|
|
||||||
roomId: roomId,
|
|
||||||
jumpToEvent: jumpToEvent,
|
|
||||||
getEventOptions: getEventOptions,
|
|
||||||
highlightedEvent: highlightedEvent.value,
|
|
||||||
composerHeight: composerSize.value,
|
|
||||||
),
|
|
||||||
AsyncLoading _ => Loading(),
|
|
||||||
AsyncError(:final error, :final stackTrace) =>
|
|
||||||
ErrorDialog(error, stackTrace),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
right: 16,
|
|
||||||
bottom: composerSize.value,
|
|
||||||
child: IgnorePointer(
|
|
||||||
ignoring: scroll.atBottom,
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
opacity: scroll.atBottom ? 0 : 1,
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
child: FloatingActionButton.small(
|
|
||||||
onPressed: scroll.jumpToBottom,
|
|
||||||
child: const Icon(Icons.keyboard_arrow_down),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
child: MeasureSize(
|
|
||||||
onChange: (size) => composerSize.value = size.height,
|
|
||||||
child: Composer(
|
|
||||||
roomId,
|
|
||||||
node: composerNode,
|
|
||||||
onSend:
|
|
||||||
(text, {required shouldMention, required tags}) =>
|
|
||||||
notifier
|
|
||||||
.send(
|
|
||||||
text,
|
|
||||||
tags: tags,
|
|
||||||
relationType: relationType.value,
|
|
||||||
shouldMention: shouldMention,
|
|
||||||
relatedEventRowId: relatedEvent.value,
|
|
||||||
)
|
|
||||||
.onError(showError),
|
|
||||||
relationType: relationType.value,
|
|
||||||
relatedEvent: relatedEvent.value,
|
|
||||||
onDismiss: () {
|
|
||||||
relatedEvent.value = null;
|
|
||||||
composerNode.requestFocus();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (memberListOpened == true && showMembersByDefault)
|
|
||||||
MemberList(roomId),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,318 +0,0 @@
|
||||||
import "dart:math";
|
|
||||||
|
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
|
||||||
import "package:material_ui/material_ui.dart";
|
|
||||||
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
|
|
||||||
import "package:nexus/helpers/extensions/get_all_child_rooms.dart";
|
|
||||||
import "package:nexus/models/nav_page.dart";
|
|
||||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
|
||||||
import "package:nexus/widgets/divider_widget.dart";
|
|
||||||
import "package:nexus/widgets/error_dialog.dart";
|
|
||||||
import "package:nexus/widgets/pages/settings.dart";
|
|
||||||
import "package:nexus/widgets/join_dialog.dart";
|
|
||||||
import "package:nexus/widgets/room_menu_button.dart";
|
|
||||||
import "package:nexus/controllers/key.dart";
|
|
||||||
import "package:nexus/controllers/spaces.dart";
|
|
||||||
import "package:nexus/models/room.dart";
|
|
||||||
import "package:collection/collection.dart";
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
|
|
||||||
// Needed for navigation_rail_m3e (#65).
|
|
||||||
import "package:flutter/material.dart" as old_mat;
|
|
||||||
|
|
||||||
List<NavigationRailM3EDestination> roomDestinations(
|
|
||||||
IList<Room> rooms, {
|
|
||||||
required bool isDm,
|
|
||||||
}) => [
|
|
||||||
for (final room in rooms)
|
|
||||||
.new(
|
|
||||||
label: room.metadata?.name ?? "Unnamed Room",
|
|
||||||
badgeCount: switch (room.metadata?.unreadNotifications) {
|
|
||||||
0 || null => room.metadata?.unreadMessages == 0 ? null : 0,
|
|
||||||
int unread => unread,
|
|
||||||
},
|
|
||||||
icon: AvatarOrHash(
|
|
||||||
room.metadata?.avatar,
|
|
||||||
room.metadata?.name ?? "Unnamed Room",
|
|
||||||
fallback: isDm ? null : const Icon(Icons.numbers),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
class const RoomList({super.key}) extends ConsumerWidget implements NavPage {
|
|
||||||
@override
|
|
||||||
String get title => "Rooms";
|
|
||||||
|
|
||||||
@override
|
|
||||||
IconData get icon => Icons.list;
|
|
||||||
|
|
||||||
// TODO: Show unread badge here and on sidebar hamburger
|
|
||||||
@override
|
|
||||||
Future<int>? badgeBuilder(WidgetRef ref) => null;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final spacesAsync = ref.watch(SpacesController.provider);
|
|
||||||
if (spacesAsync.value == null) {
|
|
||||||
return switch (spacesAsync) {
|
|
||||||
AsyncError(:final error, :final stackTrace) => ErrorDialog(
|
|
||||||
error,
|
|
||||||
stackTrace,
|
|
||||||
),
|
|
||||||
_ => const Center(child: CircularProgressIndicator()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return const Row(
|
|
||||||
children: [
|
|
||||||
SpacesRail(),
|
|
||||||
Expanded(child: RoomsPane()),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class const SpacesRail({super.key}) extends ConsumerWidget {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final spaces = ref.watch(
|
|
||||||
SpacesController.provider.select((async) => async.requireValue),
|
|
||||||
);
|
|
||||||
final selectedSpaceId = ref.watch(
|
|
||||||
KeyController.provider(KeyController.spaceKey)
|
|
||||||
.select((async) => async.requireValue),
|
|
||||||
);
|
|
||||||
|
|
||||||
return MaterialUiCompatibilityBridge(
|
|
||||||
child: Builder(
|
|
||||||
builder: (context) => old_mat.Theme(
|
|
||||||
data: old_mat.Theme.of(context).copyWith(
|
|
||||||
extensions: [
|
|
||||||
NavigationRailM3ETheme(
|
|
||||||
itemCollapsedHeight: 48,
|
|
||||||
itemVerticalGap: 0,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Container(
|
|
||||||
color: NavigationRailTokensAdapter(context).containerColor,
|
|
||||||
padding: .only(top: 16),
|
|
||||||
child: NavigationRailM3E(
|
|
||||||
type: .alwaysCollapse,
|
|
||||||
labelBehavior: .alwaysHide,
|
|
||||||
scrollable: true,
|
|
||||||
onDestinationSelected: (value) {
|
|
||||||
ref
|
|
||||||
.read(
|
|
||||||
KeyController.provider(KeyController.spaceKey).notifier,
|
|
||||||
)
|
|
||||||
.set(spaces[value].id);
|
|
||||||
ref
|
|
||||||
.read(
|
|
||||||
KeyController.provider(KeyController.roomKey).notifier,
|
|
||||||
)
|
|
||||||
.set(spaces[value].children.firstOrNull?.metadata?.id);
|
|
||||||
},
|
|
||||||
sections: [
|
|
||||||
.new(
|
|
||||||
destinations: spaces.map((space) {
|
|
||||||
final notifications = space.allChildRooms.fold(
|
|
||||||
0,
|
|
||||||
(sum, room) =>
|
|
||||||
sum + (room.metadata?.unreadNotifications ?? 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
return NavigationRailM3EDestination(
|
|
||||||
badgeCount: notifications > 0
|
|
||||||
? notifications
|
|
||||||
: space.allChildRooms.any(
|
|
||||||
(room) => room.metadata?.unreadMessages != 0,
|
|
||||||
)
|
|
||||||
? 0
|
|
||||||
: null,
|
|
||||||
short: true,
|
|
||||||
icon: AvatarOrHash(
|
|
||||||
dimension: 28,
|
|
||||||
space.room?.metadata?.avatar,
|
|
||||||
fallback: space.icon == null ? null : Icon(space.icon),
|
|
||||||
space.title,
|
|
||||||
),
|
|
||||||
label: space.title,
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
selectedIndex: max(
|
|
||||||
0,
|
|
||||||
spaces.indexWhere((space) => space.id == selectedSpaceId),
|
|
||||||
),
|
|
||||||
trailingAtBottom: true,
|
|
||||||
trailing: Padding(
|
|
||||||
padding: .only(top: 8),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
PopupMenuButton(
|
|
||||||
itemBuilder: (_) => [
|
|
||||||
PopupMenuItem(
|
|
||||||
onTap: () => showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => JoinDialog(ref),
|
|
||||||
),
|
|
||||||
child: const ListTile(
|
|
||||||
title: Text("Join an existing room (or space)"),
|
|
||||||
leading: Icon(Icons.numbers),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
onTap: null,
|
|
||||||
child: ListTile(
|
|
||||||
title: Text("Create a new room"),
|
|
||||||
leading: Icon(Icons.add),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
),
|
|
||||||
const IconButton(
|
|
||||||
tooltip: "Explore other rooms",
|
|
||||||
onPressed: null,
|
|
||||||
icon: Icon(Icons.explore),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: "Open settings",
|
|
||||||
onPressed: () => showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => SettingsPage(),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.settings),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class const RoomsPane({super.key}) extends ConsumerWidget {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final selectedSpaceId = ref.watch(
|
|
||||||
KeyController.provider(KeyController.spaceKey)
|
|
||||||
.select((async) => async.requireValue),
|
|
||||||
);
|
|
||||||
final selectedRoomId = ref.watch(
|
|
||||||
KeyController.provider(KeyController.roomKey)
|
|
||||||
.select((async) => async.requireValue),
|
|
||||||
);
|
|
||||||
final selectedSpace = ref.watch(
|
|
||||||
SpacesController.provider.select((async) {
|
|
||||||
final spaces = async.requireValue;
|
|
||||||
return spaces.firstWhereOrNull(
|
|
||||||
(space) => space.id == selectedSpaceId,
|
|
||||||
) ??
|
|
||||||
spaces.first;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
final isDm = selectedSpace.id == "dms";
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
appBar: AppBar(
|
|
||||||
leading: AvatarOrHash(
|
|
||||||
selectedSpace.room?.metadata?.avatar,
|
|
||||||
fallback: selectedSpace.icon == null
|
|
||||||
? null
|
|
||||||
: Icon(selectedSpace.icon),
|
|
||||||
selectedSpace.title,
|
|
||||||
),
|
|
||||||
title: Text(selectedSpace.title, overflow: .ellipsis),
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
actions: [
|
|
||||||
RoomMenuButton(
|
|
||||||
selectedSpace.room,
|
|
||||||
children: selectedSpace.allChildRooms,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: MaterialUiCompatibilityBridge(
|
|
||||||
child: Builder(
|
|
||||||
builder: (context) => old_mat.Theme(
|
|
||||||
data: old_mat.Theme.of(context).copyWith(
|
|
||||||
extensions: [
|
|
||||||
NavigationRailM3ETheme(
|
|
||||||
itemExpandedHeight: 48,
|
|
||||||
iconLabelGap: 16,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: NavigationRailM3E(
|
|
||||||
expandedWidth: double.infinity,
|
|
||||||
scrollable: true,
|
|
||||||
background: Colors.transparent,
|
|
||||||
type: .alwaysExpand,
|
|
||||||
selectedIndex: max(
|
|
||||||
0,
|
|
||||||
selectedSpace.allChildRooms.indexWhere(
|
|
||||||
(room) => room.metadata?.id == selectedRoomId,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
sections: [
|
|
||||||
.new(
|
|
||||||
header: selectedSpace.room == null
|
|
||||||
? null
|
|
||||||
: const DividerWidget(Text("Rooms")),
|
|
||||||
destinations: roomDestinations(
|
|
||||||
selectedSpace.children,
|
|
||||||
isDm: isDm,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
for (final subSpace in selectedSpace.subSpaces)
|
|
||||||
.new(
|
|
||||||
header: DividerWidget(
|
|
||||||
Row(
|
|
||||||
mainAxisSize: .min,
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
|
||||||
if (subSpace.room.metadata?.avatar != null)
|
|
||||||
AvatarOrHash(
|
|
||||||
subSpace.room.metadata?.avatar,
|
|
||||||
subSpace.room.metadata?.name ?? "Unnamed Room",
|
|
||||||
dimension: 16,
|
|
||||||
),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
subSpace.room.metadata?.name ?? "Unnamed Space",
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: .ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
destinations: roomDestinations(
|
|
||||||
subSpace.children,
|
|
||||||
isDm: isDm,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onDestinationSelected: (value) {
|
|
||||||
ref
|
|
||||||
.read(
|
|
||||||
KeyController.provider(KeyController.roomKey).notifier,
|
|
||||||
)
|
|
||||||
.set(selectedSpace.allChildRooms[value].metadata?.id);
|
|
||||||
if (Navigator.of(context).canPop()) {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue