forked from Nexus/nexus
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
0a09a4ee0a |
|||
|
62e200f305 |
|||
|
dd01ad9d74 |
|||
|
853116ab2f |
|||
|
53d9c2b1e6 |
|||
|
401b527714 |
|||
|
3754bc2feb |
|||
|
868023a4a1 |
|||
|
9e6951818a |
|||
|
0743c4cfc6 |
|||
|
6336764da0 |
|||
|
3a39f1c5ac |
|||
|
6e2d56ec75 |
|||
|
4e140e9fc7 |
|||
|
e5dd53ed6c |
|||
|
c0f4c053b0 |
|||
|
ee36a9b1c8 |
|||
|
f5371382d7 |
|||
|
2937c241e0 |
|||
|
9dcddb3fbe |
|||
|
3014a9d029 |
|||
|
4a9f151d92 |
|||
|
d4ae5ebccf |
|||
|
850bd33b18 |
|||
|
8fbfcde9c5 |
|||
|
e0b5dc58d0 |
|||
|
ffbf2722f7 |
|||
|
3e1552f246 |
|||
|
44a1ec667e |
|||
|
479279b68e |
|||
|
b0dd452926 |
|||
|
fcfe4eed1e |
|||
|
b390dbbbe1 |
|||
|
8e51852ae3 |
|||
|
48c04d9b3e |
|||
|
7f30150e95 |
|||
|
c8e1aef74b |
|||
|
5ee3449f02 |
|||
|
1715ee785e |
|||
|
e38307657d |
|||
|
e14ab3e708 |
|||
|
f5645db6ea |
|||
|
0fcbc275cd |
|||
|
a54004736b |
|||
|
427e06be3e |
|||
|
a1a15af30d |
|||
| 800fb5566d | |||
|
80197aff00 |
|||
|
78162efbde |
|||
|
a2906746b9 |
|||
|
6659f21ceb |
|||
|
a065b823bb |
|||
|
765e96cce4 |
|||
|
9cf16db669 |
|||
|
2c8a4da2a8 |
|||
|
cb426cd27e |
|||
|
bfd259543d |
|||
|
9f0b80072d |
|||
|
b6ee5aec64 |
|||
|
deac9ef24e |
|||
|
e24e99fd8e |
|||
|
4580110da0 |
|||
|
d1724932b2 |
|||
|
a6fe80fea2 |
|||
|
9f45cbc1e7 |
|||
|
eca2b0b558 |
|||
|
4e6ba571fc |
|||
|
200e03f082 |
|||
|
8180338d99 |
|||
|
72c5e7d819 |
|||
|
254b7e134f |
|||
|
ef25e765d4 |
|||
|
58a27fdd9e |
|||
|
6d42477041 |
|||
|
2f17f5b6c6 |
|||
|
1380233f2e |
|||
|
a3a3781252 |
|||
|
81851bdc42 |
|||
|
f19a51939c |
|||
|
0ad85f24d5 |
|||
|
1c2ba2971b |
|||
|
fb0af571ed |
|||
|
9cd43f4e3c |
|||
|
0fa0f57738 |
|||
|
00f22d46b7 |
|||
|
23046f08a9 |
|||
|
784e5254e2 |
|||
|
7193ac0dba |
|||
|
7ab4b5e59f |
|||
|
c23dc19c6e |
|||
|
0b79aac7af |
|||
|
e4f290eaa6 |
|||
|
d309754c60 |
|||
|
a5dedb0d38 |
|||
|
659e76b8f8 |
|||
|
ab3eda0beb |
|||
|
b71a7f0bd6 |
|||
|
8d944831f5 |
|||
|
9117663c48 |
|||
|
ba258c2fcd |
|||
|
485383e99c |
|||
|
ed84410039 |
|||
|
df592b3f38 |
|||
|
d47c4ae15c |
|||
|
d308ef98cb |
|||
|
f082a75d9c |
|||
|
36f61a62d8 |
|||
|
27137c488d |
|||
|
ce5c7b972b |
|||
|
76d78596cd |
|||
|
ed2d08ed40 |
|||
|
24e516e6dd |
|||
|
66a437094a |
|||
|
d37ec2878b |
|||
|
dbf30c3706 |
|||
|
2d619bf1ad |
|||
|
07700c1eff |
|||
|
a5fef138dd |
|||
|
7353955d4e |
|||
|
012ecd9249 |
|||
|
8fda43ccf8 |
|||
|
3a33bf7bfe |
|||
|
9237a3dbed |
|||
|
30a5fa100c |
|||
|
f198888635 |
|||
|
be945031cd |
|||
|
ad525b338c |
|||
|
0010160714 |
|||
|
f741a6790d |
|||
|
7be1dfe0bc |
|||
|
08dcfdab75 |
|||
|
c81000d6c6 |
|||
|
3c6c610f7c |
|||
|
61c5aa94c2 |
|||
|
02ba9180db |
|||
|
3c6f9f5da2 |
|||
|
1d4af179d4 |
|||
|
9974a6c225 |
|||
|
9c83ae573a |
|||
|
66cfb0e2c6 |
|||
|
02c486e1b3 |
|||
|
b694da31f9 |
|||
|
f931615890 |
|||
|
ae8fa17db3 |
|||
|
a63f4674fd |
|||
|
a46d93dfc5 |
|||
|
294723fdfb |
|||
|
5e3c7ffa68 |
|||
|
1fd9810b83 |
|||
|
d119370816 |
|||
|
ffaa516dcd |
|||
|
e480e6193b |
|||
|
e41efd92c1 |
|||
|
b253fc1d96 |
|||
|
967fe66a1b |
|||
|
658fa562cf |
|||
|
fe33ae2e97 |
|||
|
bcb085c035 |
|||
|
eae79d943d |
|||
|
bdac4a11d7 |
|||
|
2ed2443298 |
|||
|
7f71adab4c |
|||
|
0a3ec830a3 |
|||
|
650f1ac8e4 |
|||
|
af5f238db0 |
|||
|
006008f61d |
|||
|
f23a29e7c1 |
|||
|
493776e3dd |
|||
|
25afcf4acd |
|||
|
e54b059bc9 |
|||
|
b8b074b229 |
|||
|
9ffad93469 |
|||
|
3875c3a14a |
|||
|
a86748e85f |
|||
|
19363baa8d |
|||
|
41bdb04b72 |
|||
|
8da46dffe4 |
|||
|
49f2dc55b8 |
|||
|
f9ff04405f |
|||
|
ecb965994a |
|||
|
36917bbea5 |
|||
|
684d6bad82 |
|||
|
45d69b0b9f |
|||
|
3b2cf6b2a4 |
|||
|
a61cac130b |
|||
|
ec30adc0e3 |
|||
|
a63d182c50 |
|||
|
64a8edda7d |
|||
|
2e85b26511 |
|||
|
a91b45be31 |
|||
|
d9062f68c4 |
|||
|
b3b873ffce |
|||
|
ab69bfe553 |
|||
|
98108d013e |
|||
|
bdf8fc6ce1 |
|||
|
b738c1d0e0 |
|||
|
93b832839b |
|||
|
301bcd94f2 |
|||
|
da7f2dcf4e |
|||
|
b92aaed610 |
|||
|
1e4aab08ad |
|||
| 1c4bf99be2 | |||
|
597112f5d3 |
|||
|
c8b9654219 |
|||
|
9bc2d9955a |
|||
|
ead463262c |
|||
|
281bab0202 |
|||
|
9e024fb6f2 |
|||
|
a0036bdda3 |
|||
|
bd7cd0b10c |
|||
|
ec0934110f |
|||
|
1cfda9c68b |
|||
|
1758815cc9 |
|||
|
9f4702b9d1 |
|||
|
195d212d68 |
|||
|
a698cf8c28 |
|||
|
338d5dfee9 |
|||
|
efa3681816 |
|||
|
3824ca1c13 |
|||
|
01566894a6 |
|||
|
64e548f0c5 |
|||
|
4bde7875fb |
|||
|
07f8e58a4c |
|||
|
a220e27877 |
|||
|
5304f22456 |
|||
|
e5b39507c8 |
|||
|
d37538b69f |
|||
|
e300f4efc9 |
|||
|
bab699658f |
|||
|
1c1f585362 |
|||
|
c90a1c41b0 |
|||
|
9f070fcdba |
|||
|
d712bb3193 |
|||
|
3217c17ea0 |
|||
|
031fbe326c |
|||
|
0149aa2f84 |
|||
|
a1efdb0f46 |
|||
| 389b65ae61 | |||
|
3429a3ed98 |
|||
|
7ab92f76bf |
|||
|
aecfac9533 |
|||
|
c7b89b8e56 |
|||
|
721727d48c |
|||
|
004c3bcd0d |
|||
|
943cd85e22 |
|||
|
09904536e7 |
|||
|
856072e0b0 |
|||
|
110caff170 |
|||
|
eeb2e2f54b |
|||
|
529a2e35c4 |
|||
|
6ad4657d76 |
|||
|
38fd19ad1a |
|||
|
0636366359 |
|||
|
efe9262a97 |
|||
|
2763a0fdf5 |
|||
|
a5da44fdea |
|||
|
ccca02a0bc |
|||
|
721349ca21 |
|||
|
5d994bf2bb |
|||
|
a90bf00f18 |
|||
|
f2efbbeb9f |
|||
|
ed761d6071 |
|||
|
8f35ea2435 |
|||
|
023e75e063 |
|||
|
4d0a4605d6 |
|||
|
faa7292001 |
|||
|
027033ad9e |
|||
|
ece3d16318 |
|||
|
5738239da2 |
|||
|
a2791a6717 |
|||
|
f910d87a23 |
|||
|
f4142859ba |
|||
|
775c77194b |
|||
|
41806be9bf |
|||
|
6e47594a91 |
|||
|
386b3ad1cf |
|||
|
61f2aa7f2a |
|||
|
0c660a98a7 |
|||
|
09534a6775 |
|||
|
0cd815dc79 |
|||
|
800ed0636d |
|||
|
e4cf2a837c |
|||
|
11dca9bf14 |
|||
|
436b8642b3 |
|||
|
269d97be8f |
|||
|
43f70f35a3 |
|||
|
92b148f5b6 |
|||
|
00a42b5f1a |
|||
|
700b4e720f |
|||
|
2a11bcb7e8 |
|||
|
fea39e4191 |
|||
|
deaf564c36 |
|||
|
c0d14ac30e |
|||
|
1a6a5c36ea |
|||
|
35ef82accd |
|||
|
f40dd22277 |
|||
|
1cfea5a819 |
|||
|
b3023d8147 |
|||
|
e8fb3a8523 |
|||
|
0d1ace3fd0 |
|||
|
3d690c26d8 |
|||
|
3fee789c90 |
|||
|
d7c64fea80 |
|||
|
87e7e093f7 |
|||
|
8b7e29ae33 |
|||
|
673c5bd894 |
|||
|
650c62fb99 |
|||
|
4f3b77f9f3 |
|||
|
85bb653980 |
|||
|
a9d2ccbcd1 |
|||
|
73c4a7d07f |
|||
|
57f9498aa3 |
|||
|
cfab803fa0 |
|||
|
f23a35384d |
|||
|
7f7fb451e6 |
|||
|
5d62e66cd9 |
|||
|
009ba504e3 |
|||
|
30b922d390 |
|||
|
63183e0824 |
|||
|
163947fc90 |
|||
|
11457470a0 |
|||
|
dca0b7dd7d |
|||
|
e5dc270f83 |
|||
|
810a0b102b |
|||
|
ec1603b828 |
|||
| 5acdbb5be0 | |||
|
e21c9e8c22 |
|||
|
ea687471bc |
|||
|
8c440447f2 |
|||
|
8ebec15324 |
|||
|
cd8d551e26 |
|||
|
118e5a5826 |
|||
|
6ca9b2a948 |
|||
|
23e01c7a0d |
|||
|
9e7fcdbef7 |
|||
|
9507d69458 |
|||
|
13d90df226 |
|||
|
e925f9657c |
|||
|
b8882d6e48 |
|||
| ff9bf3bec4 | |||
|
6e42902322 |
|||
|
c105a6035c |
|||
|
3d7ef9b32f |
|||
|
51ef66174a |
|||
|
1efb68ebe0 |
326 changed files with 16113 additions and 4746 deletions
58
.github/workflows/android.yml
vendored
58
.github/workflows/android.yml
vendored
|
|
@ -1,39 +1,39 @@
|
|||
name: "Build APK"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-apk:
|
||||
runs-on: ubuntu-latest
|
||||
build-apk:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Lix GHA Installer Action
|
||||
uses: samueldr/lix-gha-installer-action@v2026-02-22
|
||||
with:
|
||||
extra_nix_config: experimental-features = nix-command flakes flake-self-attrs
|
||||
- name: Lix GHA Installer Action
|
||||
uses: samueldr/lix-gha-installer-action@v2026-02-22
|
||||
with:
|
||||
extra_nix_config: experimental-features = nix-command flakes flake-self-attrs
|
||||
|
||||
- name: Decode keystore
|
||||
run: echo "$KEYSTORE_CONTENT" | base64 --decode > keystore.jks
|
||||
env:
|
||||
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
|
||||
- name: Decode keystore
|
||||
run: echo "$KEYSTORE_CONTENT" | base64 --decode > keystore.jks
|
||||
env:
|
||||
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
|
||||
|
||||
- name: Build app
|
||||
run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64"
|
||||
env:
|
||||
KEYSTORE_PATH: ../../keystore.jks
|
||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
- name: Build app
|
||||
run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64 --split-per-abi --dart-define=GIT_COMMIT=${{ github.sha }}"
|
||||
env:
|
||||
KEYSTORE_PATH: ../../keystore.jks
|
||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
|
||||
- name: Upload installer artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: APK
|
||||
path: build/app/outputs/flutter-apk/app-release.apk
|
||||
- name: Upload installer artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: APK
|
||||
path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
|
||||
|
|
|
|||
92
.github/workflows/flatpak.yml
vendored
92
.github/workflows/flatpak.yml
vendored
|
|
@ -1,37 +1,73 @@
|
|||
name: "Build Flatpaks"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-flatpak:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x86_64
|
||||
runner: ubuntu-latest
|
||||
- arch: aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
build-flatpak:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x86_64
|
||||
runner: ubuntu-latest
|
||||
- arch: aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Lix GHA Installer Action
|
||||
uses: samueldr/lix-gha-installer-action@v2026-02-22
|
||||
with:
|
||||
extra_nix_config: experimental-features = nix-command flakes flake-self-attrs
|
||||
- name: Lix GHA Installer Action
|
||||
uses: samueldr/lix-gha-installer-action@v2026-02-22
|
||||
with:
|
||||
extra_nix_config: experimental-features = nix-command flakes flake-self-attrs
|
||||
|
||||
- name: Build app
|
||||
run: nix build .#flatpak
|
||||
- name: Build app
|
||||
run: nix build .#flatpak
|
||||
|
||||
- name: Upload installer artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: flatpak-${{ matrix.arch }}
|
||||
path: result/nexus.federated.nexus.flatpak
|
||||
- name: Upload installer artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: flatpak-${{ matrix.arch }}
|
||||
path: result/nexus.federated.nexus.flatpak
|
||||
|
||||
publish:
|
||||
needs: build-flatpak
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Install flatpak tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y flatpak ostree
|
||||
|
||||
- name: Download bundles
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: flatpak-*
|
||||
path: bundles
|
||||
|
||||
- name: Build OSTree repo
|
||||
run: |
|
||||
ostree init --repo=repo --mode=archive-z2
|
||||
for bundle in bundles/*/nexus.federated.nexus.flatpak; do
|
||||
flatpak build-import-bundle repo "$bundle"
|
||||
done
|
||||
flatpak build-update-repo --prune repo
|
||||
|
||||
- uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: repo
|
||||
|
||||
- name: Deploy to Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
|
|
|||
4
.github/workflows/ios.yml
vendored
4
.github/workflows/ios.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
|||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: 3.47.0
|
||||
flutter-version: 3.47.4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
flutter pub get
|
||||
dart scripts/generate.dart
|
||||
flutter pub run build_runner build
|
||||
flutter build ios --release --no-codesign
|
||||
flutter build ios --no-codesign --dart-define=GIT_COMMIT=${{ github.sha }}
|
||||
|
||||
# Unsigned IPA: SideStore/AltStore re-sign this on-device with the
|
||||
# user's own Apple ID at install time, so no certificate is needed here.
|
||||
|
|
|
|||
4
.github/workflows/macos.yml
vendored
4
.github/workflows/macos.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
|||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: 3.47.0
|
||||
flutter-version: 3.47.4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
flutter pub get
|
||||
dart scripts/generate.dart
|
||||
flutter pub run build_runner build
|
||||
flutter build macos --release
|
||||
flutter build macos --dart-define=GIT_COMMIT=${{ github.sha }}
|
||||
|
||||
- name: 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
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: 3.47.0
|
||||
flutter-version: 3.47.4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
- name: Setup MSYS2
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: MINGW64
|
||||
msystem: CLANG64
|
||||
install: >-
|
||||
mingw-w64-x86_64-gcc
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
flutter pub get
|
||||
dart scripts/generate.dart
|
||||
flutter pub run build_runner build
|
||||
flutter build windows --release
|
||||
flutter build windows --dart-define=GIT_COMMIT=${{ github.sha }}
|
||||
|
||||
- name: Copy MinGW runtime DLLs
|
||||
shell: msys2 {0}
|
||||
|
|
@ -52,6 +52,15 @@ jobs:
|
|||
cp /mingw64/bin/libwinpthread-1.dll build/windows/x64/runner/Release/
|
||||
cp /mingw64/bin/libstdc++-6.dll build/windows/x64/runner/Release/
|
||||
|
||||
- name: Copy MSVC runtime DLLs
|
||||
shell: powershell
|
||||
run: |
|
||||
$release = "build/windows/x64/runner/Release"
|
||||
|
||||
Copy-Item C:\Windows\System32\MSVCP140.dll $release -Force
|
||||
Copy-Item C:\Windows\System32\VCRUNTIME140.dll $release -Force
|
||||
Copy-Item C:\Windows\System32\VCRUNTIME140_1.dll $release -Force
|
||||
|
||||
- name: Install Inno Setup
|
||||
run: choco install innosetup -y
|
||||
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -42,3 +42,5 @@ password.txt
|
|||
|
||||
# Nix
|
||||
/result
|
||||
# Widget Preview related
|
||||
.widget_preview/
|
||||
|
|
|
|||
1
.gitmodules
vendored
1
.gitmodules
vendored
|
|
@ -1,4 +1,3 @@
|
|||
[submodule "gomuks"]
|
||||
path = gomuks
|
||||
url = https://github.com/gomuks/gomuks
|
||||
branch = main
|
||||
|
|
|
|||
17
.vscode/launch.json
vendored
Normal file
17
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Flutter",
|
||||
"type": "dart",
|
||||
"request": "launch",
|
||||
"program": "lib/main.dart"
|
||||
},
|
||||
{
|
||||
"name": "Flutter (Headless)",
|
||||
"type": "dart",
|
||||
"request": "launch",
|
||||
"program": "lib/main.dart",
|
||||
"env": { "FLUTTER_HEADLESS": "1" }
|
||||
}
|
||||
]
|
||||
}
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
|
@ -10,7 +10,10 @@
|
|||
"msgtype",
|
||||
"muks",
|
||||
"prefs",
|
||||
"recents",
|
||||
"unban",
|
||||
"unredact"
|
||||
"unifiedpush",
|
||||
"unredact",
|
||||
"webpush"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,28 +21,32 @@ 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:
|
||||
|
||||
```dart
|
||||
ref.watch(MyController.provider.notifier).helperMethod()
|
||||
ref.read(MyController.provider.notifier).helperMethod()
|
||||
```
|
||||
|
||||
We use an object oriented style for controllers, where `provider` is a static member on the controller class. E.g.
|
||||
|
||||
```dart
|
||||
class MyController extends AsyncNotifier<DataThisControllerExposes> {
|
||||
final SomeInputType input;
|
||||
MyController(this.input);
|
||||
|
||||
class MyController(final SomeInputType input)
|
||||
extends AsyncNotifier<DataThisControllerExposes> {
|
||||
@override
|
||||
Future<DataThisControllerExposes> build() async {
|
||||
return input.foo;
|
||||
}
|
||||
|
||||
static final provider =
|
||||
AsyncNotifierProvider.family<MyController, DataThisControllerExposes, SomeInputType>(
|
||||
AuthorController.new,
|
||||
);
|
||||
AsyncNotifierProvider.family<MyController, DataThisControllerExposes, SomeInputType>(
|
||||
MyController.new,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### `watch` vs `read`
|
||||
|
||||
Only use `ref.watch` in a `build` method, where you want to rebuild when the value changes. Everywhere else, like callbacks, `useEffect`, and methods on a controller, use `ref.read`.
|
||||
|
||||
Use `.select` to narrow down what you depend on, e.g. `ref.watch(RoomsController.provider.select((rooms) => rooms[roomId]))`.
|
||||
|
||||
Providers which are not controllers, e.g. they expose no data, only methods, should instead live in `lib/helpers/`. For an example, see `lib/helpers/launch_helper.dart`. Other, non-provider helpers, like extensions or helper methods can also go in `lib/helpers/`.
|
||||
|
||||
### Don't use StatefulWidgets ([Flutter Hooks](https://pub.dev/packages/flutter_hooks))
|
||||
|
|
@ -61,6 +65,36 @@ When possible, use immutable collections instead of the mutable equivalent. For
|
|||
|
||||
When possible, we prefer not to create global variables or methods. You can usually replace a global variable with a Riverpod controller, and a global method with an extension method.
|
||||
|
||||
I'm also not a big fan of unneeded private methods or variables, but this is contextual.
|
||||
|
||||
### Modern Dart syntax
|
||||
|
||||
We try to use newer Dart language features where they make code shorter:
|
||||
|
||||
- Primary constructors for classes, including widgets, e.g. `class const MemberList(final String roomId, {super.key}) extends HookConsumerWidget`.
|
||||
- Dot shorthands wherever the type can be inferred, e.g. `.new(...)`, `padding: .all(8)`, `relationType == .reply`.
|
||||
- Patterns and switch expressions over null checks and casts, e.g. `if (event.content case MessageContent(:final body?))` rather than checking `is` then using `!`.
|
||||
|
||||
### Prefer `map` over inline `for` loops
|
||||
|
||||
When building a list (or set, or map) from another collection, use `.map`, `.expand` and friends rather than a `for` inside the collection literal:
|
||||
|
||||
```dart
|
||||
// Prefer
|
||||
children: rooms.map((room) => RoomTile(room)).toList(),
|
||||
|
||||
// Over
|
||||
children: [for (final room in rooms) RoomTile(room)],
|
||||
```
|
||||
|
||||
Regular `for` statements are fine for logic that doesn't just build a collection.
|
||||
|
||||
### Async values and errors
|
||||
|
||||
When rendering an `AsyncValue`, use the `betterWhen` extension, which shows our standard loading and error widgets, or a switch expression if you need more control (e.g. `AsyncData(:final value) || AsyncLoading(:final value?)` to keep showing old data while reloading).
|
||||
|
||||
For futures started from the UI that nothing awaits, like button presses, add `.onError(showError)` so failures are shown to the user instead of being lost.
|
||||
|
||||
## LLM/AI Assisted Contributions
|
||||
|
||||
LLM generated code is NOT allowed. All contributions should be written by humans. Using LLMs for interacting with others, e.g. for Comments, PRs, etc, is also not allowed.
|
||||
|
|
|
|||
159
README.md
159
README.md
|
|
@ -1,8 +1,5 @@
|
|||
# Nexus Client
|
||||
|
||||
> [!WARNING]
|
||||
> Nexus Client is still in development, and doesn't support everything needed for daily use.
|
||||
|
||||
## Description
|
||||
|
||||
A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
|
||||
|
|
@ -13,115 +10,63 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
|
|||
| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Platform Support
|
||||
- [x] Linux
|
||||
- [x] Windows - Currently has some bugs, due to us not having any developers on Windows. If you want to fix these, get in contact with us though the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus).
|
||||
- [x] Android
|
||||
- [x] MacOS - Unsigned .dmg only
|
||||
- [x] iOS - Unsigned .ipa only
|
||||
- [ ] Web (may not be possible)
|
||||
- [x] Login (via OAuth)
|
||||
- [x] Rooms / Spaces
|
||||
- [x] Displaying and choosing
|
||||
- [x] Reading, showing unread
|
||||
- [x] Mark as read button on rooms and spaces
|
||||
- [ ] Searching
|
||||
- [ ] Creating (Rooms, Spaces, and DMs)
|
||||
- [x] Joining
|
||||
- [x] Parse vias
|
||||
- [x] Using a text/uri/link
|
||||
- [x] Plain text
|
||||
- [x] `matrix:` Uri
|
||||
- [x] Matrix.to link
|
||||
- [ ] From space
|
||||
- [ ] From directory
|
||||
- [x] Leaving
|
||||
- [x] Subspaces
|
||||
- [x] Messages
|
||||
- [x] Encryption
|
||||
- [x] Restoring crypto identity from a recovery passphrase/key
|
||||
- [x] Sending
|
||||
- [x] Plain text
|
||||
- [x] HTML/Markdown
|
||||
- [x] Replies
|
||||
- [x] Choose ping on/off
|
||||
- [x] Per message profiles
|
||||
- [x] Attachments
|
||||
- [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391)
|
||||
- [x] Mentions
|
||||
- [x] Users
|
||||
- [x] Rooms
|
||||
- [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions)
|
||||
- [ ] Custom emojis/stickers
|
||||
- [ ] GIFs using Gomuks' GIF proxies
|
||||
- [x] Receiving
|
||||
- [x] Plain text
|
||||
- [x] Per message profiles
|
||||
- [x] HTML
|
||||
- [x] URL Previews
|
||||
- [x] Replies
|
||||
- [x] Viewing
|
||||
- [ ] Jump to original message
|
||||
- [x] In loaded timeline
|
||||
- [ ] Out of loaded timeline
|
||||
- [x] Edits
|
||||
- [x] Attachments
|
||||
- [x] Unencrypted
|
||||
- [x] Encrypted
|
||||
- [x] Blurhashing
|
||||
- [ ] Downloading attachments
|
||||
- [x] Opening attachments in their own view
|
||||
- [ ] Polls
|
||||
- [x] Mentions
|
||||
- [x] Users
|
||||
- [x] Clickable
|
||||
- [x] Rooms
|
||||
- [ ] Clickable
|
||||
- [x] Matrix URIs
|
||||
- [x] Matrix.to links
|
||||
- [x] Events
|
||||
- [ ] Render more nicely
|
||||
- [ ] Clickable
|
||||
- [x] Custom emojis/stickers
|
||||
- [x] History loading
|
||||
- [x] Editing
|
||||
- [x] Deleting
|
||||
- [x] Reactions
|
||||
- [x] Pins
|
||||
- [x] Displaying
|
||||
- [x] Pinning/Unpinning
|
||||
- [ ] Threads
|
||||
- [x] Profile popouts
|
||||
- [x] Working actions
|
||||
- [x] Copy link to:
|
||||
- [x] Room
|
||||
- [x] Space
|
||||
- [x] Message
|
||||
- [ ] Reporting
|
||||
- [x] Events
|
||||
- [ ] Rooms
|
||||
- [x] Member list
|
||||
- [x] Sort by power level
|
||||
- [ ] Colors based off of power level
|
||||
- [ ] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35))
|
||||
- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195)
|
||||
- [ ] Invites
|
||||
- [x] Settings
|
||||
|
||||
## Try it out
|
||||
|
||||
If you want to try out Nexus, grab one of the following artifacts from CI:
|
||||
If you want to try out Nexus, download the [latest release](https://git.federated.nexus/Nexus/nexus/releases/latest).
|
||||
|
||||
### Nightly Builds
|
||||
|
||||
For the newest changes, grab one of the following nightly artifacts, built by CI from the latest commit on `main`.
|
||||
|
||||
- [Android APK](https://nightly.link/Henry-Hiles/nexus/workflows/android/main/APK.zip)
|
||||
- [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip)
|
||||
- [Unsigned iOS IPA](https://nightly.link/Henry-Hiles/nexus/workflows/ios/main/Nexus.ipa.zip)
|
||||
- [Unsigned 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)
|
||||
- Flatpak
|
||||
- [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)
|
||||
|
||||
Or, use one of the below methods:
|
||||
|
||||
### Nightly Flatpak Repo
|
||||
|
||||
Make sure Flatpak is set up on your system, then run:
|
||||
|
||||
```bash
|
||||
flatpak install --user https://nexus.federated.nexus/flatpak/nexus.flatpakref
|
||||
```
|
||||
|
||||
This adds the Nexus remote and installs the app for your user only, so no root access is needed (x86_64 and aarch64 are supported). To install system-wide instead, use `--system` in place of `--user`. Updates arrive through `flatpak update` or your software center.
|
||||
|
||||
### NixOS Module
|
||||
|
||||
Add Nexus as a flake input, following `nixpkgs` for less fetches:
|
||||
|
||||
```nix
|
||||
nexus = {
|
||||
url = "git+https://git.federated.nexus/Nexus/nexus";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
```
|
||||
|
||||
This tracks the latest commit on `main`. To stay on a release instead, pin the input to its tag:
|
||||
|
||||
```nix
|
||||
url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0";
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -129,7 +74,7 @@ If you want to try out Nexus, grab one of the following artifacts from CI:
|
|||
|
||||
#### Linux
|
||||
|
||||
- On NixOS: Either use direnv and `direnv allow`, or `nix flake develop`
|
||||
- On NixOS: Either use direnv and `direnv allow`, or `nix develop`
|
||||
- On other distros: Install Flutter, Go, Git, Libclang, Libass, MPV, and Glibc. Do not use any Snap packages, they cause various compilation issues.
|
||||
|
||||
#### Windows
|
||||
|
|
@ -208,7 +153,7 @@ Development instructions can be found in [DEVELOPMENT.md](./DEVELOPMENT.md).
|
|||
|
||||
Join the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client.
|
||||
|
||||
# Credits
|
||||
## Credits
|
||||
|
||||
Thank you Hylke Bons (https://planetpeanut.studio) for making the amazing icon for Nexus!
|
||||
Thank you Tulir Asokan for making [Gomuks](https://github.com/gomuks/gomuks), and helping us integrate it into Nexus!
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"name": "Nexus",
|
||||
"identifier": "nexus.federated.nexus.source",
|
||||
"subtitle": "Nexus for iOS",
|
||||
"description": "Nexus iOS builds for AltStore and SideStore.",
|
||||
"iconURL": "https://git.federated.nexus/Nexus/nexus/raw/branch/main/assets/icon.png",
|
||||
"website": "https://git.federated.nexus/Nexus/nexus",
|
||||
"apps": [
|
||||
{
|
||||
"name": "Nexus",
|
||||
"bundleIdentifier": "nexus.federated.nexus",
|
||||
"developerName": "Nexus",
|
||||
"subtitle": "A Matrix client",
|
||||
"localizedDescription": "Nexus for iOS. Unsigned builds, resigned at install by AltStore/SideStore.",
|
||||
"iconURL": "https://git.federated.nexus/Nexus/nexus/raw/branch/main/assets/icon.png",
|
||||
"category": "social",
|
||||
"screenshots": [],
|
||||
"versions": []
|
||||
}
|
||||
],
|
||||
"news": []
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ analyzer:
|
|||
errors:
|
||||
invalid_annotation_target: ignore
|
||||
avoid_print: ignore
|
||||
annotate_overrides: ignore
|
||||
exclude:
|
||||
- "build/**"
|
||||
- "**/*.g.dart"
|
||||
|
|
|
|||
|
|
@ -20,16 +20,21 @@ android {
|
|||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "nexus.federated.nexus"
|
||||
minSdk = 29
|
||||
|
||||
minSdk = 29
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
|
||||
multiDexEnabled = true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
|
@ -63,6 +68,11 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.unifiedpush.android:embedded-fcm-distributor:3.1.0")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
<!-- Until https://github.com/flutter/flutter/issues/145290 is fixed, we need enableOnBackInvokedCallback as false -->
|
||||
<application
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
android:label="Nexus"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
|
|
@ -49,6 +51,16 @@
|
|||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<!-- Needed by pasteboard, for pasting files from the clipboard -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
package nexus.federated.nexus
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity: FlutterActivity()
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package nexus.federated.nexus
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowInsetsAnimation
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
// TODO: Revert this commit once https://github.com/flutter/flutter/issues/191156 is fixed
|
||||
class MainActivity: FlutterActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
|
||||
|
||||
window.decorView.setWindowInsetsAnimationCallback(
|
||||
object : WindowInsetsAnimation.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
|
||||
override fun onProgress(
|
||||
insets: WindowInsets,
|
||||
runningAnimations: List<WindowInsetsAnimation>,
|
||||
) = insets
|
||||
|
||||
override fun onEnd(animation: WindowInsetsAnimation) {
|
||||
if ((animation.typeMask and WindowInsets.Type.ime()) != 0) reapplyInsets()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) reapplyInsets()
|
||||
}
|
||||
|
||||
// Posted so it runs after Flutter has applied any insets it deferred.
|
||||
private fun reapplyInsets() {
|
||||
val view = findViewById<View>(FLUTTER_VIEW_ID) ?: return
|
||||
view.post { view.rootWindowInsets?.let(view::onApplyWindowInsets) }
|
||||
}
|
||||
}
|
||||
6
android/app/src/main/res/xml/provider_paths.xml
Normal file
6
android/app/src/main/res/xml/provider_paths.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path
|
||||
name="external_files"
|
||||
path="." />
|
||||
</paths>
|
||||
|
|
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-all.zip
|
||||
|
|
|
|||
BIN
assets/fonts/AtkinsonHyperlegibleMono.ttf
Normal file
BIN
assets/fonts/AtkinsonHyperlegibleMono.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Inter.ttf
Normal file
BIN
assets/fonts/Inter.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 616 KiB After Width: | Height: | Size: 246 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 616 KiB After Width: | Height: | Size: 251 KiB |
0
assets/twim/.gitkeep
Normal file
0
assets/twim/.gitkeep
Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB |
24
flake.lock
generated
24
flake.lock
generated
|
|
@ -5,11 +5,11 @@
|
|||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785627969,
|
||||
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
|
||||
"lastModified": 1788450739,
|
||||
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
|
||||
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -42,11 +42,11 @@
|
|||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1774860670,
|
||||
"narHash": "sha256-YjJkQrvxrErXtfDi3obUn6rNmkA+CIAZ3f5NgL5xuYE=",
|
||||
"lastModified": 1791751882,
|
||||
"narHash": "sha256-DWcMlQFQMEqQGAHU6rAbl4uKWmlJMR1HZG0BvH0H2fk=",
|
||||
"owner": "neobrain",
|
||||
"repo": "nix2flatpak",
|
||||
"rev": "61d68e21e3fbc2d57590051f48736bea271f4aba",
|
||||
"rev": "7731e8b3fca73879f8217de5a919c0a5d17303de",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -73,11 +73,11 @@
|
|||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1785031560,
|
||||
"narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=",
|
||||
"lastModified": 1788057806,
|
||||
"narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c",
|
||||
"rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -88,11 +88,11 @@
|
|||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1786862985,
|
||||
"narHash": "sha256-FBJRXmbGXiSUDvYEbfLYRkckayyZ6SK1UEqhCrIZ2Cs=",
|
||||
"lastModified": 1790689126,
|
||||
"narHash": "sha256-ilerN1WLSvF+HMjziC/Wv99J5y02maDH+6hZPwsORKg=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e5bdc4a41d4c072fe1e3787eaa0320a384741d44",
|
||||
"rev": "b4fd65b198c599cbe814fcb9f42d25d021595ec9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
|||
52
flake.nix
52
flake.nix
|
|
@ -16,62 +16,12 @@
|
|||
...
|
||||
}@inputs:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
imports = [ ./linux/nix ];
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"aarch64-darwin"
|
||||
"x86_64-darwin"
|
||||
];
|
||||
|
||||
perSystem =
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
system,
|
||||
...
|
||||
}:
|
||||
|
||||
{
|
||||
_module.args.pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config = {
|
||||
android_sdk.accept_license = true;
|
||||
allowUnfree = true;
|
||||
};
|
||||
};
|
||||
|
||||
packages =
|
||||
let
|
||||
default = pkgs.callPackage ./linux/nix/pkg {
|
||||
src = self;
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit default;
|
||||
|
||||
flatpak = inputs.nix2flatpak.lib.${system}.mkFlatpak {
|
||||
appName = "Nexus";
|
||||
developer = "QuadRadical";
|
||||
appId = "nexus.federated.nexus";
|
||||
package = default;
|
||||
runtime = "org.gnome.Platform/49";
|
||||
permissions = {
|
||||
share = [ "network" ];
|
||||
sockets = [
|
||||
"pulseaudio"
|
||||
"fallback-x11"
|
||||
"wayland"
|
||||
];
|
||||
devices = [ "dri" ];
|
||||
};
|
||||
};
|
||||
|
||||
gomuks = pkgs.callPackage ./linux/nix/pkg/gomuks.nix {
|
||||
src = self;
|
||||
};
|
||||
};
|
||||
|
||||
devShells.default = pkgs.callPackage ./linux/nix/devshell.nix { };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
2
gomuks
2
gomuks
|
|
@ -1 +1 @@
|
|||
Subproject commit 0e06c7b74f96d3e29a8101893ca1921d13a90573
|
||||
Subproject commit 53cae0e0334aef5ac376ea84f19ba1cff8a92310
|
||||
|
|
@ -122,7 +122,7 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
|
|||
"sqlite_fts5",
|
||||
"goolm",
|
||||
// goheif/dav1d is not supported on Android or iOS, would need to be fixed upstream
|
||||
if (targetOS == OS.android || targetOS == OS.iOS) "noheic",
|
||||
"noheic",
|
||||
].join(",");
|
||||
|
||||
final archiveFile = targetOS == OS.iOS
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import UIKit
|
|||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import "dart:convert";
|
||||
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/models/account_data.dart";
|
||||
|
||||
|
|
@ -9,11 +10,16 @@ class AccountDataController extends Notifier<AccountData> {
|
|||
AccountData build() => .new();
|
||||
|
||||
void update(IMap<String, IMap<String, dynamic>> newAccountData) =>
|
||||
state = .fromJson({
|
||||
...json.decode(json.encode(state.toJson())),
|
||||
...newAccountData
|
||||
.map((key, value) => MapEntry(key, value["content"]))
|
||||
.unlock,
|
||||
state = newAccountData.entries.fold(state, (data, entry) {
|
||||
try {
|
||||
return .fromJson({
|
||||
...json.decode(json.encode(data.toJson())),
|
||||
entry.key: entry.value["content"],
|
||||
});
|
||||
} catch (error) {
|
||||
debugPrint("Ignoring invalid ${entry.key} account data: $error");
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
static final provider = NotifierProvider<AccountDataController, AccountData>(
|
||||
|
|
|
|||
25
lib/controllers/all_emoji.dart
Normal file
25
lib/controllers/all_emoji.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:material_emoji_picker/material_emoji_picker.dart";
|
||||
import "package:nexus/controllers/image_packs.dart";
|
||||
import "package:nexus/helpers/extensions/image_pack_emoji.dart";
|
||||
|
||||
class AllEmojiController(final String roomId)
|
||||
extends AsyncNotifier<IList<EmojiCategory>> {
|
||||
@override
|
||||
Future<IList<EmojiCategory>> build() async {
|
||||
final packs = await ref.watch(ImagePacksController.provider(roomId).future);
|
||||
final unicode = await ref.watch(EmojiController.provider.future);
|
||||
|
||||
return packs
|
||||
.map((pack) => pack.emojiCategory)
|
||||
.nonNulls
|
||||
.toIList()
|
||||
.addAll(unicode);
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.autoDispose
|
||||
.family<AllEmojiController, IList<EmojiCategory>, String>(
|
||||
AllEmojiController.new,
|
||||
);
|
||||
}
|
||||
18
lib/controllers/app_paused.dart
Normal file
18
lib/controllers/app_paused.dart
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
class AppPausedController extends Notifier<bool> {
|
||||
@override
|
||||
bool build() {
|
||||
final listener = AppLifecycleListener(
|
||||
onPause: () => state = true,
|
||||
onResume: () => state = false,
|
||||
);
|
||||
ref.onDispose(listener.dispose);
|
||||
return WidgetsBinding.instance.lifecycleState == .paused;
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider<AppPausedController, bool>(
|
||||
AppPausedController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ class AttachmentController(final String roomId)
|
|||
);
|
||||
|
||||
final content = await ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.read(ClientController.provider.notifier)
|
||||
.uploadMedia(.new(path: file.path, encrypt: isEncrypted));
|
||||
|
||||
state = (filename, content);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ class AuthUrlController(final Uri homeserver)
|
|||
extends AsyncNotifier<OAuthAuthCodeResponse> {
|
||||
@override
|
||||
Future<OAuthAuthCodeResponse> build() async => ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.read(ClientController.provider.notifier)
|
||||
.getAuthUrl(
|
||||
.new(
|
||||
homeserverUrl: homeserver,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "dart:async";
|
||||
import "dart:ffi";
|
||||
import "dart:io";
|
||||
import "dart:isolate";
|
||||
|
|
@ -5,33 +6,44 @@ import "dart:math";
|
|||
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:ffi/ffi.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:nexus/controllers/account_data.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:flutter/cupertino.dart";
|
||||
import "package:intl/intl.dart";
|
||||
import "package:nexus/controllers/settings.dart";
|
||||
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/models/capabilities.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/event_context.dart";
|
||||
import "package:nexus/models/mentions_response.dart";
|
||||
import "package:nexus/models/gomuks_config.dart";
|
||||
import "package:nexus/models/gomuks_exception.dart";
|
||||
import "package:nexus/models/oauth_auth_code_response.dart";
|
||||
import "package:nexus/models/open_graph_data.dart";
|
||||
import "package:nexus/models/paginate.dart";
|
||||
import "package:nexus/models/paginate_manual.dart";
|
||||
import "package:nexus/models/requests/create_room.dart";
|
||||
import "package:nexus/models/requests/deregister_pusher.dart";
|
||||
import "package:nexus/models/requests/download_media.dart";
|
||||
import "package:nexus/models/requests/get_event.dart";
|
||||
import "package:nexus/models/requests/get_event_context.dart";
|
||||
import "package:nexus/models/requests/get_mentions.dart";
|
||||
import "package:nexus/models/requests/get_related_events.dart";
|
||||
import "package:nexus/models/requests/get_room_state.dart";
|
||||
import "package:nexus/models/requests/get_specific_room_state.dart";
|
||||
import "package:nexus/models/requests/get_space_hierarchy.dart";
|
||||
import "package:nexus/models/requests/join_room.dart";
|
||||
import "package:nexus/models/profile_response.dart";
|
||||
import "package:nexus/models/push_notification.dart";
|
||||
import "package:nexus/models/recovery_key.dart";
|
||||
import "package:nexus/models/requests/oauth/exchange_token.dart";
|
||||
import "package:nexus/models/requests/oauth/get_auth_url.dart";
|
||||
import "package:nexus/models/requests/oauth/register_client.dart";
|
||||
import "package:nexus/models/requests/paginate.dart";
|
||||
import "package:nexus/models/requests/paginate_manual.dart";
|
||||
import "package:nexus/models/requests/redact_event.dart";
|
||||
import "package:nexus/models/requests/register_pusher.dart";
|
||||
import "package:nexus/models/requests/report.dart";
|
||||
import "package:nexus/models/requests/search.dart";
|
||||
import "package:nexus/models/requests/send_event.dart";
|
||||
import "package:nexus/models/requests/send_message.dart";
|
||||
import "package:nexus/models/requests/set_account_data.dart";
|
||||
|
|
@ -40,8 +52,8 @@ import "package:nexus/models/requests/set_state.dart";
|
|||
import "package:nexus/models/requests/upload_media.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
import "package:nexus/models/room_summary.dart";
|
||||
import "package:nexus/models/space_hierarchy.dart";
|
||||
import "package:nexus/models/spec_versions_response.dart";
|
||||
import "package:nexus/models/sync_data.dart";
|
||||
import "package:nexus/src/third_party/gomuks.g.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:path_provider/path_provider.dart";
|
||||
|
|
@ -49,134 +61,115 @@ import "package:path_provider/path_provider.dart";
|
|||
class ClientController extends AsyncNotifier<int> {
|
||||
@override
|
||||
Future<int> build() async {
|
||||
final Pointer<Char> root;
|
||||
debugPrint("Setting Gomuks env...");
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
root = "${dir.path}/gomuks".toNativeUtf8().cast();
|
||||
} else {
|
||||
root = nullptr.cast();
|
||||
final env = {
|
||||
"GOMUKS_ROOT": (await getApplicationSupportDirectory()).path,
|
||||
"GOMUKS_CACHE_HOME": (await getApplicationCacheDirectory()).path,
|
||||
};
|
||||
for (final MapEntry(:key, :value) in env.entries) {
|
||||
final keyPtr = key.toNativeUtf8().cast<Char>();
|
||||
final valuePtr = value.toNativeUtf8().cast<Char>();
|
||||
try {
|
||||
GomuksSetEnv(keyPtr, valuePtr);
|
||||
} finally {
|
||||
calloc
|
||||
..free(keyPtr)
|
||||
..free(valuePtr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final handle = GomuksInit(root);
|
||||
debugPrint("Initializing Gomuks...");
|
||||
final handle = await Isolate.run(() {
|
||||
final bufferPointer = GomuksConfig(
|
||||
matrix: .new(
|
||||
initialDeviceDisplayName:
|
||||
"Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
|
||||
),
|
||||
).toJson().toGomuksBufferPtr();
|
||||
|
||||
final callable =
|
||||
NativeCallable<
|
||||
Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer)
|
||||
>.listener((
|
||||
Pointer<Char> command,
|
||||
int requestId,
|
||||
GomuksOwnedBuffer data,
|
||||
) {
|
||||
try {
|
||||
final muksEventType = command.cast<Utf8>().toDartString();
|
||||
debugPrint("Handling $muksEventType...");
|
||||
final decodedMuksEvent = data.toJson();
|
||||
try {
|
||||
return GomuksInit(bufferPointer.ref);
|
||||
} finally {
|
||||
calloc
|
||||
..free(bufferPointer.ref.base)
|
||||
..free(bufferPointer);
|
||||
}
|
||||
});
|
||||
|
||||
switch (muksEventType) {
|
||||
case "client_state":
|
||||
ref
|
||||
.watch(ClientStateController.provider.notifier)
|
||||
.set(.fromJson(decodedMuksEvent));
|
||||
break;
|
||||
case "sync_status":
|
||||
ref
|
||||
.watch(SyncStatusController.provider.notifier)
|
||||
.set(.fromJson(decodedMuksEvent));
|
||||
break;
|
||||
case "init_complete":
|
||||
ref.watch(InitCompleteController.provider.notifier).complete();
|
||||
break;
|
||||
case "send_complete":
|
||||
final event = Event.fromJson(decodedMuksEvent["event"]);
|
||||
ref
|
||||
.watch(RoomsController.provider.notifier)
|
||||
.update(
|
||||
.new({
|
||||
event.roomId: .new(events: .new({event.rowId: event})),
|
||||
}),
|
||||
.new(),
|
||||
);
|
||||
|
||||
break;
|
||||
case "sync_complete":
|
||||
final syncData = SyncData.fromJson(decodedMuksEvent);
|
||||
final roomProvider = RoomsController.provider;
|
||||
final accountDataProvider = AccountDataController.provider;
|
||||
|
||||
if (syncData.clearState) {
|
||||
ref.invalidate(roomProvider);
|
||||
ref.invalidate(accountDataProvider);
|
||||
}
|
||||
|
||||
ref
|
||||
.watch(roomProvider.notifier)
|
||||
.update(syncData.rooms, syncData.leftRooms);
|
||||
ref
|
||||
.watch(accountDataProvider.notifier)
|
||||
.update(syncData.accountData);
|
||||
|
||||
if (syncData.topLevelSpaces != null) {
|
||||
ref
|
||||
.watch(TopLevelSpacesController.provider.notifier)
|
||||
.set(syncData.topLevelSpaces!);
|
||||
}
|
||||
|
||||
if (syncData.spaceEdges != null) {
|
||||
ref
|
||||
.watch(SpaceEdgesController.provider.notifier)
|
||||
.set(syncData.spaceEdges!);
|
||||
}
|
||||
|
||||
// ref
|
||||
// .watch(SyncStatusController.provider.notifier)
|
||||
// .set(SyncStatus.fromJson(decodedMuksEvent));
|
||||
break;
|
||||
default:
|
||||
debugPrint("Unhandled event: $muksEventType");
|
||||
}
|
||||
debugPrint("Finished handling $muksEventType...");
|
||||
} catch (error, stackTrace) {
|
||||
if (kDebugMode) {
|
||||
debugPrintStack(stackTrace: stackTrace, label: error.toString());
|
||||
rethrow;
|
||||
} else {
|
||||
showError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!ref.mounted) {
|
||||
GomuksDestroy(handle);
|
||||
return Completer<int>().future;
|
||||
}
|
||||
|
||||
ref.onDispose(() => GomuksDestroy(handle));
|
||||
ref.onDispose(callable.close);
|
||||
|
||||
final errorCode = GomuksStart(handle, callable.nativeFunction);
|
||||
return handle;
|
||||
}
|
||||
|
||||
if (errorCode == 0) return handle;
|
||||
throw Exception("GomuksStart returned error code $errorCode");
|
||||
/// Calls gomuks with [data] as JSON, returning its decoded response.
|
||||
Future<dynamic> callGomuksMethod(
|
||||
Map<String, dynamic> data,
|
||||
void Function(
|
||||
int handle,
|
||||
GomuksOwnedBuffer data,
|
||||
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
|
||||
)
|
||||
call,
|
||||
) async {
|
||||
final handle = await future;
|
||||
final buffer = data.toGomuksOwnedBuffer();
|
||||
|
||||
final completer = Completer<dynamic>();
|
||||
late final NativeCallable<Void Function(GomuksResponse)> callable;
|
||||
callable = .listener((GomuksResponse response) {
|
||||
callable.close();
|
||||
try {
|
||||
final json = response.buf.toJsonAndFree();
|
||||
|
||||
if (response.command.cast<Utf8>().toDartString() == "error") {
|
||||
throw GomuksException(json.toString());
|
||||
}
|
||||
|
||||
completer.complete(json);
|
||||
} catch (error, stackTrace) {
|
||||
completer.completeError(error, stackTrace);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
call(handle, buffer, callable.nativeFunction);
|
||||
} catch (_) {
|
||||
// gomuks never got the call, so it won't respond or free the buffer
|
||||
callable.close();
|
||||
GomuksFreeBuffer(buffer);
|
||||
rethrow;
|
||||
}
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<PushNotification?> handlePush(Map<String, dynamic> data) async {
|
||||
final response = await callGomuksMethod(data, GomuksHandlePushAsync);
|
||||
|
||||
return response?["notification"] == null
|
||||
? null
|
||||
: .fromJson(response["notification"]);
|
||||
}
|
||||
|
||||
Future<dynamic> _sendCommand(
|
||||
String command, [
|
||||
Map<String, dynamic> data = const {},
|
||||
]) async {
|
||||
final bufferPointer = data.toGomuksBufferPtr();
|
||||
final handle = await future;
|
||||
final response = await Isolate.run(
|
||||
() => GomuksSubmitCommand(
|
||||
handle,
|
||||
command.toNativeUtf8().cast<Char>(),
|
||||
bufferPointer.ref,
|
||||
),
|
||||
);
|
||||
|
||||
calloc.free(bufferPointer);
|
||||
|
||||
final json = response.buf.toJson();
|
||||
if (response.command.cast<Utf8>().toDartString() == "error") {
|
||||
throw json;
|
||||
]) => callGomuksMethod(data, (handle, data, callback) {
|
||||
final commandPointer = command.toNativeUtf8().cast<Char>();
|
||||
try {
|
||||
GomuksSubmitCommandAsync(handle, commandPointer, data, callback);
|
||||
} finally {
|
||||
// gomuks copies the command name before returning
|
||||
calloc.free(commandPointer);
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
});
|
||||
|
||||
Future<void> redactEvent(RedactEventRequest report) =>
|
||||
_sendCommand("redact_event", report.toJson());
|
||||
|
|
@ -188,16 +181,17 @@ class ClientController extends AsyncNotifier<int> {
|
|||
final json = request.toJson();
|
||||
final content = request.content.toJson();
|
||||
|
||||
return Event.fromJson(
|
||||
return .fromJson(
|
||||
await _sendCommand("send_event", {
|
||||
...json,
|
||||
"content": {
|
||||
...content,
|
||||
"m.relates_to": {
|
||||
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
|
||||
"event_id": request.relatesTo,
|
||||
"rel_type": request.relationType,
|
||||
},
|
||||
if (request.relatesTo != null)
|
||||
"m.relates_to": {
|
||||
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
|
||||
"event_id": request.relatesTo,
|
||||
"rel_type": request.relationType,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -206,30 +200,54 @@ class ClientController extends AsyncNotifier<int> {
|
|||
Future<String?> setState(SetStateRequest request) async =>
|
||||
await _sendCommand("set_state", request.toJson());
|
||||
|
||||
Future<String?> verify(String recoveryKey) async {
|
||||
try {
|
||||
await _sendCommand("verify", {"recovery_key": recoveryKey});
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error.toString();
|
||||
Future<void> verify(String recoveryKey) =>
|
||||
_sendCommand("verify", {"recovery_key": recoveryKey});
|
||||
|
||||
Future<RecoveryKey> generateRecoveryKey(String passphrase) async => .fromJson(
|
||||
await _sendCommand("generate_recovery_key", {"passphrase": passphrase}),
|
||||
);
|
||||
|
||||
Future<void> resetEncryption(RecoveryKey key) =>
|
||||
_sendCommand("reset_encryption", key.toJson());
|
||||
|
||||
Future<Uri?> getCrossSigningResetUrl(Uri homeserver) async {
|
||||
const action = "org.matrix.cross_signing_reset";
|
||||
final oauth = (await _sendCommand("get_login_flows", {
|
||||
"homeserver_url": homeserver.toString(),
|
||||
}))["oauth"];
|
||||
|
||||
if (oauth?["account_management_uri"] case final String uri
|
||||
when (oauth["account_management_actions_supported"] as List?)?.contains(
|
||||
action,
|
||||
) ??
|
||||
false) {
|
||||
final parsed = Uri.parse(uri);
|
||||
return parsed.replace(
|
||||
queryParameters: {...parsed.queryParameters, "action": action},
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String> joinRoom(JoinRoomRequest request) async =>
|
||||
(await _sendCommand("join_room", request.toJson()))["room_id"];
|
||||
|
||||
Future<String> knockRoom(JoinRoomRequest request) async =>
|
||||
(await _sendCommand("knock_room", request.toJson()))["room_id"];
|
||||
|
||||
Future<String> createRoom(CreateRoomRequest request) async =>
|
||||
(await _sendCommand("create_room", request.toJson()))["room_id"];
|
||||
|
||||
Future<SpaceHierarchy> getSpaceHierarchy(
|
||||
GetSpaceHierarchyRequest request,
|
||||
) async =>
|
||||
.fromJson(await _sendCommand("get_space_hierarchy", request.toJson()));
|
||||
|
||||
Future<RoomSummary> getRoomSummary(JoinRoomRequest request) async =>
|
||||
.fromJson(await _sendCommand("get_room_summary", request.toJson()));
|
||||
|
||||
Future<void> leaveRoom(Room room) async {
|
||||
if (room.metadata == null) return;
|
||||
await _sendCommand("leave_room", {"room_id": room.metadata!.id});
|
||||
}
|
||||
|
||||
// (await _sendCommand("get_event_context", {
|
||||
// "room_id": request.roomId,
|
||||
// "event_id": r"$OqZT4NuTj0J1-771IOEEWRI4XdumRNu6ighlvO3K3gc",
|
||||
// }));
|
||||
Future<void> leaveRoom(String roomId) =>
|
||||
_sendCommand("leave_room", {"room_id": roomId});
|
||||
|
||||
Future<IList<Event>> getRoomState(GetRoomStateRequest request) async {
|
||||
Future<List?> getState(GetRoomStateRequest request) async =>
|
||||
|
|
@ -243,6 +261,15 @@ class ClientController extends AsyncNotifier<int> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<IList<Event>> getSpecificRoomState(
|
||||
GetSpecificRoomStateRequest request,
|
||||
) async => .new(
|
||||
((await _sendCommand("get_specific_room_state", request.toJson()))
|
||||
as List? ??
|
||||
[])
|
||||
.map((event) => .fromJson(event)),
|
||||
);
|
||||
|
||||
Future<IList<Event>?> getRelatedEvents(
|
||||
GetRelatedEventsRequest request,
|
||||
) async {
|
||||
|
|
@ -251,6 +278,9 @@ class ClientController extends AsyncNotifier<int> {
|
|||
return .new(response?.map((event) => .fromJson(event)));
|
||||
}
|
||||
|
||||
Future<MentionsResponse> getMentions(GetMentionsRequest request) async =>
|
||||
.fromJson(await _sendCommand("get_mentions", request.toJson()));
|
||||
|
||||
Future<Event?> getEvent(GetEventRequest request) async {
|
||||
final json = await _sendCommand("get_event", request.toJson());
|
||||
return json == null ? null : .fromJson(json);
|
||||
|
|
@ -262,14 +292,35 @@ class ClientController extends AsyncNotifier<int> {
|
|||
Future<Paginate> paginate(PaginateRequest request) async =>
|
||||
.fromJson(await _sendCommand("paginate", request.toJson()));
|
||||
|
||||
Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
|
||||
.fromJson(await _sendCommand("paginate_manual", request.toJson()));
|
||||
|
||||
Future<PaginateManual> searchLocal(SearchRequest request) async =>
|
||||
.fromJson(await _sendCommand("search_local", request.toJson()));
|
||||
|
||||
Future<PaginateManual> searchServer(SearchRequest request) async =>
|
||||
.fromJson(await _sendCommand("search_server", request.toJson()));
|
||||
|
||||
Future<EventContext> getEventContext(GetEventContextRequest request) async =>
|
||||
.fromJson(await _sendCommand("get_event_context", request.toJson()));
|
||||
|
||||
Future<ProfileResponse> getProfile(String userId) async {
|
||||
try {
|
||||
return .fromJson(await _sendCommand("get_profile", {"user_id": userId}));
|
||||
} catch (_) {
|
||||
return ProfileResponse(profile: .new(id: userId));
|
||||
return .new(profile: .new(id: userId));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setProfileField(String field, Object? value) => _sendCommand(
|
||||
"set_profile_field",
|
||||
{
|
||||
"field": field,
|
||||
// gomuks deletes the field when the value is omitted, not when it's null
|
||||
"value": ?value,
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> reportEvent(ReportRequest request) =>
|
||||
_sendCommand("report_event", request.toJson());
|
||||
|
||||
|
|
@ -279,6 +330,15 @@ class ClientController extends AsyncNotifier<int> {
|
|||
Future<void> setAccountData(SetAccountDataRequest request) =>
|
||||
_sendCommand("set_account_data", request.toJson());
|
||||
|
||||
Future<void> registerPusher(RegisterPusherRequest request) =>
|
||||
_sendCommand("register_homeserver_push", request.toJson());
|
||||
|
||||
Future<void> deregisterPusher(DeregisterPusherRequest request) =>
|
||||
_sendCommand("register_homeserver_push", {
|
||||
...request.toJson(),
|
||||
"kind": null,
|
||||
});
|
||||
|
||||
Future<MessageContent> uploadMedia(UploadMediaRequest request) async =>
|
||||
.fromJson(await _sendCommand("upload_media", request.toJson()));
|
||||
|
||||
|
|
@ -287,14 +347,26 @@ class ClientController extends AsyncNotifier<int> {
|
|||
|
||||
Future<void> logout() => _sendCommand("logout");
|
||||
|
||||
Future<void> setTyping(String roomId, Duration timeout) => _sendCommand(
|
||||
"set_typing",
|
||||
{"room_id": roomId, "timeout": timeout.inMilliseconds},
|
||||
);
|
||||
|
||||
Future<void> markRead(Room room) async {
|
||||
if (room.timeline.isEmpty || room.metadata == null) return;
|
||||
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
|
||||
final event = eventRowId == null ? null : room.events[eventRowId];
|
||||
if (event == null || room.metadata == null) return;
|
||||
|
||||
if (event == null) return;
|
||||
|
||||
// Private until settings have loaded, in case the user turned sending off
|
||||
final sendPublic =
|
||||
ref.read(SettingsController.provider).value?.readReceipts ==
|
||||
.showAndSend;
|
||||
|
||||
await _sendCommand("mark_read", {
|
||||
"room_id": room.metadata!.id,
|
||||
"receipt_type": "m.read",
|
||||
"receipt_type": sendPublic ? "m.read" : "m.read.private",
|
||||
"event_id": event.eventId,
|
||||
});
|
||||
}
|
||||
|
|
@ -313,9 +385,40 @@ class ClientController extends AsyncNotifier<int> {
|
|||
Future<void> exchangeToken(OAuthExchangeTokenRequest request) async =>
|
||||
await _sendCommand("oauth_exchange_token", request.toJson());
|
||||
|
||||
Future<IList<String>> getMutualRooms(String userId) async => IList(
|
||||
((await _sendCommand("get_mutual_rooms", {"user_id": userId}))["joined"]
|
||||
as List?)
|
||||
?.cast<String>(),
|
||||
);
|
||||
|
||||
Future<IList<String>> getLocalAliases(String roomId) async => IList(
|
||||
((await _sendCommand("get_local_aliases", {"room_id": roomId}))["aliases"]
|
||||
as List?)
|
||||
?.cast<String>(),
|
||||
);
|
||||
|
||||
Future<void> createAlias(String roomId, String alias) =>
|
||||
_sendCommand("create_alias", {"room_id": roomId, "alias": alias});
|
||||
|
||||
Future<void> deleteAlias(String alias) =>
|
||||
_sendCommand("delete_alias", {"alias": alias});
|
||||
|
||||
/// The room [alias] points to, or null if it doesn't exist.
|
||||
Future<String?> resolveAlias(String alias) async {
|
||||
try {
|
||||
return (await _sendCommand("resolve_alias", {"alias": alias}))["room_id"];
|
||||
} on GomuksException catch (error) {
|
||||
if (error.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<SpecVersionsResponse> getSpecVersions() async =>
|
||||
.fromJson(await _sendCommand("get_versions"));
|
||||
|
||||
Future<Capabilities> getCapabilities() async =>
|
||||
.fromJson((await _sendCommand("get_capabilities"))["capabilities"]);
|
||||
|
||||
Future<Uri?> discoverHomeserver(Uri homeserver) async {
|
||||
try {
|
||||
final response = await _sendCommand("discover_homeserver", {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import "package:nexus/controllers/client.dart";
|
|||
class ClientIdController(final Uri homeserver) extends AsyncNotifier<String> {
|
||||
@override
|
||||
Future<String> build() => ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.read(ClientController.provider.notifier)
|
||||
.registerClient(
|
||||
.new(
|
||||
clientName: "Nexus",
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
import "dart:convert";
|
||||
import "package:emoji_text_field/models/emoji_category.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:http/http.dart";
|
||||
import "package:nexus/models/emoji.dart";
|
||||
|
||||
typedef EmojiTuple = (IMap<String, EmojiCategory>, IMap<String, List<String>>);
|
||||
|
||||
class EmojiController extends AsyncNotifier<EmojiTuple> {
|
||||
@override
|
||||
Future<EmojiTuple> build() async {
|
||||
final response = await get(
|
||||
.https("github.com", "github/gemoji/raw/refs/heads/master/db/emoji.json"),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception("Failed to load emoji data");
|
||||
}
|
||||
|
||||
final data = json.decode(response.body);
|
||||
|
||||
final entries = (data as List)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(Emoji.fromJson)
|
||||
.toIList();
|
||||
|
||||
final categoryMap = entries.fold<IMap<String, IList<String>>>(
|
||||
.new(),
|
||||
(acc, entry) => acc.update(
|
||||
entry.category,
|
||||
(list) => list.add(entry.emoji),
|
||||
ifAbsent: () => .new([entry.emoji]),
|
||||
),
|
||||
);
|
||||
|
||||
final keywordMap = entries.fold<IMap<String, IList<String>>>(
|
||||
.new(),
|
||||
(acc, entry) => acc.add(
|
||||
entry.emoji,
|
||||
.new([...entry.tags, ...entry.aliases, entry.description]),
|
||||
),
|
||||
);
|
||||
|
||||
final customCategories = IMap.fromEntries(
|
||||
categoryMap.entries.map(
|
||||
(entry) => MapEntry(
|
||||
entry.key,
|
||||
EmojiCategory(
|
||||
name: entry.key,
|
||||
icon: switch (entry.key) {
|
||||
"Smileys & Emotion" => Icons.emoji_emotions,
|
||||
"People & Body" => Icons.emoji_people,
|
||||
"Animals & Nature" => Icons.emoji_nature,
|
||||
"Food & Drink" => Icons.emoji_food_beverage,
|
||||
"Travel & Places" => Icons.travel_explore,
|
||||
"Activities" => Icons.sports_soccer,
|
||||
"Objects" => Icons.emoji_objects,
|
||||
"Symbols" => Icons.emoji_symbols,
|
||||
"Flags" => Icons.emoji_flags,
|
||||
_ => Icons.category,
|
||||
},
|
||||
emojis: entry.value.toList(growable: false),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final customKeywords = IMap(
|
||||
.fromEntries(
|
||||
keywordMap.entries.map(
|
||||
(e) => .new(e.key, e.value.toList(growable: false)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return (customCategories, customKeywords);
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider<EmojiController, EmojiTuple>(
|
||||
EmojiController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -9,18 +9,40 @@ class EventController(final GetEventRequest request)
|
|||
extends AsyncNotifier<Event?> {
|
||||
@override
|
||||
Future<Event?> build() async {
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select((value) => value[request.roomId]),
|
||||
);
|
||||
final event = room?.events.values.firstWhereOrNull(
|
||||
(event) => event.eventId == request.eventId,
|
||||
final event = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => rooms[request.roomId]?.events.values.firstWhereOrNull(
|
||||
(event) => event.eventId == request.eventId,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return event ??
|
||||
await ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.getEvent(request)
|
||||
.onError((_, _) => null);
|
||||
if (event != null) return event;
|
||||
|
||||
final link = ref.keepAlive();
|
||||
final fetchedEvent = await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.getEvent(request)
|
||||
.onError((_, _) => null)
|
||||
.whenComplete(link.close);
|
||||
|
||||
if (fetchedEvent != null) {
|
||||
// Storing the event invalidates this provider, as it watches the room.
|
||||
// If nothing is listening, Riverpod won't rebuild it, and this build's
|
||||
// result would be lost, so store it after returning.
|
||||
final rooms = ref.read(RoomsController.provider.notifier);
|
||||
Future.microtask(
|
||||
() => rooms.update(
|
||||
.new({
|
||||
request.roomId: .new(
|
||||
events: .new({fetchedEvent.rowId: fetchedEvent}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return fetchedEvent;
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
|
|
|
|||
97
lib/controllers/event_search.dart
Normal file
97
lib/controllers/event_search.dart
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/models/requests/search.dart";
|
||||
|
||||
typedef EventSearchParams = ({String roomId, String query});
|
||||
typedef EventSearchResults = ({IList<int> rowIds, String? nextBatch});
|
||||
|
||||
class EventSearchController(final EventSearchParams info)
|
||||
extends AsyncNotifier<EventSearchResults> {
|
||||
// We could have a mode without this for power users in the future
|
||||
static String toFtsQuery(String query) => query
|
||||
.split(RegExp(r"\s+"))
|
||||
.where((word) => word.isNotEmpty)
|
||||
.map((word) => '"${word.replaceAll('"', '""')}"*')
|
||||
.join(" ");
|
||||
|
||||
@override
|
||||
Future<EventSearchResults> build() async {
|
||||
if (info.query.trim().isEmpty) {
|
||||
return (rowIds: IList<int>(), nextBatch: null);
|
||||
}
|
||||
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
|
||||
final encrypted = ref.read(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => rooms[info.roomId]?.metadata?.encryptionEvent != null,
|
||||
),
|
||||
);
|
||||
final request = SearchRequest(
|
||||
searchTerm: encrypted ? toFtsQuery(info.query) : info.query,
|
||||
roomIds: .new([info.roomId]),
|
||||
);
|
||||
final response = encrypted
|
||||
? await client.searchLocal(request)
|
||||
: await client.searchServer(request);
|
||||
|
||||
if (!ref.mounted) return (rowIds: IList<int>(), nextBatch: null);
|
||||
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.storeEvents(response.events.addAll(response.relatedEvents));
|
||||
|
||||
return (
|
||||
rowIds: response.events.map((event) => event.rowId).toIList(),
|
||||
nextBatch: response.nextBatch,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> paginate() async {
|
||||
if (state.isLoading) return;
|
||||
|
||||
final results = await future;
|
||||
if (!ref.mounted || results.nextBatch == null) return;
|
||||
|
||||
state = .loading();
|
||||
|
||||
final encrypted = ref.read(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => rooms[info.roomId]?.metadata?.encryptionEvent != null,
|
||||
),
|
||||
);
|
||||
|
||||
final request = SearchRequest(
|
||||
searchTerm: encrypted ? toFtsQuery(info.query) : info.query,
|
||||
roomIds: .new([info.roomId]),
|
||||
nextBatch: results.nextBatch,
|
||||
);
|
||||
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
final response = encrypted
|
||||
? await client.searchLocal(request)
|
||||
: await client.searchServer(request);
|
||||
|
||||
if (!ref.mounted) return;
|
||||
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.storeEvents(response.events.addAll(response.relatedEvents));
|
||||
|
||||
state = .data((
|
||||
rowIds: results.rowIds.addAll(
|
||||
response.events.map((event) => event.rowId),
|
||||
),
|
||||
nextBatch: response.nextBatch,
|
||||
));
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
.autoDispose<
|
||||
EventSearchController,
|
||||
EventSearchResults,
|
||||
EventSearchParams
|
||||
>(EventSearchController.new);
|
||||
}
|
||||
193
lib/controllers/gomuks_listener.dart
Normal file
193
lib/controllers/gomuks_listener.dart
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import "dart:async";
|
||||
import "dart:ffi";
|
||||
import "dart:io";
|
||||
import "dart:isolate";
|
||||
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:nexus/controllers/account_data.dart";
|
||||
import "package:nexus/controllers/app_paused.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/init_complete.dart";
|
||||
import "package:nexus/controllers/invites.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/space_edges.dart";
|
||||
import "package:nexus/controllers/sync_status.dart";
|
||||
import "package:nexus/controllers/top_level_spaces.dart";
|
||||
import "package:nexus/controllers/typing.dart";
|
||||
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:ffi/ffi.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/events_decrypted.dart";
|
||||
import "package:nexus/models/sync_data.dart";
|
||||
import "package:nexus/src/third_party/gomuks.g.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
class GomuksListenerController extends AsyncNotifier<void> {
|
||||
@override
|
||||
Future<void> build() async {
|
||||
debugPrint("Starting gomuks...");
|
||||
final handle = await ref.watch(ClientController.provider.future);
|
||||
|
||||
final callable =
|
||||
NativeCallable<
|
||||
Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer)
|
||||
>.listener((
|
||||
Pointer<Char> command,
|
||||
int requestId,
|
||||
GomuksOwnedBuffer data,
|
||||
) {
|
||||
try {
|
||||
final muksEventType = command.cast<Utf8>().toDartString();
|
||||
debugPrint("Handling $muksEventType...");
|
||||
final decodedMuksEvent = data.toJsonAndFree();
|
||||
|
||||
switch (muksEventType) {
|
||||
case "client_state":
|
||||
ref
|
||||
.read(ClientStateController.provider.notifier)
|
||||
.set(.fromJson(decodedMuksEvent));
|
||||
case "sync_status":
|
||||
ref
|
||||
.read(SyncStatusController.provider.notifier)
|
||||
.set(.fromJson(decodedMuksEvent));
|
||||
case "init_complete":
|
||||
ref.read(InitCompleteController.provider.notifier).complete();
|
||||
case "send_complete":
|
||||
final event = Event.fromJson(decodedMuksEvent["event"]);
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.update(
|
||||
.new({
|
||||
event.roomId: .new(events: .new({event.rowId: event})),
|
||||
}),
|
||||
);
|
||||
case "typing":
|
||||
ref
|
||||
.read(TypingController.provider.notifier)
|
||||
.set(.fromJson(decodedMuksEvent));
|
||||
case "events_decrypted":
|
||||
final decrypted = EventsDecrypted.fromJson(decodedMuksEvent);
|
||||
final metadata = ref
|
||||
.read(RoomsController.provider)[decrypted.roomId]
|
||||
?.metadata;
|
||||
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.update(
|
||||
.new({
|
||||
decrypted.roomId: .new(
|
||||
events: decrypted.events,
|
||||
metadata: metadata?.copyWith(
|
||||
previewEventRowID:
|
||||
decrypted.previewEventRowID ??
|
||||
metadata.previewEventRowID,
|
||||
sortingTimestamp:
|
||||
decrypted.sortingTimestamp ??
|
||||
metadata.sortingTimestamp,
|
||||
),
|
||||
),
|
||||
}),
|
||||
);
|
||||
case "sync_complete":
|
||||
final syncData = SyncData.fromJson(decodedMuksEvent);
|
||||
final roomProvider = RoomsController.provider;
|
||||
final accountDataProvider = AccountDataController.provider;
|
||||
final invitesProvider = InvitesController.provider;
|
||||
|
||||
if (syncData.clearState) {
|
||||
ref.invalidate(roomProvider);
|
||||
ref.invalidate(accountDataProvider);
|
||||
ref.invalidate(invitesProvider);
|
||||
ref.invalidate(TypingController.provider);
|
||||
}
|
||||
|
||||
ref
|
||||
.read(roomProvider.notifier)
|
||||
.update(syncData.rooms, syncData.leftRooms);
|
||||
ref
|
||||
.read(accountDataProvider.notifier)
|
||||
.update(syncData.accountData);
|
||||
ref
|
||||
.read(invitesProvider.notifier)
|
||||
.update(
|
||||
syncData.invitedRooms,
|
||||
joinedRooms: syncData.rooms.keys.toISet(),
|
||||
leftRooms: syncData.leftRooms,
|
||||
);
|
||||
|
||||
if (syncData.topLevelSpaces case final topLevelSpaces?) {
|
||||
ref
|
||||
.read(TopLevelSpacesController.provider.notifier)
|
||||
.set(topLevelSpaces);
|
||||
}
|
||||
|
||||
if (syncData.spaceEdges case final spaceEdges?) {
|
||||
ref
|
||||
.read(SpaceEdgesController.provider.notifier)
|
||||
.set(spaceEdges);
|
||||
}
|
||||
default:
|
||||
debugPrint("Unhandled event: $muksEventType");
|
||||
}
|
||||
debugPrint("Finished handling $muksEventType...");
|
||||
} catch (error, stackTrace) {
|
||||
if (kDebugMode) {
|
||||
debugPrintStack(stackTrace: stackTrace, label: error.toString());
|
||||
rethrow;
|
||||
} else {
|
||||
showError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ref.onDispose(callable.close);
|
||||
|
||||
final errorCode = await _start(handle, callable.nativeFunction);
|
||||
if (errorCode != 0) {
|
||||
throw Exception("GomuksStart returned error code $errorCode");
|
||||
}
|
||||
|
||||
if (!ref.mounted) return;
|
||||
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
// Mobile OSes block network access for backgrounded apps, so pause
|
||||
// syncing instead of letting it fail.
|
||||
ref.listen(AppPausedController.provider, (_, paused) {
|
||||
if (paused) {
|
||||
GomuksPauseSync(handle);
|
||||
} else {
|
||||
ref.invalidate(TypingController.provider);
|
||||
GomuksResumeSync(handle);
|
||||
}
|
||||
}, fireImmediately: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for gomuks to report the client's device ID
|
||||
Future<String> deviceId() {
|
||||
final completer = Completer<String>();
|
||||
final subscription = ref.listen(
|
||||
ClientStateController.provider.select((value) => value?.deviceId),
|
||||
(_, next) {
|
||||
if (next != null && !completer.isCompleted) completer.complete(next);
|
||||
},
|
||||
fireImmediately: true,
|
||||
);
|
||||
return completer.future.whenComplete(subscription.close);
|
||||
}
|
||||
|
||||
static Future<int> _start(
|
||||
int handle,
|
||||
Pointer<
|
||||
NativeFunction<Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer)>
|
||||
>
|
||||
callback,
|
||||
) => Isolate.run(() => GomuksStart(handle, callback));
|
||||
|
||||
static final provider = AsyncNotifierProvider<GomuksListenerController, void>(
|
||||
GomuksListenerController.new,
|
||||
);
|
||||
}
|
||||
112
lib/controllers/image_pack_sources.dart
Normal file
112
lib/controllers/image_pack_sources.dart
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/account_data.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/space_edges.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/requests/get_specific_room_state.dart";
|
||||
|
||||
typedef ImagePackSource = ({String roomId, String? stateKey});
|
||||
|
||||
class ImagePackSourcesController(final String roomId)
|
||||
extends AsyncNotifier<IList<ImagePackSource>> {
|
||||
@override
|
||||
Future<IList<ImagePackSource>> build() async {
|
||||
final enabledPacks = ref.watch(
|
||||
AccountDataController.provider.select(
|
||||
(data) =>
|
||||
(data.imagePackRooms ?? data.legacyImagePackRooms)?.rooms.map(
|
||||
(roomId, packs) => .new(roomId, packs.keys.toISet()),
|
||||
) ??
|
||||
const .empty(),
|
||||
),
|
||||
);
|
||||
final spaceEdges = ref.watch(SpaceEdgesController.provider);
|
||||
final directParents = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) =>
|
||||
rooms[roomId]?.state["m.space.parent"]?.keys.toISet() ??
|
||||
const .empty(),
|
||||
),
|
||||
);
|
||||
|
||||
Iterable<String> parentsOf(String childId) => spaceEdges.entries
|
||||
.where((entry) => entry.value.any((edge) => edge.childId == childId))
|
||||
.map((entry) => entry.key)
|
||||
.followedBy(childId == roomId ? directParents : const []);
|
||||
|
||||
ISet<String> withAncestors(ISet<String> spaces, String childId) =>
|
||||
parentsOf(childId).fold(
|
||||
spaces,
|
||||
(spaces, parentId) => spaces.contains(parentId)
|
||||
? spaces
|
||||
: withAncestors(spaces.add(parentId), parentId),
|
||||
);
|
||||
|
||||
final rooms = ref.read(RoomsController.provider);
|
||||
final spaces = withAncestors(
|
||||
const .empty(),
|
||||
roomId,
|
||||
).remove(roomId).where(rooms.containsKey).toIList();
|
||||
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
final roomsNotifier = ref.read(RoomsController.provider.notifier);
|
||||
|
||||
await Future.wait(
|
||||
spaces
|
||||
.where((spaceId) => !rooms[spaceId]!.hasFetchedState)
|
||||
.map(
|
||||
(spaceId) async => roomsNotifier.addState(
|
||||
spaceId,
|
||||
await client.getRoomState(.new(roomId: spaceId)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const packTypes = <EventType>[.imagePack, .legacyImagePack];
|
||||
// Rooms we aren't in can't be fetched, and fetched rooms have every pack
|
||||
final missingKeys = enabledPacks.entries
|
||||
.where((entry) => !(rooms[entry.key]?.hasFetchedState ?? true))
|
||||
.expand(
|
||||
(entry) => entry.value
|
||||
.where(
|
||||
(stateKey) => packTypes.every(
|
||||
(type) =>
|
||||
rooms[entry.key]?.state[type.type]?[stateKey] == null,
|
||||
),
|
||||
)
|
||||
.expand(
|
||||
(stateKey) => packTypes.map(
|
||||
(type) => RoomStateKey(
|
||||
roomId: entry.key,
|
||||
type: type.type,
|
||||
stateKey: stateKey,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toIList();
|
||||
|
||||
if (missingKeys.isNotEmpty) {
|
||||
roomsNotifier.addSpecificState(
|
||||
await client.getSpecificRoomState(.new(keys: missingKeys)),
|
||||
);
|
||||
}
|
||||
|
||||
return .new([
|
||||
(roomId: roomId, stateKey: null),
|
||||
...spaces.map((spaceId) => (roomId: spaceId, stateKey: null)),
|
||||
...enabledPacks.entries.expand(
|
||||
(entry) => entry.value.map(
|
||||
(stateKey) => (roomId: entry.key, stateKey: stateKey),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.autoDispose
|
||||
.family<ImagePackSourcesController, IList<ImagePackSource>, String>(
|
||||
ImagePackSourcesController.new,
|
||||
);
|
||||
}
|
||||
72
lib/controllers/image_packs.dart
Normal file
72
lib/controllers/image_packs.dart
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/image_pack_sources.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/image_pack.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/image_pack.dart";
|
||||
|
||||
class ImagePacksController(final String roomId)
|
||||
extends AsyncNotifier<IList<ImagePack>> {
|
||||
@override
|
||||
Future<IList<ImagePack>> build() async {
|
||||
final sources = await ref.watch(
|
||||
ImagePackSourcesController.provider(roomId).future,
|
||||
);
|
||||
|
||||
final packs = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => sources.expand((source) {
|
||||
final room = rooms[source.roomId];
|
||||
if (room == null) return const <ImagePack>[];
|
||||
|
||||
final newPacks = room.state[EventType.imagePack.type];
|
||||
final legacyPacks = room.state[EventType.legacyImagePack.type];
|
||||
final roomName = room.metadata?.name ?? source.roomId;
|
||||
|
||||
return switch (source.stateKey) {
|
||||
final stateKey? => ISet([stateKey]),
|
||||
null => ISet(
|
||||
newPacks?.keys ?? const <String>[],
|
||||
).addAll(legacyPacks?.keys ?? const []),
|
||||
}
|
||||
.map(
|
||||
(stateKey) =>
|
||||
switch (newPacks?[stateKey] ?? legacyPacks?[stateKey]) {
|
||||
final rowId? => switch (room.events[rowId]) {
|
||||
Event(
|
||||
redactedBy: null,
|
||||
content: final ImagePackContent content,
|
||||
) =>
|
||||
ImagePack.fromContent(
|
||||
content,
|
||||
id: "${source.roomId}/$stateKey",
|
||||
fallbackName: stateKey.isEmpty
|
||||
? roomName
|
||||
: "$roomName - $stateKey",
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
null => null,
|
||||
},
|
||||
)
|
||||
.nonNulls;
|
||||
}).toIList(),
|
||||
),
|
||||
);
|
||||
|
||||
return .new(
|
||||
packs
|
||||
.whereNoDuplicates(by: (pack) => pack.id)
|
||||
.where(
|
||||
(pack) => pack.emoticons.isNotEmpty || pack.stickers.isNotEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.autoDispose
|
||||
.family<ImagePacksController, IList<ImagePack>, String>(
|
||||
ImagePacksController.new,
|
||||
);
|
||||
}
|
||||
122
lib/controllers/invites.dart
Normal file
122
lib/controllers/invites.dart
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/models/content/avatar.dart";
|
||||
import "package:nexus/models/content/canonical_alias.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/create.dart";
|
||||
import "package:nexus/models/content/encryption.dart";
|
||||
import "package:nexus/models/content/history_visibility.dart";
|
||||
import "package:nexus/models/content/join_rules.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/models/content/name.dart";
|
||||
import "package:nexus/models/content/topic.dart";
|
||||
import "package:nexus/models/invited_room.dart";
|
||||
import "package:nexus/models/room_summary.dart";
|
||||
|
||||
/// Pending invites, newest first.
|
||||
class InvitesController extends Notifier<IList<RoomSummary>> {
|
||||
@override
|
||||
IList<RoomSummary> build() => .new();
|
||||
|
||||
/// Adds new invites, and removes invites for rooms that have since been
|
||||
/// joined or left.
|
||||
void update(
|
||||
IList<InvitedRoom> invitedRooms, {
|
||||
ISet<String> joinedRooms = const .empty(),
|
||||
ISet<String> leftRooms = const .empty(),
|
||||
}) {
|
||||
final userId = ref.read(ClientStateController.provider)?.userId;
|
||||
|
||||
RoomSummary toSummary(InvitedRoom invite) {
|
||||
final summary = invite.inviteState.fold(
|
||||
RoomSummary(roomId: invite.roomId, membership: .invite),
|
||||
(summary, event) => switch (event.content) {
|
||||
NameContent(:final name) when name.isNotEmpty => summary.copyWith(
|
||||
name: name,
|
||||
),
|
||||
AvatarContent(:final url) => summary.copyWith(avatarUrl: url),
|
||||
TopicContent(:final topic) when topic.isNotEmpty => summary.copyWith(
|
||||
topic: topic,
|
||||
),
|
||||
CanonicalAliasContent(:final alias) => summary.copyWith(
|
||||
canonicalAlias: alias,
|
||||
),
|
||||
JoinRulesContent(:final joinRule) => summary.copyWith(
|
||||
joinRule: joinRule,
|
||||
),
|
||||
CreateContent(:final type, :final roomVersion) => summary.copyWith(
|
||||
roomType: type,
|
||||
roomVersion: roomVersion,
|
||||
),
|
||||
EncryptionContent(:final algorithm) => summary.copyWith(
|
||||
encryption: algorithm,
|
||||
),
|
||||
HistoryVisibilityContent(:final historyVisibility) =>
|
||||
summary.copyWith(
|
||||
worldReadable: historyVisibility == .worldReadable,
|
||||
),
|
||||
MembershipContent(:final isDirect, :final reason)
|
||||
when event.stateKey == userId =>
|
||||
summary.copyWith(
|
||||
invitedBy: event.sender,
|
||||
inviteReason: reason,
|
||||
isDirect: isDirect,
|
||||
),
|
||||
_ => summary,
|
||||
},
|
||||
);
|
||||
|
||||
// The inviter is only known once the user's own invite has been found
|
||||
final inviterProfile = switch (invite.inviteState
|
||||
.firstWhereOrNull(
|
||||
(event) =>
|
||||
event.type == EventType.membership.type &&
|
||||
event.stateKey == summary.invitedBy,
|
||||
)
|
||||
?.content) {
|
||||
MembershipContent content => content,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return summary.copyWith(
|
||||
inviterProfile: inviterProfile,
|
||||
// Direct messages usually have no name, so use the inviter's
|
||||
name:
|
||||
summary.name ??
|
||||
(summary.isDirect
|
||||
? inviterProfile?.displayName ?? summary.invitedBy?.localpart
|
||||
: null),
|
||||
);
|
||||
}
|
||||
|
||||
final newInvites = invitedRooms.map(toSummary).toIList();
|
||||
final newRoomIds = newInvites.map((invite) => invite.roomId).toISet();
|
||||
final removed = joinedRooms.addAll(leftRooms);
|
||||
|
||||
state = newInvites
|
||||
.addAll(state.where((invite) => !newRoomIds.contains(invite.roomId)))
|
||||
.removeWhere((invite) => removed.contains(invite.roomId));
|
||||
}
|
||||
|
||||
void remove(String roomId) =>
|
||||
state = state.removeWhere((invite) => invite.roomId == roomId);
|
||||
|
||||
Future<void> decline(String roomId) async {
|
||||
await ref.read(ClientController.provider.notifier).leaveRoom(roomId);
|
||||
remove(roomId);
|
||||
}
|
||||
|
||||
/// Declines every invite, removing each as it succeeds. If any fail, the
|
||||
/// first error is thrown once all have finished.
|
||||
Future<void> declineAll() =>
|
||||
Future.wait(state.map((invite) => decline(invite.roomId)));
|
||||
|
||||
static final provider =
|
||||
NotifierProvider<InvitesController, IList<RoomSummary>>(
|
||||
InvitesController.new,
|
||||
);
|
||||
}
|
||||
17
lib/controllers/jump_request.dart
Normal file
17
lib/controllers/jump_request.dart
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
typedef JumpRequest = ({String roomId, int rowId});
|
||||
|
||||
class JumpRequestController extends Notifier<JumpRequest?> {
|
||||
@override
|
||||
JumpRequest? build() => null;
|
||||
|
||||
void request(String roomId, int rowId) =>
|
||||
state = (roomId: roomId, rowId: rowId);
|
||||
|
||||
void consume() => state = null;
|
||||
|
||||
static final provider = NotifierProvider<JumpRequestController, JumpRequest?>(
|
||||
JumpRequestController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,27 +1,28 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/shared_prefs.dart";
|
||||
|
||||
class KeyController(final String key) extends Notifier<String?> {
|
||||
class KeyController(final String key) extends AsyncNotifier<String?> {
|
||||
static const String spaceKey = "space";
|
||||
static const String roomKey = "room";
|
||||
static const String pushKeyKey = "pushKey";
|
||||
|
||||
@override
|
||||
String? build() =>
|
||||
ref.watch(SharedPrefsController.provider).requireValue.getString(key);
|
||||
Future<String?> build() =>
|
||||
ref.watch(SharedPrefsController.provider).getString(key);
|
||||
|
||||
Future<void> set(String? value) async {
|
||||
final prefs = ref.watch(SharedPrefsController.provider).requireValue;
|
||||
state = value;
|
||||
final prefs = ref.read(SharedPrefsController.provider);
|
||||
state = .data(value);
|
||||
|
||||
if (value == null) {
|
||||
prefs.remove(key);
|
||||
await prefs.remove(key);
|
||||
} else {
|
||||
prefs.setString(key, value);
|
||||
await prefs.setString(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
static final provider =
|
||||
NotifierProvider.family<KeyController, String?, String>(
|
||||
AsyncNotifierProvider.family<KeyController, String?, String>(
|
||||
KeyController.new,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
28
lib/controllers/local_aliases.dart
Normal file
28
lib/controllers/local_aliases.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
|
||||
/// A room's aliases on the user's own server.
|
||||
class LocalAliasesController(final String roomId)
|
||||
extends AsyncNotifier<IList<String>> {
|
||||
@override
|
||||
Future<IList<String>> build() =>
|
||||
ref.read(ClientController.provider.notifier).getLocalAliases(roomId);
|
||||
|
||||
Future<void> add(String alias) async {
|
||||
await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.createAlias(roomId, alias);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
Future<void> remove(String alias) async {
|
||||
await ref.read(ClientController.provider.notifier).deleteAlias(alias);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
.autoDispose<LocalAliasesController, IList<String>, String>(
|
||||
LocalAliasesController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +1,20 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/shared_prefs.dart";
|
||||
|
||||
class MemberListOpenedController extends Notifier<bool> {
|
||||
class MemberListOpenedController extends AsyncNotifier<bool> {
|
||||
static const String key = "memberListOpened";
|
||||
|
||||
@override
|
||||
bool build() =>
|
||||
ref.watch(SharedPrefsController.provider).requireValue.getBool(key) ??
|
||||
true;
|
||||
Future<bool> build() async =>
|
||||
await ref.watch(SharedPrefsController.provider).getBool(key) ?? true;
|
||||
|
||||
Future<void> set(bool value) async {
|
||||
final prefs = ref.watch(SharedPrefsController.provider).requireValue;
|
||||
state = value;
|
||||
|
||||
prefs.setBool(key, value);
|
||||
state = .data(value);
|
||||
await ref.read(SharedPrefsController.provider).setBool(key, value);
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider<MemberListOpenedController, bool>(
|
||||
MemberListOpenedController.new,
|
||||
);
|
||||
static final provider =
|
||||
AsyncNotifierProvider<MemberListOpenedController, bool>(
|
||||
MemberListOpenedController.new,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class MembersController(final String roomId)
|
|||
|
||||
if (!room.hasFetchedMembers) {
|
||||
final fetchedState = await ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.read(ClientController.provider.notifier)
|
||||
.getRoomState(
|
||||
GetRoomStateRequest(
|
||||
roomId: roomId,
|
||||
|
|
@ -27,7 +27,7 @@ class MembersController(final String roomId)
|
|||
),
|
||||
);
|
||||
|
||||
await ref
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.addState(roomId, fetchedState, isMembers: true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,27 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/members_by_status.dart";
|
||||
import "package:nexus/controllers/room_creators.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/room_power_levels.dart";
|
||||
import "package:nexus/models/configs/members_by_status.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
|
||||
class MembersGroupedController(final MembersByStatusConfig config)
|
||||
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
|
||||
@override
|
||||
Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select((value) => value[config.roomId]),
|
||||
final powerLevels = ref.watch(
|
||||
RoomPowerLevelsController.provider(config.roomId),
|
||||
);
|
||||
|
||||
final roomCreators = room == null
|
||||
? null
|
||||
: ref.watch((RoomCreatorsController.provider(room)));
|
||||
|
||||
final powerLevelsRowId = room?.state[EventType.powerLevels.type]?[""];
|
||||
final powerLevelsEvent = powerLevelsRowId == null
|
||||
? null
|
||||
: room?.events[powerLevelsRowId];
|
||||
|
||||
final content = switch (powerLevelsEvent?.content) {
|
||||
PowerLevelsContent content => content,
|
||||
_ => PowerLevelsContent(),
|
||||
};
|
||||
|
||||
final members = await ref.watch(
|
||||
MembersByStatusController.provider(config).future,
|
||||
);
|
||||
|
||||
return members
|
||||
.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
|
||||
final groupKey = roomCreators?.contains(event.stateKey!) == true
|
||||
final groupKey = powerLevels.creators.contains(event.stateKey!)
|
||||
? null
|
||||
: content.users[event.stateKey!] ?? content.usersDefault;
|
||||
: powerLevels.levelOf(event.stateKey!).toInt();
|
||||
|
||||
return result.update(
|
||||
groupKey,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
|
|||
class MultiProviderController(final IList<AsyncNotifierProvider> providers)
|
||||
extends AsyncNotifier<void> {
|
||||
@override
|
||||
Future<void> build() =>
|
||||
.wait(providers.map((provider) => ref.watch(provider.future)));
|
||||
Future<void> build() => .wait(
|
||||
providers.map((provider) => ref.watch(provider.future)),
|
||||
eagerError: true,
|
||||
);
|
||||
|
||||
static final provider =
|
||||
AsyncNotifierProvider.family<
|
||||
|
|
|
|||
15
lib/controllers/mutual_rooms.dart
Normal file
15
lib/controllers/mutual_rooms.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
|
||||
class MutualRoomsController(final String userId)
|
||||
extends AsyncNotifier<IList<String>> {
|
||||
@override
|
||||
Future<IList<String>> build() =>
|
||||
ref.read(ClientController.provider.notifier).getMutualRooms(userId);
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
.autoDispose<MutualRoomsController, IList<String>, String>(
|
||||
MutualRoomsController.new,
|
||||
);
|
||||
}
|
||||
281
lib/controllers/notification.dart
Normal file
281
lib/controllers/notification.dart
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import "dart:async";
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
|
||||
import "package:flutter/services.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/portal.dart";
|
||||
import "package:nexus/controllers/sidebar_page.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:flutter_local_notifications/flutter_local_notifications.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/notification_request.dart";
|
||||
import "package:nexus/models/push_notification.dart";
|
||||
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
|
||||
|
||||
typedef NotificationMessage = ({
|
||||
NotificationUser sender,
|
||||
NotificationUser self,
|
||||
File? senderAvatar,
|
||||
bool isDm,
|
||||
});
|
||||
|
||||
class NotificationController
|
||||
extends AsyncNotifier<FlutterLocalNotificationsPlugin> {
|
||||
@override
|
||||
Future<FlutterLocalNotificationsPlugin> build() async {
|
||||
final notifications = FlutterLocalNotificationsPlugin();
|
||||
|
||||
if (!Platform.isLinux) {
|
||||
final darwin = DarwinInitializationSettings();
|
||||
|
||||
await notifications.initialize(
|
||||
settings: .new(
|
||||
windows: .new(
|
||||
appName: "Nexus",
|
||||
appUserModelId: "nexus.federated.nexus",
|
||||
guid: "dde78daf-130f-4e46-a80a-e31deeab45d7",
|
||||
),
|
||||
android: .new("ic_launcher_foreground"),
|
||||
iOS: darwin,
|
||||
macOS: darwin,
|
||||
),
|
||||
onDidReceiveNotificationResponse: (details) {
|
||||
if (details.payload == null) return;
|
||||
|
||||
if (int.tryParse(details.payload!) case final rowId?) {
|
||||
navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
ref
|
||||
.read(SidebarPageController.provider.notifier)
|
||||
.set(.notifications);
|
||||
ref
|
||||
.read(NotificationRequestController.provider.notifier)
|
||||
.request(rowId);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isLinux) {
|
||||
const notificationChannel = MethodChannel("nexus/notifications");
|
||||
|
||||
notificationChannel.setMethodCallHandler((call) async {
|
||||
if (call.method != "notificationClicked") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (int.tryParse(call.arguments) case int rowId) {
|
||||
navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||
ref.read(SidebarPageController.provider.notifier).set(.notifications);
|
||||
ref
|
||||
.read(NotificationRequestController.provider.notifier)
|
||||
.request(rowId);
|
||||
}
|
||||
});
|
||||
|
||||
ref.onDispose(() => notificationChannel.setMethodCallHandler(null));
|
||||
}
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
Future<bool> requestPermissions() async {
|
||||
if (Platform.isLinux) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final controller = await future;
|
||||
|
||||
if (Platform.isIOS) {
|
||||
return await controller
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true) ??
|
||||
true;
|
||||
} else if (Platform.isMacOS) {
|
||||
return await controller
|
||||
.resolvePlatformSpecificImplementation<
|
||||
MacOSFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true) ??
|
||||
true;
|
||||
} else if (Platform.isAndroid) {
|
||||
return await controller
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestNotificationsPermission() ??
|
||||
true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Whether notifications can currently be shown, without prompting
|
||||
Future<bool> permissionsGranted() async {
|
||||
if (Platform.isLinux) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final controller = await future;
|
||||
|
||||
if (Platform.isIOS) {
|
||||
return (await controller
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.checkPermissions())
|
||||
?.isEnabled ??
|
||||
true;
|
||||
} else if (Platform.isMacOS) {
|
||||
return (await controller
|
||||
.resolvePlatformSpecificImplementation<
|
||||
MacOSFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.checkPermissions())
|
||||
?.isEnabled ??
|
||||
true;
|
||||
} else if (Platform.isAndroid) {
|
||||
return await controller
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.areNotificationsEnabled() ??
|
||||
true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Passing [message] shows it as a chat message in the conversation for
|
||||
/// [threadId], naming the sender in group chats.
|
||||
Future<void> send({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
String? payload,
|
||||
String? threadId,
|
||||
DateTime? timestamp,
|
||||
File? icon,
|
||||
bool sound = true,
|
||||
NotificationMessage? message,
|
||||
}) async {
|
||||
debugPrint("Sending notification for $id");
|
||||
|
||||
// In DMs the title is already the sender
|
||||
final sender = message == null || message.isDm ? null : message.sender;
|
||||
final prefixedBody = sender == null ? body : "${sender.name}: $body";
|
||||
|
||||
if (Platform.isLinux) {
|
||||
final portal = await ref.read(PortalController.provider.future);
|
||||
const escape = HtmlEscape(.element);
|
||||
|
||||
await portal.notification.addNotification(
|
||||
id.toString(),
|
||||
title: title,
|
||||
body: prefixedBody,
|
||||
markupBody: sender == null
|
||||
? null
|
||||
: "<b>${escape.convert(sender.name)}</b>: ${escape.convert(body)}",
|
||||
defaultAction: "app.event",
|
||||
defaultActionTarget: payload,
|
||||
sound: sound ? null : const XdgNotificationSoundSilent(),
|
||||
category: message == null ? null : XdgNotificationCategory.imReceived,
|
||||
priority: .normal,
|
||||
buttons: [
|
||||
// TODO: Impl when notif daemons actualy support imReplyWithText
|
||||
// .new(
|
||||
// label: "Reply",
|
||||
// action: "app.reply",
|
||||
// purpose: XdgNotificationButtonPurpose.imReplyWithText,
|
||||
// ),
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final controller = await future;
|
||||
|
||||
// Append to the conversation already shown under this id, if any
|
||||
final existingStyle = Platform.isAndroid && message != null
|
||||
? await controller
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.getActiveNotificationMessagingStyle(id: id)
|
||||
.onError((_, _) => null)
|
||||
: null;
|
||||
|
||||
final darwin = DarwinNotificationDetails(
|
||||
presentSound: sound,
|
||||
threadIdentifier: threadId,
|
||||
);
|
||||
|
||||
await controller.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: prefixedBody,
|
||||
payload: payload,
|
||||
notificationDetails: .new(
|
||||
android: message == null
|
||||
? .new(
|
||||
"general",
|
||||
"General",
|
||||
silent: !sound,
|
||||
when: timestamp?.millisecondsSinceEpoch,
|
||||
largeIcon: icon == null
|
||||
? null
|
||||
: FilePathAndroidBitmap(icon.path),
|
||||
styleInformation: BigTextStyleInformation(body),
|
||||
)
|
||||
: .new(
|
||||
"messages",
|
||||
"Messages",
|
||||
silent: !sound,
|
||||
when: timestamp?.millisecondsSinceEpoch,
|
||||
category: .message,
|
||||
largeIcon: icon == null
|
||||
? null
|
||||
: FilePathAndroidBitmap(icon.path),
|
||||
styleInformation: MessagingStyleInformation(
|
||||
.new(key: message.self.id, name: message.self.name),
|
||||
conversationTitle: message.isDm ? null : title,
|
||||
groupConversation: !message.isDm,
|
||||
messages: [
|
||||
...?existingStyle?.messages,
|
||||
.new(
|
||||
body,
|
||||
timestamp ?? .now(),
|
||||
.new(
|
||||
key: message.sender.id,
|
||||
name: message.sender.name,
|
||||
icon: message.senderAvatar == null
|
||||
? null
|
||||
: BitmapFilePathAndroidIcon(
|
||||
message.senderAvatar!.path,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
iOS: darwin,
|
||||
macOS: darwin,
|
||||
windows: .new(
|
||||
audio: sound ? null : .silent(),
|
||||
timestamp: timestamp,
|
||||
header: threadId == null
|
||||
? null
|
||||
: .new(id: threadId, title: title, arguments: payload ?? ""),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static final provider =
|
||||
AsyncNotifierProvider<
|
||||
NotificationController,
|
||||
FlutterLocalNotificationsPlugin
|
||||
>(NotificationController.new);
|
||||
}
|
||||
14
lib/controllers/notification_request.dart
Normal file
14
lib/controllers/notification_request.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
class NotificationRequestController extends Notifier<int?> {
|
||||
@override
|
||||
int? build() => null;
|
||||
|
||||
void request(int rowId) => state = rowId;
|
||||
|
||||
void consume() => state = null;
|
||||
|
||||
static final provider = NotifierProvider<NotificationRequestController, int?>(
|
||||
NotificationRequestController.new,
|
||||
);
|
||||
}
|
||||
78
lib/controllers/notifications.dart
Normal file
78
lib/controllers/notifications.dart
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
|
||||
typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
|
||||
|
||||
class NotificationsController(final NotificationsRequest request)
|
||||
extends AsyncNotifier<IList<(int, String)>> {
|
||||
@override
|
||||
Future<IList<(int, String)>> build() async {
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
|
||||
final (unreadType, roomId) = request;
|
||||
|
||||
final mentions = await client.getMentions(
|
||||
.new(
|
||||
maxTimestamp: .now(),
|
||||
unreadType: unreadType ?? .highlight,
|
||||
roomId: roomId,
|
||||
),
|
||||
);
|
||||
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.storeEvents(mentions.events.addAll(mentions.relatedEvents));
|
||||
|
||||
return .new(mentions.events.map((event) => (event.rowId, event.roomId)));
|
||||
}
|
||||
|
||||
Future<void> loadOlder() async {
|
||||
final currentNotifications = await future;
|
||||
state = .loading();
|
||||
state = await .guard(() async {
|
||||
final lastNotification = currentNotifications.lastOrNull;
|
||||
final lastTs = lastNotification == null
|
||||
? null
|
||||
: ref.read(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => rooms[lastNotification.$2]
|
||||
?.events[lastNotification.$1]
|
||||
?.timestamp,
|
||||
),
|
||||
);
|
||||
|
||||
if (lastTs == null) return currentNotifications;
|
||||
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
final (unreadType, roomId) = request;
|
||||
|
||||
final newNotifications = await client.getMentions(
|
||||
.new(
|
||||
maxTimestamp: lastTs,
|
||||
unreadType: unreadType ?? .highlight,
|
||||
roomId: roomId,
|
||||
),
|
||||
);
|
||||
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.storeEvents(
|
||||
newNotifications.events.addAll(newNotifications.relatedEvents),
|
||||
);
|
||||
|
||||
return currentNotifications.addAll(
|
||||
newNotifications.events.map((event) => (event.rowId, event.roomId)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
.autoDispose<
|
||||
NotificationsController,
|
||||
IList<(int, String)>,
|
||||
NotificationsRequest
|
||||
>(NotificationsController.new);
|
||||
}
|
||||
|
|
@ -2,26 +2,25 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/event.dart";
|
||||
import "package:nexus/controllers/pinned_ids.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
|
||||
class PinnedEventsController(final String roomId)
|
||||
extends AsyncNotifier<IList<Event>> {
|
||||
extends AsyncNotifier<IList<int>> {
|
||||
@override
|
||||
Future<IList<Event>> build() async {
|
||||
Future<IList<int>> build() async {
|
||||
final pinIds = ref.watch(PinnedIdsController.provider(roomId));
|
||||
|
||||
return (await Future.wait(
|
||||
pinIds.map(
|
||||
(eventId) => ref.watch(
|
||||
EventController.provider(.new(eventId: eventId, roomId: roomId))
|
||||
.future,
|
||||
.selectAsync((data) => data?.rowId),
|
||||
),
|
||||
),
|
||||
)).nonNulls.toIList();
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
.autoDispose<PinnedEventsController, IList<Event>, String>(
|
||||
.autoDispose<PinnedEventsController, IList<int>, String>(
|
||||
PinnedEventsController.new,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
22
lib/controllers/portal.dart
Normal file
22
lib/controllers/portal.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import "dart:io";
|
||||
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
|
||||
|
||||
class PortalController extends AsyncNotifier<XdgDesktopPortalClient> {
|
||||
@override
|
||||
Future<XdgDesktopPortalClient> build() async {
|
||||
final portal = XdgDesktopPortalClient();
|
||||
if (!await File("/.flatpak-info").exists()) {
|
||||
await portal.registerApplication("nexus.federated.nexus");
|
||||
}
|
||||
|
||||
ref.onDispose(portal.close);
|
||||
return portal;
|
||||
}
|
||||
|
||||
static final provider =
|
||||
AsyncNotifierProvider<PortalController, XdgDesktopPortalClient>(
|
||||
PortalController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/room_creators.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/room_power_levels.dart";
|
||||
import "package:nexus/models/configs/power_level.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
|
||||
class PowerLevelController(final PowerLevelConfig config)
|
||||
extends Notifier<bool> {
|
||||
|
|
@ -17,31 +15,17 @@ class PowerLevelController(final PowerLevelConfig config)
|
|||
);
|
||||
}
|
||||
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select((value) => value[config.roomId]),
|
||||
final powerLevels = ref.watch(
|
||||
RoomPowerLevelsController.provider(config.roomId),
|
||||
);
|
||||
|
||||
final roomCreators = room == null
|
||||
? null
|
||||
: ref.watch(RoomCreatorsController.provider(room));
|
||||
|
||||
final eventRowId = room?.state[EventType.powerLevels.type]?[""];
|
||||
|
||||
final event = eventRowId == null ? null : room?.events[eventRowId];
|
||||
final content = event?.content is PowerLevelsContent
|
||||
? event!.content
|
||||
: PowerLevelsContent();
|
||||
final content = powerLevels.content;
|
||||
|
||||
final user = ref.watch(
|
||||
ClientStateController.provider.select((value) => value?.userId),
|
||||
);
|
||||
if (user == null || content is! PowerLevelsContent) return false;
|
||||
if (user == null) return false;
|
||||
|
||||
double powerLevelOf(String userId) => roomCreators?.contains(userId) == true
|
||||
? double.infinity
|
||||
: (content.users[userId] ?? content.usersDefault).toDouble();
|
||||
|
||||
final userLevel = powerLevelOf(user);
|
||||
final userLevel = powerLevels.levelOf(user);
|
||||
|
||||
return switch (config) {
|
||||
EventPowerLevelConfig(:final eventType) =>
|
||||
|
|
@ -52,10 +36,12 @@ class PowerLevelController(final PowerLevelConfig config)
|
|||
.invite => userLevel >= content.invite,
|
||||
|
||||
.kick =>
|
||||
userLevel >= content.kick && userLevel > powerLevelOf(targetUser),
|
||||
userLevel >= content.kick &&
|
||||
userLevel > powerLevels.levelOf(targetUser),
|
||||
|
||||
.ban =>
|
||||
userLevel >= content.ban && userLevel > powerLevelOf(targetUser),
|
||||
userLevel >= content.ban &&
|
||||
userLevel > powerLevels.levelOf(targetUser),
|
||||
|
||||
.unban => userLevel >= content.ban,
|
||||
},
|
||||
|
|
|
|||
20
lib/controllers/power_levels_saving.dart
Normal file
20
lib/controllers/power_levels_saving.dart
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
class PowerLevelsSavingController(final String roomId) extends Notifier<bool> {
|
||||
@override
|
||||
bool build() => false;
|
||||
|
||||
Future<void> run(Future<void> Function() action) async {
|
||||
state = true;
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
if (ref.mounted) state = false;
|
||||
}
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider.autoDispose
|
||||
.family<PowerLevelsSavingController, bool, String>(
|
||||
PowerLevelsSavingController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -6,10 +6,19 @@ class ProfileController(final String userId)
|
|||
extends AsyncNotifier<ProfileResponse> {
|
||||
@override
|
||||
Future<ProfileResponse> build() {
|
||||
final client = ref.watch(ClientController.provider.notifier);
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
return client.getProfile(userId);
|
||||
}
|
||||
|
||||
Future<void> setField(String field, Object? value) async {
|
||||
await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.setProfileField(field, value);
|
||||
if (!ref.mounted) return;
|
||||
ref.invalidateSelf();
|
||||
await future;
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
.autoDispose<ProfileController, ProfileResponse, String>(
|
||||
ProfileController.new,
|
||||
|
|
|
|||
31
lib/controllers/push_key.dart
Normal file
31
lib/controllers/push_key.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import "dart:convert";
|
||||
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/key.dart";
|
||||
|
||||
class PushKeyController(final String instance) extends AsyncNotifier<String?> {
|
||||
@override
|
||||
Future<String?> build() async => json.decode(
|
||||
await ref.watch(KeyController.provider(KeyController.pushKeyKey).future) ??
|
||||
"{}",
|
||||
)[instance];
|
||||
|
||||
Future<void> set(String? value) async {
|
||||
final provider = KeyController.provider(KeyController.pushKeyKey);
|
||||
final notifier = ref.read(provider.notifier);
|
||||
final prefs = IMap(json.decode(await ref.read(provider.future) ?? "{}"));
|
||||
|
||||
state = .data(value);
|
||||
notifier.set(
|
||||
json.encode(
|
||||
prefs.add(instance, value).where((_, value) => value != null).unlock,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static final provider =
|
||||
AsyncNotifierProvider.family<PushKeyController, String?, String>(
|
||||
PushKeyController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ class ReactionsController(final ReactionsConfig config)
|
|||
|
||||
final reactionEvents = eventInfo?.$2.isNotEmpty == true
|
||||
? await ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.read(ClientController.provider.notifier)
|
||||
.getRelatedEvents(
|
||||
.new(
|
||||
roomId: config.roomId,
|
||||
|
|
|
|||
90
lib/controllers/read_receipts.dart
Normal file
90
lib/controllers/read_receipts.dart
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/room_chat.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/settings.dart";
|
||||
import "package:nexus/helpers/extensions/is_shown_as_message.dart";
|
||||
import "package:nexus/models/read_receipt.dart";
|
||||
|
||||
/// Other users' read receipts in a chat, by the event ID of the message to
|
||||
/// show them under: the nearest message at or before what they've read.
|
||||
class ReadReceiptsController(final ChatParams chat)
|
||||
extends Notifier<IMap<String, IList<ReadReceipt>>> {
|
||||
@override
|
||||
IMap<String, IList<ReadReceipt>> build() {
|
||||
final shouldShow = ref.watch(
|
||||
SettingsController.provider.select(
|
||||
(settings) => settings.value?.readReceipts != .neither,
|
||||
),
|
||||
);
|
||||
if (!shouldShow) return const .empty();
|
||||
|
||||
final userId = ref.watch(
|
||||
ClientStateController.provider.select((state) => state?.userId),
|
||||
);
|
||||
// The chat's own timeline, so receipts follow what's actually shown
|
||||
final timeline = ref.watch(
|
||||
RoomChatController.provider(chat)
|
||||
.select((roomChat) => roomChat.value?.timeline),
|
||||
);
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => switch (rooms[chat.roomId]) {
|
||||
final room? => (events: room.events, receipts: room.receipts),
|
||||
null => null,
|
||||
},
|
||||
),
|
||||
);
|
||||
if (userId == null || timeline == null || room == null) {
|
||||
return const .empty();
|
||||
}
|
||||
|
||||
final events = timeline.map((rowId) => room.events[rowId]).nonNulls;
|
||||
final indexes = IMap.fromEntries(
|
||||
events.indexed.map((entry) => MapEntry(entry.$2.eventId, entry.$1)),
|
||||
);
|
||||
final messages = events.indexed
|
||||
.where((entry) => entry.$2.isShownAsMessage)
|
||||
.toIList();
|
||||
final messageIndexes = messages.map((entry) => entry.$1).toList();
|
||||
|
||||
return room.receipts.values.flattened
|
||||
.where(
|
||||
(receipt) =>
|
||||
receipt.userId != userId &&
|
||||
// Receipts in threads are for the thread, not the timeline
|
||||
receipt.thread == "main",
|
||||
)
|
||||
.map(
|
||||
(receipt) => switch (indexes[receipt.eventId]) {
|
||||
final index? => (
|
||||
receipt: receipt,
|
||||
message: lowerBound(messageIndexes, index + 1) - 1,
|
||||
),
|
||||
null => null,
|
||||
},
|
||||
)
|
||||
.nonNulls
|
||||
.where((entry) => entry.message >= 0)
|
||||
.groupListsBy((entry) => messages[entry.message].$2.eventId)
|
||||
.map(
|
||||
(eventId, entries) => MapEntry(
|
||||
eventId,
|
||||
entries
|
||||
.map((entry) => entry.receipt)
|
||||
.sorted((a, b) => b.timestamp.compareTo(a.timestamp))
|
||||
.toIList(),
|
||||
),
|
||||
)
|
||||
.toIMap();
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider.family
|
||||
.autoDispose<
|
||||
ReadReceiptsController,
|
||||
IMap<String, IList<ReadReceipt>>,
|
||||
ChatParams
|
||||
>(ReadReceiptsController.new);
|
||||
}
|
||||
63
lib/controllers/recent_emoji.dart
Normal file
63
lib/controllers/recent_emoji.dart
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:material_emoji_picker/material_emoji_picker.dart";
|
||||
import "package:nexus/controllers/account_data.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/models/account_data.dart";
|
||||
|
||||
class RecentEmojiController extends Notifier<IList<RecentEmoji>> {
|
||||
@override
|
||||
IList<RecentEmoji> build() {
|
||||
final Set<String> emoji = ref
|
||||
.watch(EmojiController.provider)
|
||||
.requireValue
|
||||
.map((element) => element.emojis)
|
||||
.flattened
|
||||
.map((e) => e.value)
|
||||
.toSet();
|
||||
|
||||
final IList<RecentEmoji> recents = ref.watch(
|
||||
AccountDataController.provider.select(
|
||||
(value) => value.recentEmoji.recentEmoji,
|
||||
),
|
||||
);
|
||||
|
||||
return .new(
|
||||
recents.where(
|
||||
(recent) =>
|
||||
// Is unicode or custom emote
|
||||
emoji.contains(recent.emoji) || recent.emoji.startsWith("mxc://"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> add(String emoji) => ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.setAccountData(
|
||||
.new(
|
||||
type: AccountData.recentEmojiKey,
|
||||
content: RecentEmojiData(
|
||||
recentEmoji: .new([
|
||||
.new(
|
||||
emoji: emoji,
|
||||
total:
|
||||
(state
|
||||
.firstWhereOrNull(
|
||||
(element) => element.emoji == emoji,
|
||||
)
|
||||
?.total ??
|
||||
0) +
|
||||
1,
|
||||
),
|
||||
...state.whereNot((element) => element.emoji == emoji),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
static final provider =
|
||||
NotifierProvider.autoDispose<RecentEmojiController, IList<RecentEmoji>>(
|
||||
RecentEmojiController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -9,101 +9,156 @@ import "package:nexus/controllers/attachment.dart";
|
|||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/image_pack.dart";
|
||||
import "package:nexus/models/content/reaction.dart";
|
||||
import "package:nexus/models/content/sticker.dart";
|
||||
import "package:nexus/models/direction.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/requests/redact_event.dart";
|
||||
import "package:nexus/models/relation_type.dart";
|
||||
import "package:nexus/models/requests/send_message.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
import "package:nexus/models/room_chat.dart";
|
||||
|
||||
class RoomChatController(final String roomId)
|
||||
extends AsyncNotifier<IList<Event>?> {
|
||||
typedef ChatParams = ({String roomId, int? contextualEvent});
|
||||
|
||||
class RoomChatController(final ChatParams info)
|
||||
extends AsyncNotifier<RoomChat?> {
|
||||
@override
|
||||
Future<IList<Event>?> build() async {
|
||||
final client = ref.watch(ClientController.provider.notifier);
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select((rooms) => rooms[roomId]),
|
||||
Future<RoomChat?> build() async {
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
|
||||
final room = ref.read(
|
||||
RoomsController.provider.select((rooms) => rooms[info.roomId]),
|
||||
);
|
||||
|
||||
if (room == null) return null;
|
||||
|
||||
if (!room.hasFetchedState) {
|
||||
final state = await client.getRoomState(.new(roomId: roomId));
|
||||
|
||||
await ref.read(RoomsController.provider.notifier).addState(roomId, state);
|
||||
final state = await client.getRoomState(.new(roomId: info.roomId));
|
||||
ref.read(RoomsController.provider.notifier).addState(info.roomId, state);
|
||||
}
|
||||
|
||||
// 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
|
||||
final timeline = room.timeline
|
||||
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
|
||||
.map((element) => element.value)
|
||||
.map((e) => e.value)
|
||||
.toIList()
|
||||
.addAll(room.sticky)
|
||||
.map((entry) {
|
||||
final foundEvent = entry == null ? null : room.events[entry];
|
||||
|
||||
final editedEvent =
|
||||
foundEvent == null || foundEvent.lastEditRowId == 0
|
||||
? null
|
||||
: room.events[foundEvent.lastEditRowId];
|
||||
|
||||
return editedEvent == null
|
||||
? foundEvent
|
||||
: foundEvent?.copyWith(
|
||||
content: editedEvent.content,
|
||||
localContent: editedEvent.localContent,
|
||||
);
|
||||
})
|
||||
.addAll(room.clientSticky)
|
||||
.nonNulls
|
||||
.toIList();
|
||||
if (info.contextualEvent == null ||
|
||||
timeline.contains(info.contextualEvent)) {
|
||||
ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) =>
|
||||
(rooms[info.roomId]?.timeline, rooms[info.roomId]?.clientSticky),
|
||||
),
|
||||
);
|
||||
|
||||
return .new(
|
||||
timeline: timeline,
|
||||
hasMoreBackward: room.hasMore,
|
||||
hasMoreForward: false,
|
||||
);
|
||||
} else {
|
||||
final context = await client.getEventContext(
|
||||
.new(
|
||||
roomId: info.roomId,
|
||||
eventId: room.events[info.contextualEvent!]!.eventId,
|
||||
),
|
||||
);
|
||||
|
||||
// `before` is in reverse chronological order
|
||||
final events = context.before.reversed
|
||||
.toIList()
|
||||
.add(context.event)
|
||||
.addAll(context.after);
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.storeEvents(events.addAll(context.relatedEvents));
|
||||
return .new(
|
||||
timeline: .new(events.map((element) => element.rowId)),
|
||||
hasMoreBackward: true,
|
||||
hasMoreForward: true,
|
||||
historicalData: .new(start: context.start, end: context.end),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteMessage(Event event, {String? reason}) => ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.read(ClientController.provider.notifier)
|
||||
.redactEvent(
|
||||
RedactEventRequest(
|
||||
eventId: event.eventId,
|
||||
roomId: roomId,
|
||||
roomId: info.roomId,
|
||||
reason: reason,
|
||||
),
|
||||
);
|
||||
|
||||
Future<bool> loadOlder() async {
|
||||
state = AsyncLoading();
|
||||
final timelineKeys = ref
|
||||
.read(RoomsController.provider.select((value) => value[roomId]))
|
||||
?.timeline
|
||||
.keys;
|
||||
final response = await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.paginate(
|
||||
.new(
|
||||
roomId: roomId,
|
||||
maxTimelineId: timelineKeys?.isNotEmpty == true
|
||||
? timelineKeys?.reduce(min)
|
||||
: null,
|
||||
void hideClientSticky(int eventRowId) => ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.update(
|
||||
.new({
|
||||
info.roomId: Room(
|
||||
resetClientSticky: true,
|
||||
clientSticky:
|
||||
ref
|
||||
.read(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => rooms[info.roomId]?.clientSticky,
|
||||
),
|
||||
)
|
||||
?.remove(eventRowId) ??
|
||||
.new(),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
Future<void> paginate(Direction direction) async {
|
||||
if (state.isLoading) return;
|
||||
|
||||
final chat = await future;
|
||||
|
||||
if (direction == .forward
|
||||
? chat?.hasMoreForward == false
|
||||
: chat?.hasMoreBackward == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = .loading();
|
||||
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
|
||||
if (chat?.historicalData == null) {
|
||||
final timelineKeys = ref
|
||||
.read(RoomsController.provider.select((value) => value[info.roomId]))
|
||||
?.timeline
|
||||
.keys;
|
||||
final response = await client.paginate(
|
||||
.new(
|
||||
roomId: info.roomId,
|
||||
maxTimelineId: timelineKeys?.isNotEmpty == true
|
||||
? timelineKeys?.reduce(min)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
if (response.events.isEmpty) {
|
||||
state = .data(chat);
|
||||
}
|
||||
|
||||
if (response.events.isEmpty) {
|
||||
state = .data(state.value);
|
||||
} else {
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.update(
|
||||
IMap({
|
||||
roomId: Room(
|
||||
info.roomId: Room(
|
||||
events: IMap.fromIterable(
|
||||
response.events.addAll(response.relatedEvents),
|
||||
keyMapper: (event) => event.rowId,
|
||||
valueMapper: (event) => event,
|
||||
),
|
||||
hasMore: response.hasMore,
|
||||
receipts: response.receipts,
|
||||
timeline: IMap.fromIterable(
|
||||
response.events,
|
||||
keyMapper: (event) => event.timelineRowId,
|
||||
|
|
@ -111,11 +166,57 @@ class RoomChatController(final String roomId)
|
|||
),
|
||||
),
|
||||
}),
|
||||
.new(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
final paginationResponse = await client.paginateManual(
|
||||
.new(
|
||||
roomId: info.roomId,
|
||||
direction: direction,
|
||||
since: direction == .forward
|
||||
? chat!.historicalData!.end
|
||||
: chat!.historicalData!.start,
|
||||
),
|
||||
);
|
||||
|
||||
return response.hasMore;
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.storeEvents(
|
||||
paginationResponse.events.addAll(paginationResponse.relatedEvents),
|
||||
);
|
||||
|
||||
final eventRowIds =
|
||||
(direction == .forward
|
||||
? paginationResponse.events
|
||||
: paginationResponse.events.reversed)
|
||||
.map((element) => element.rowId)
|
||||
.toIList();
|
||||
|
||||
state = .data(
|
||||
.new(
|
||||
timeline: direction == .forward
|
||||
? chat.timeline.addAll(eventRowIds)
|
||||
: eventRowIds.addAll(chat.timeline),
|
||||
hasMoreForward:
|
||||
direction == .forward && paginationResponse.nextBatch == null
|
||||
? false
|
||||
: chat.hasMoreForward,
|
||||
hasMoreBackward:
|
||||
direction == .backward && paginationResponse.nextBatch == null
|
||||
? false
|
||||
: chat.hasMoreBackward,
|
||||
historicalData: chat.historicalData?.copyWith(
|
||||
start:
|
||||
(direction == .backward
|
||||
? paginationResponse.nextBatch
|
||||
: null) ??
|
||||
chat.historicalData!.start,
|
||||
end:
|
||||
(direction == .forward ? paginationResponse.nextBatch : null) ??
|
||||
chat.historicalData!.end,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> send(
|
||||
|
|
@ -123,60 +224,62 @@ class RoomChatController(final String roomId)
|
|||
bool shouldMention = true,
|
||||
required IList<Tag> tags,
|
||||
required RelationType relationType,
|
||||
Event? relation,
|
||||
int? relatedEventRowId,
|
||||
}) async {
|
||||
Content? baseContent;
|
||||
if (relationType == .edit) {
|
||||
baseContent = relation?.content;
|
||||
final relatedEvent = relatedEventRowId == null
|
||||
? null
|
||||
: ref.read(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => rooms[info.roomId]?.events[relatedEventRowId],
|
||||
),
|
||||
);
|
||||
|
||||
if (relationType == .edit && relatedEventRowId != null) {
|
||||
baseContent = relatedEvent?.content;
|
||||
} else {
|
||||
final provider = AttachmentController.provider(roomId);
|
||||
baseContent = ref.watch(provider)?.$2;
|
||||
final provider = AttachmentController.provider(info.roomId);
|
||||
baseContent = ref.read(provider)?.$2;
|
||||
ref.invalidate(provider);
|
||||
}
|
||||
|
||||
var taggedMessage = text;
|
||||
|
||||
for (final tag in tags) {
|
||||
final escaped = RegExp.escape(tag.id);
|
||||
final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?");
|
||||
|
||||
taggedMessage = taggedMessage.replaceAllMapped(
|
||||
pattern,
|
||||
// Replaces each formatted tag, like "@id#name#", with its ID
|
||||
final taggedMessage = tags.fold(
|
||||
text,
|
||||
(message, tag) => message.replaceAllMapped(
|
||||
RegExp(r"@+(" + RegExp.escape(tag.id) + r")(#[^#]*#)?"),
|
||||
(match) => match.group(1)!,
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
|
||||
final client = ref.watch(ClientController.provider.notifier);
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
final event = await client.sendMessage(
|
||||
SendMessageRequest(
|
||||
roomId: roomId,
|
||||
roomId: info.roomId,
|
||||
baseContent: baseContent,
|
||||
mentions: Mentions(
|
||||
userIds: [
|
||||
if (shouldMention == true &&
|
||||
relation != null &&
|
||||
relationType == RelationType.reply)
|
||||
relation.sender,
|
||||
if (shouldMention && relatedEvent != null && relationType == .reply)
|
||||
relatedEvent.sender,
|
||||
].toIList(),
|
||||
room: taggedMessage.contains("@room"),
|
||||
),
|
||||
text: taggedMessage,
|
||||
relation: relation == null
|
||||
relation: relatedEvent == null
|
||||
? null
|
||||
: .new(eventId: relation.eventId, relationType: relationType),
|
||||
: .new(eventId: relatedEvent.eventId, relationType: relationType),
|
||||
),
|
||||
);
|
||||
|
||||
ref
|
||||
.watch(RoomsController.provider.notifier)
|
||||
.read(RoomsController.provider.notifier)
|
||||
.update(
|
||||
.new({
|
||||
roomId: .new(
|
||||
info.roomId: .new(
|
||||
events: .new({event.rowId: event}),
|
||||
sticky: .new({event.rowId}),
|
||||
clientSticky: .new({event.rowId}),
|
||||
),
|
||||
}),
|
||||
.new(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -185,42 +288,50 @@ class RoomChatController(final String roomId)
|
|||
Event event,
|
||||
String userId,
|
||||
) async {
|
||||
final client = ref.watch(ClientController.provider.notifier);
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
final allReactionEvents = await client.getRelatedEvents(
|
||||
.new(
|
||||
roomId: roomId,
|
||||
roomId: info.roomId,
|
||||
eventId: event.eventId,
|
||||
relationType: "m.annotation",
|
||||
),
|
||||
);
|
||||
|
||||
final reactionEvents = allReactionEvents
|
||||
?.where((event) => event.redactedBy == null)
|
||||
.toIList();
|
||||
|
||||
final reactionEvent = reactionEvents?.firstWhereOrNull(
|
||||
(event) => switch (event.content) {
|
||||
ReactionContent(:final key) =>
|
||||
key == reaction && event.sender == userId,
|
||||
_ => false,
|
||||
},
|
||||
final reactionEvent = allReactionEvents?.firstWhereOrNull(
|
||||
(event) =>
|
||||
event.redactedBy == null &&
|
||||
switch (event.content) {
|
||||
ReactionContent(:final key) =>
|
||||
key == reaction && event.sender == userId,
|
||||
_ => false,
|
||||
},
|
||||
);
|
||||
|
||||
if (reactionEvent != null) {
|
||||
await ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId));
|
||||
await client.redactEvent(
|
||||
.new(eventId: reactionEvent.eventId, roomId: info.roomId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendReaction(String reaction, Event event) async {
|
||||
final client = ref.watch(ClientController.provider.notifier);
|
||||
/// Reacts to [event] with [reaction], which is an mxc URL for custom emoji,
|
||||
/// with their [shortcode].
|
||||
Future<void> sendReaction(
|
||||
String reaction,
|
||||
Event event, {
|
||||
String? shortcode,
|
||||
}) async {
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
|
||||
await client.sendEvent(
|
||||
.new(
|
||||
roomId: roomId,
|
||||
roomId: info.roomId,
|
||||
type: EventType.reaction.type,
|
||||
content: ReactionContent(key: reaction),
|
||||
// Wrapped in colons, like other clients send it
|
||||
content: ReactionContent(
|
||||
key: reaction,
|
||||
shortcode: shortcode == null ? null : ":$shortcode:",
|
||||
),
|
||||
synchronous: true,
|
||||
disableEncryption: true,
|
||||
relatesTo: event.eventId,
|
||||
|
|
@ -229,8 +340,35 @@ class RoomChatController(final String roomId)
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> sendSticker(String shortcode, PackImage image) async {
|
||||
final event = await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.sendEvent(
|
||||
.new(
|
||||
roomId: info.roomId,
|
||||
type: EventType.sticker.type,
|
||||
content: StickerContent(
|
||||
body: image.body ?? shortcode,
|
||||
info: image.info ?? const .new(),
|
||||
url: image.url,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.update(
|
||||
.new({
|
||||
info.roomId: .new(
|
||||
events: .new({event.rowId: event}),
|
||||
clientSticky: .new({event.rowId}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
.autoDispose<RoomChatController, IList<Event>?, String>(
|
||||
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
|
||||
RoomChatController.new,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/create.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
|
||||
class RoomCreatorsController(final Room room) extends Notifier<IList<String>> {
|
||||
@override
|
||||
IList<String> build() {
|
||||
final createRowId = room.state[EventType.create.type]?[""];
|
||||
final createEvent = createRowId == null ? null : room.events[createRowId];
|
||||
|
||||
if (createEvent == null) return .new();
|
||||
|
||||
final createEventContent = switch (createEvent.content) {
|
||||
CreateContent content => content,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return switch (createEventContent?.additionalCreatorIds) {
|
||||
IList<String> creators => creators.add(createEvent.sender),
|
||||
_ => .new([createEvent.sender]),
|
||||
};
|
||||
}
|
||||
|
||||
static final provider =
|
||||
NotifierProvider.family<RoomCreatorsController, IList<String>, Room>(
|
||||
RoomCreatorsController.new,
|
||||
);
|
||||
}
|
||||
59
lib/controllers/room_power_levels.dart
Normal file
59
lib/controllers/room_power_levels.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/create.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/room_power_levels.dart";
|
||||
|
||||
class RoomPowerLevelsController(final String roomId)
|
||||
extends Notifier<RoomPowerLevels> {
|
||||
@override
|
||||
RoomPowerLevels build() {
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select((rooms) => rooms[roomId]),
|
||||
);
|
||||
|
||||
Event? stateEvent(EventType type) => switch (room?.state[type.type]?[""]) {
|
||||
final rowId? => room?.events[rowId],
|
||||
null => null,
|
||||
};
|
||||
|
||||
final createEvent = stateEvent(.create);
|
||||
final create = switch (createEvent?.content) {
|
||||
final CreateContent content => content,
|
||||
_ => null,
|
||||
};
|
||||
// Creators only outrank everyone from room version 12, earlier ones just have a power level
|
||||
final creatorsPrivileged =
|
||||
(int.tryParse(create?.roomVersion ?? "") ?? 0) >= 12;
|
||||
|
||||
return .new(
|
||||
content: switch (stateEvent(.powerLevels)?.content) {
|
||||
final PowerLevelsContent content => content,
|
||||
// Until there are power levels, creators have 100
|
||||
_ => PowerLevelsContent(
|
||||
users: switch (createEvent) {
|
||||
final createEvent? when !creatorsPrivileged => .new({
|
||||
createEvent.sender: 100,
|
||||
}),
|
||||
_ => const .empty(),
|
||||
},
|
||||
),
|
||||
},
|
||||
creators: switch (createEvent) {
|
||||
final createEvent? when creatorsPrivileged =>
|
||||
(create?.additionalCreatorIds ?? const .empty())
|
||||
.add(createEvent.sender)
|
||||
.toISet(),
|
||||
_ => const .empty(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider.autoDispose
|
||||
.family<RoomPowerLevelsController, RoomPowerLevels, String>(
|
||||
RoomPowerLevelsController.new,
|
||||
);
|
||||
}
|
||||
830
lib/controllers/room_settings_sections.dart
Normal file
830
lib/controllers/room_settings_sections.dart
Normal file
|
|
@ -0,0 +1,830 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/local_aliases.dart";
|
||||
import "package:nexus/controllers/power_level.dart";
|
||||
import "package:nexus/controllers/power_levels_saving.dart";
|
||||
import "package:nexus/controllers/room_power_levels.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/space_edges.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
import "package:nexus/helpers/extensions/join_rule_name.dart";
|
||||
import "package:nexus/helpers/file_dialog_helper.dart";
|
||||
import "package:nexus/helpers/hooks/saving.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/models/content/avatar.dart";
|
||||
import "package:nexus/models/content/canonical_alias.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/create.dart";
|
||||
import "package:nexus/models/content/encryption.dart";
|
||||
import "package:nexus/models/content/history_visibility.dart";
|
||||
import "package:nexus/models/content/join_rules.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/content/name.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
import "package:nexus/models/content/topic.dart";
|
||||
import "package:nexus/models/join_rule.dart";
|
||||
import "package:nexus/models/power_level_permission.dart";
|
||||
import "package:nexus/models/setting.dart";
|
||||
import "package:nexus/models/settings_category.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/divider_text.dart";
|
||||
import "package:nexus/widgets/settings/add_users_power_level_sheet.dart";
|
||||
import "package:nexus/widgets/settings/aliases_sheet.dart";
|
||||
import "package:nexus/widgets/settings/allowed_rooms_sheet.dart";
|
||||
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
||||
import "package:nexus/widgets/settings/local_aliases_sheet.dart";
|
||||
import "package:nexus/widgets/settings/power_level_entry_sheet.dart";
|
||||
import "package:nexus/widgets/settings/power_level_tile.dart";
|
||||
import "package:nexus/widgets/settings/text_setting_tile.dart";
|
||||
|
||||
class RoomSettingsSectionsController(final String roomId)
|
||||
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {
|
||||
@override
|
||||
Future<IMap<String, IList<SettingsCategory>>> build() async {
|
||||
final (:state, :metadataTopic, :metadataAvatar) = ref.watch(
|
||||
RoomsController.provider.select((rooms) {
|
||||
final room = rooms[roomId];
|
||||
return (
|
||||
state: room?.state ?? const IMap.empty(),
|
||||
metadataTopic: room?.metadata?.topic,
|
||||
metadataAvatar: room?.metadata?.avatar,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
Content? contentOf(int rowId) =>
|
||||
ref.read(RoomsController.provider)[roomId]?.events[rowId]?.content;
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
final roomsController = ref.read(RoomsController.provider.notifier);
|
||||
|
||||
// The room's own state, rather than metadata like a name made up from
|
||||
// its members when it has none
|
||||
Content? stateContent(EventType type) => switch (state[type.type]?[""]) {
|
||||
final rowId? => contentOf(rowId),
|
||||
null => null,
|
||||
};
|
||||
final name = switch (stateContent(.name)) {
|
||||
NameContent(:final name) => name,
|
||||
_ => null,
|
||||
};
|
||||
final topic = switch (stateContent(.topic)) {
|
||||
TopicContent(:final topic) => topic,
|
||||
_ => metadataTopic,
|
||||
};
|
||||
final avatar = switch (stateContent(.avatar)) {
|
||||
AvatarContent(:final url) => url,
|
||||
_ => metadataAvatar,
|
||||
};
|
||||
|
||||
bool canSet(EventType type) => ref.watch(
|
||||
PowerLevelController.provider(.state(eventType: type, roomId: roomId)),
|
||||
);
|
||||
|
||||
final canSetAvatar = canSet(.avatar);
|
||||
final canSetName = canSet(.name);
|
||||
final canSetTopic = canSet(.topic);
|
||||
final canSetJoinRules = canSet(.joinRules);
|
||||
final canSetHistoryVisibility = canSet(.historyVisibility);
|
||||
final canSetAliases = canSet(.canonicalAlias);
|
||||
final canSetPowerLevels =
|
||||
canSet(.powerLevels) &&
|
||||
!ref.watch(PowerLevelsSavingController.provider(roomId));
|
||||
|
||||
final joinRules = switch (stateContent(.joinRules)) {
|
||||
final JoinRulesContent content => content,
|
||||
_ => JoinRulesContent(joinRule: .invite),
|
||||
};
|
||||
final historyVisibility = switch (stateContent(.historyVisibility)) {
|
||||
HistoryVisibilityContent(:final historyVisibility) => historyVisibility,
|
||||
_ => HistoryVisibility.shared,
|
||||
};
|
||||
final canonicalAlias = switch (stateContent(.canonicalAlias)) {
|
||||
final CanonicalAliasContent content => content,
|
||||
_ => CanonicalAliasContent(),
|
||||
};
|
||||
final published = canonicalAlias.altAliases.addAll([?canonicalAlias.alias]);
|
||||
final localAliases =
|
||||
ref.watch(LocalAliasesController.provider(roomId)).value ??
|
||||
const IList<String>.empty();
|
||||
|
||||
final knownAliases = localAliases.addAll(canonicalAlias.altAliases).addAll([
|
||||
?canonicalAlias.alias,
|
||||
]).toISet();
|
||||
|
||||
final roomVersion = switch (stateContent(.create)) {
|
||||
CreateContent(:final roomVersion) => int.tryParse(roomVersion),
|
||||
_ => null,
|
||||
};
|
||||
bool versionAtLeast(int version) =>
|
||||
roomVersion == null || roomVersion >= version;
|
||||
final joinRuleOptions = [
|
||||
JoinRule.public,
|
||||
JoinRule.invite,
|
||||
if (versionAtLeast(7)) JoinRule.knock,
|
||||
if (versionAtLeast(8)) JoinRule.restricted,
|
||||
if (versionAtLeast(10)) JoinRule.knockRestricted,
|
||||
];
|
||||
bool isRestricted(JoinRule rule) =>
|
||||
rule == .restricted || rule == .knockRestricted;
|
||||
|
||||
final parentSpaces = ref
|
||||
.watch(
|
||||
SpaceEdgesController.provider.select(
|
||||
(edges) => edges.entries
|
||||
.where(
|
||||
(entry) => entry.value.any((edge) => edge.childId == roomId),
|
||||
)
|
||||
.map((entry) => entry.key)
|
||||
.toIList(),
|
||||
),
|
||||
)
|
||||
.addAll(state["m.space.parent"]?.keys ?? const [])
|
||||
.toISet();
|
||||
final allowedRooms = joinRules.allow
|
||||
.map((condition) => condition.roomId)
|
||||
.nonNulls
|
||||
.toISet();
|
||||
|
||||
final roomPowerLevels = ref.watch(
|
||||
RoomPowerLevelsController.provider(roomId),
|
||||
);
|
||||
final powerLevels = roomPowerLevels.content;
|
||||
final userId = ref.watch(
|
||||
ClientStateController.provider.select((state) => state?.userId),
|
||||
);
|
||||
final ownLevel = userId == null ? 0.0 : roomPowerLevels.levelOf(userId);
|
||||
final encrypted = stateContent(.encryption) is EncryptionContent;
|
||||
final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted);
|
||||
|
||||
Future<void> setState(EventType type, Content content) async {
|
||||
final eventId = await client.setState(
|
||||
.new(roomId: roomId, type: type.type, stateKey: "", content: content),
|
||||
);
|
||||
if (eventId case final eventId?) {
|
||||
await roomsController.awaitState(roomId, type.type, eventId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setAliases(
|
||||
String? canonical,
|
||||
ISet<String> alternatives,
|
||||
) async {
|
||||
final stale = (await Future.wait(
|
||||
alternatives.addAll([?canonical]).map((alias) async {
|
||||
try {
|
||||
return await client.resolveAlias(alias) == roomId ? null : alias;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
)).nonNulls.toISet();
|
||||
bool kept(String alias) => !stale.contains(alias);
|
||||
|
||||
await setState(
|
||||
.canonicalAlias,
|
||||
CanonicalAliasContent(
|
||||
alias: canonical != null && kept(canonical) ? canonical : null,
|
||||
altAliases: alternatives.where(kept).toISet(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setAllowedRooms(ISet<String> roomIds) => setState(
|
||||
.joinRules,
|
||||
joinRules.copyWith(
|
||||
allow: roomIds
|
||||
.map((roomId) => AllowCondition(roomId: roomId, type: .membership))
|
||||
.toISet(),
|
||||
),
|
||||
);
|
||||
|
||||
Setting header(String title, String description, IconData icon) => .new(
|
||||
title: title,
|
||||
description: description,
|
||||
icon: icon,
|
||||
builder: (title, _, _) => Padding(
|
||||
padding: .symmetric(horizontal: 16),
|
||||
child: DividerText(title),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> setPowerLevels(PowerLevelsContent content) => ref
|
||||
.read(PowerLevelsSavingController.provider(roomId).notifier)
|
||||
.run(() => setState(.powerLevels, content).onError(showError));
|
||||
|
||||
// Event types with their own level that don't have a setting of their own
|
||||
final listedEvents = shownEvents
|
||||
.addAll(PowerLevelGroup.values.expand((group) => group.events))
|
||||
.map((event) => event.type.type)
|
||||
.toISet();
|
||||
final otherEvents = powerLevels.events.entries
|
||||
.where((entry) => !listedEvents.contains(entry.key))
|
||||
.toIList()
|
||||
.sort((a, b) => a.key.compareTo(b.key));
|
||||
|
||||
// Users with their own level, the highest first
|
||||
final users = powerLevels.users.entries.toIList().sort(
|
||||
(a, b) => switch (b.value.compareTo(a.value)) {
|
||||
0 => a.key.compareTo(b.key),
|
||||
final order => order,
|
||||
},
|
||||
);
|
||||
MembershipContent? memberOf(String userId) =>
|
||||
switch (state[EventType.membership.type]?[userId]) {
|
||||
final rowId? => switch (contentOf(rowId)) {
|
||||
final MembershipContent content => content,
|
||||
_ => null,
|
||||
},
|
||||
null => null,
|
||||
};
|
||||
|
||||
/// A tile opening a [sheet] to add entries, like event types or users, with their own power level.
|
||||
Setting addSetting({
|
||||
required String title,
|
||||
required String description,
|
||||
required Widget Function(void Function(PowerLevelsContent content) save)
|
||||
sheet,
|
||||
}) => .new(
|
||||
title: title,
|
||||
description: description,
|
||||
icon: Icons.add,
|
||||
builder: (title, description, icon) => Builder(
|
||||
builder: (context) => ListTile(
|
||||
enabled: canSetPowerLevels,
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(description),
|
||||
onTap: () => context.showAdaptiveSheet((_) => sheet(setPowerLevels)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Setting powerLevelSetting({
|
||||
required String title,
|
||||
required String description,
|
||||
required IconData icon,
|
||||
required int value,
|
||||
required PowerLevelsContent Function(int level) update,
|
||||
}) => .new(
|
||||
title: title,
|
||||
description: description,
|
||||
icon: icon,
|
||||
builder: (title, description, icon) => PowerLevelTile(
|
||||
leading: Icon(icon),
|
||||
title: title,
|
||||
description: description,
|
||||
value: value,
|
||||
ownLevel: ownLevel,
|
||||
onChanged: canSetPowerLevels
|
||||
? (level) => setPowerLevels(update(level))
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
Setting permissionSetting(PowerLevelPermission permission) =>
|
||||
powerLevelSetting(
|
||||
title: permission.title,
|
||||
description: permission.description,
|
||||
icon: permission.icon,
|
||||
value: permission.levelIn(powerLevels),
|
||||
update: (level) => permission.withLevel(powerLevels, level),
|
||||
);
|
||||
|
||||
/// A setting for the level needed to send [event], which otherwise needs [fallback].
|
||||
Setting eventSetting(PowerLevelEvent event, {required int fallback}) =>
|
||||
powerLevelSetting(
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
icon: event.icon,
|
||||
value: powerLevels.events[event.type.type] ?? fallback,
|
||||
update: (level) => powerLevels.copyWith(
|
||||
events: powerLevels.events.add(event.type.type, level),
|
||||
),
|
||||
);
|
||||
|
||||
Widget tileOf(Setting setting) =>
|
||||
setting.builder(setting.title, setting.description, setting.icon);
|
||||
|
||||
/// A setting for all of [group]'s levels at once, which expands to set each.
|
||||
Setting groupSetting(PowerLevelGroup group) => .new(
|
||||
title: group.title,
|
||||
description: group.description,
|
||||
icon: group.icon,
|
||||
builder: (title, description, icon) => HookBuilder(
|
||||
builder: (context) {
|
||||
final expanded = useState(false);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
PowerLevelTile(
|
||||
leading: Icon(icon),
|
||||
title: title,
|
||||
description: description,
|
||||
value: group.levelIn(powerLevels),
|
||||
ownLevel: ownLevel,
|
||||
// Changing it would change levels above the user's own
|
||||
onChanged:
|
||||
canSetPowerLevels &&
|
||||
group.highestIn(powerLevels) <= ownLevel
|
||||
? (level) =>
|
||||
setPowerLevels(group.withLevel(powerLevels, level))
|
||||
: null,
|
||||
trailing: IconButton(
|
||||
tooltip: expanded.value ? "Collapse" : "Set each",
|
||||
icon: AnimatedRotation(
|
||||
turns: expanded.value ? 0.5 : 0,
|
||||
duration: Durations.medium2,
|
||||
child: Icon(Icons.expand_more),
|
||||
),
|
||||
onPressed: () => expanded.value = !expanded.value,
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: Durations.medium2,
|
||||
curve: Curves.easeInOut,
|
||||
alignment: .topCenter,
|
||||
child: expanded.value
|
||||
? Padding(
|
||||
padding: .only(left: 16),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
...group.permissions
|
||||
.map(permissionSetting)
|
||||
.map(tileOf),
|
||||
...group.events
|
||||
.map(
|
||||
(event) => eventSetting(
|
||||
event,
|
||||
fallback: powerLevels.stateDefault,
|
||||
),
|
||||
)
|
||||
.map(tileOf),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox(width: .infinity),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return .new({
|
||||
"Room": .new([
|
||||
.new(
|
||||
title: "General",
|
||||
icon: Icons.tune,
|
||||
settings: .new([
|
||||
.new(
|
||||
title: "Avatar",
|
||||
description: "The image shown for the room.",
|
||||
icon: Icons.image,
|
||||
builder: (title, description, icon) => HookBuilder(
|
||||
builder: (context) {
|
||||
final saving = Saving.use();
|
||||
Future<void> save(Future<void> Function() action) =>
|
||||
saving.run(() => action().onError(showError));
|
||||
|
||||
return ListTile(
|
||||
enabled: canSetAvatar && !saving.active,
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(description),
|
||||
trailing: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
AvatarOrHash(
|
||||
avatar,
|
||||
name ?? "Unnamed Room",
|
||||
dimension: 40,
|
||||
fallback: Icon(Icons.numbers),
|
||||
),
|
||||
if (avatar != null && canSetAvatar)
|
||||
IconButton(
|
||||
tooltip: "Remove avatar",
|
||||
icon: Icon(Icons.delete),
|
||||
onPressed: saving.active
|
||||
? null
|
||||
: () => save(
|
||||
() => setState(.avatar, const .new()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
final file = await ProviderScope.containerOf(context)
|
||||
.read(FileDialogHelper.provider)
|
||||
.pickFile(images: true);
|
||||
if (file == null || !context.mounted) return;
|
||||
|
||||
await save(() async {
|
||||
final content = await client.uploadMedia(
|
||||
.new(path: file.path, encrypt: false),
|
||||
);
|
||||
if (content case ImageMessageContent(
|
||||
:final url,
|
||||
:final info,
|
||||
)) {
|
||||
await setState(
|
||||
.avatar,
|
||||
AvatarContent(url: url, info: info),
|
||||
);
|
||||
} else {
|
||||
throw Exception("The file isn't an image");
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Room Name",
|
||||
description: "The name shown for the room.",
|
||||
icon: Icons.title,
|
||||
builder: (title, description, icon) => TextSettingTile(
|
||||
icon: Icon(icon),
|
||||
title: title,
|
||||
description: description,
|
||||
value: name,
|
||||
onChanged: canSetName
|
||||
? (value) => setState(
|
||||
.name,
|
||||
NameContent(name: value),
|
||||
).onError(showError)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Topic",
|
||||
description: "A short description of the room.",
|
||||
icon: Icons.info,
|
||||
builder: (title, description, icon) => TextSettingTile(
|
||||
icon: Icon(icon),
|
||||
title: title,
|
||||
description: description,
|
||||
value: topic,
|
||||
multiline: true,
|
||||
onChanged: canSetTopic
|
||||
? (value) => setState(
|
||||
.topic,
|
||||
TopicContent(
|
||||
topic: value,
|
||||
content: .new(
|
||||
representations: .new([.new(body: value)]),
|
||||
),
|
||||
),
|
||||
).onError(showError)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
.new(
|
||||
title: "Access",
|
||||
icon: Icons.key,
|
||||
settings: .new([
|
||||
.new(
|
||||
title: "Join Rule",
|
||||
description: "Who can join the room.",
|
||||
icon: Icons.login,
|
||||
builder: (title, description, icon) => DialogListTile<JoinRule>(
|
||||
icon: Icon(icon),
|
||||
title: title,
|
||||
subtitle: Text(description),
|
||||
value: joinRules.joinRule,
|
||||
options: joinRuleOptions,
|
||||
getName: (rule) => rule.displayName,
|
||||
onChanged: canSetJoinRules
|
||||
? (rule) => setState(
|
||||
.joinRules,
|
||||
JoinRulesContent(
|
||||
joinRule: rule,
|
||||
allow: isRestricted(rule) && joinRules.allow.isEmpty
|
||||
? .new(
|
||||
parentSpaces.map(
|
||||
(roomId) =>
|
||||
.new(roomId: roomId, type: .membership),
|
||||
),
|
||||
)
|
||||
: joinRules.allow,
|
||||
),
|
||||
).onError(showError)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (isRestricted(joinRules.joinRule))
|
||||
.new(
|
||||
title: "Allowed Rooms",
|
||||
description:
|
||||
"Members of these rooms can join without an invite.",
|
||||
icon: Icons.meeting_room,
|
||||
builder: (title, description, icon) => HookConsumer(
|
||||
builder: (context, ref, _) {
|
||||
final saving = Saving.use();
|
||||
final names = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => allowedRooms
|
||||
.map(
|
||||
(roomId) =>
|
||||
rooms[roomId]?.metadata?.name ?? roomId,
|
||||
)
|
||||
.toIList(),
|
||||
),
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
enabled: canSetJoinRules && !saving.active,
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(
|
||||
names.isEmpty
|
||||
? "None, so only invited users can join"
|
||||
: names.join(", "),
|
||||
maxLines: 2,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(_) => AllowedRoomsSheet(
|
||||
roomId: roomId,
|
||||
allowed: allowedRooms,
|
||||
parentSpaces: parentSpaces,
|
||||
onSave: (allowed) => saving.run(
|
||||
() => setAllowedRooms(allowed).onError(showError),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Aliases",
|
||||
description:
|
||||
"The addresses the room advertises, and its main one.",
|
||||
icon: Icons.alternate_email,
|
||||
builder: (title, description, icon) => HookBuilder(
|
||||
builder: (context) {
|
||||
final saving = Saving.use();
|
||||
|
||||
return ListTile(
|
||||
enabled: canSetAliases && !saving.active,
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(
|
||||
switch ((
|
||||
canonicalAlias.alias,
|
||||
canonicalAlias.altAliases.length,
|
||||
)) {
|
||||
(null, 0) => description,
|
||||
(final alias?, 0) => alias,
|
||||
(final alias?, 1) =>
|
||||
"$alias and ${canonicalAlias.altAliases.first}",
|
||||
(final alias?, final others) =>
|
||||
"$alias and $others more",
|
||||
(null, _) => canonicalAlias.altAliases.join(", "),
|
||||
},
|
||||
maxLines: 2,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(_) => AliasesSheet(
|
||||
aliases: knownAliases,
|
||||
canonical: canonicalAlias.alias,
|
||||
alternatives: canonicalAlias.altAliases,
|
||||
onSave: (canonical, alternatives) => saving.run(
|
||||
() => setAliases(
|
||||
canonical,
|
||||
alternatives,
|
||||
).onError(showError),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Local Aliases",
|
||||
description: "Addresses for the room on your server.",
|
||||
icon: Icons.dns,
|
||||
builder: (title, description, icon) => Builder(
|
||||
builder: (context) => ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(
|
||||
localAliases.isEmpty
|
||||
? description
|
||||
: localAliases.join(", "),
|
||||
maxLines: 2,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(_) => LocalAliasesSheet(
|
||||
roomId,
|
||||
onRemoved: canSetAliases
|
||||
? (alias) async {
|
||||
if (published.contains(alias)) {
|
||||
await setAliases(
|
||||
canonicalAlias.alias,
|
||||
canonicalAlias.altAliases,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "History Visibility",
|
||||
description:
|
||||
"Which messages from before joining members can read.",
|
||||
icon: Icons.history,
|
||||
builder: (title, description, icon) =>
|
||||
DialogListTile<HistoryVisibility>(
|
||||
icon: Icon(icon),
|
||||
title: title,
|
||||
subtitle: Text(description),
|
||||
value: historyVisibility,
|
||||
options: [.shared, .invited, .joined],
|
||||
getName: (visibility) => switch (visibility) {
|
||||
.shared => "All events",
|
||||
.invited => "After invite",
|
||||
.joined => "After join",
|
||||
.worldReadable => "Anyone",
|
||||
.unknown => "Unknown",
|
||||
},
|
||||
onChanged: canSetHistoryVisibility
|
||||
? (visibility) => setState(
|
||||
.historyVisibility,
|
||||
HistoryVisibilityContent(
|
||||
historyVisibility: visibility,
|
||||
),
|
||||
).onError(showError)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
.new(
|
||||
title: "Permissions",
|
||||
icon: Icons.admin_panel_settings,
|
||||
settings: .new([
|
||||
permissionSetting(.usersDefault),
|
||||
header("Users", "Power levels of specific users.", Icons.group),
|
||||
...users.map((entry) {
|
||||
final member = memberOf(entry.key);
|
||||
// Others can only be changed if they're below you, but your own level can be lowered
|
||||
final canChange =
|
||||
canSetPowerLevels &&
|
||||
(entry.key == userId || entry.value < ownLevel);
|
||||
|
||||
return Setting(
|
||||
title: member?.displayName ?? entry.key,
|
||||
description: entry.key,
|
||||
icon: Icons.person,
|
||||
builder: (title, description, _) => PowerLevelTile(
|
||||
leading: AvatarOrHash(
|
||||
member?.avatarUrl,
|
||||
title,
|
||||
dimension: 40,
|
||||
),
|
||||
title: title,
|
||||
description: description,
|
||||
value: entry.value,
|
||||
ownLevel: ownLevel,
|
||||
onChanged: canChange
|
||||
? (level) => setPowerLevels(
|
||||
powerLevels.copyWith(
|
||||
users: powerLevels.users.add(entry.key, level),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onRemoved: canChange
|
||||
? () => setPowerLevels(
|
||||
powerLevels.copyWith(
|
||||
users: powerLevels.users.remove(entry.key),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
removeConfirmation: entry.key == userId
|
||||
? "Remove your own power level? You'll have the default of ${powerLevels.usersDefault}, and won't be able to raise it again yourself."
|
||||
: "Remove $title's power level? They'll have the default of ${powerLevels.usersDefault}.",
|
||||
),
|
||||
);
|
||||
}),
|
||||
addSetting(
|
||||
title: "Add Users",
|
||||
description: "Give users their own power level.",
|
||||
sheet: (save) => AddUsersPowerLevelSheet(
|
||||
roomId: roomId,
|
||||
existing: powerLevels.users.keys.toISet().union(
|
||||
roomPowerLevels.creators,
|
||||
),
|
||||
ownLevel: ownLevel,
|
||||
onSave: (users, level) => save(
|
||||
powerLevels.copyWith(
|
||||
users: powerLevels.users.addEntries(
|
||||
users.map((user) => MapEntry(user, level)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
header(
|
||||
"Groups",
|
||||
"Power levels set together, which can each be set on their own.",
|
||||
Icons.workspaces,
|
||||
),
|
||||
...PowerLevelGroup.values.map(groupSetting),
|
||||
header(
|
||||
"Regular Events",
|
||||
"Power levels needed to send messages and other timeline events.",
|
||||
Icons.chat,
|
||||
),
|
||||
...shownEvents
|
||||
.where((event) => event != .pinnedEvents)
|
||||
.map(
|
||||
(event) =>
|
||||
eventSetting(event, fallback: powerLevels.eventsDefault),
|
||||
),
|
||||
permissionSetting(.eventsDefault),
|
||||
header(
|
||||
"State Events",
|
||||
"Power levels needed to change the room's state.",
|
||||
Icons.settings,
|
||||
),
|
||||
permissionSetting(.invite),
|
||||
permissionSetting(.stateDefault),
|
||||
header(
|
||||
"Other Events",
|
||||
"Power levels for specific event types.",
|
||||
Icons.code,
|
||||
),
|
||||
...otherEvents.map(
|
||||
(entry) => Setting(
|
||||
title: entry.key,
|
||||
description: "The power level needed to send this event.",
|
||||
icon: Icons.code,
|
||||
builder: (title, _, icon) => PowerLevelTile(
|
||||
leading: Icon(icon),
|
||||
title: title,
|
||||
description: null,
|
||||
value: entry.value,
|
||||
ownLevel: ownLevel,
|
||||
onChanged: canSetPowerLevels
|
||||
? (level) => setPowerLevels(
|
||||
powerLevels.copyWith(
|
||||
events: powerLevels.events.add(entry.key, level),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onRemoved: canSetPowerLevels
|
||||
? () => setPowerLevels(
|
||||
powerLevels.copyWith(
|
||||
events: powerLevels.events.remove(entry.key),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
addSetting(
|
||||
title: "Add Event",
|
||||
description: "Give an event type its own power level.",
|
||||
sheet: (save) => PowerLevelEntrySheet(
|
||||
title: "Add Event",
|
||||
keyLabel: "Event type",
|
||||
keyHint: "m.room.name",
|
||||
ownLevel: ownLevel,
|
||||
onSave: (type, level) => save(
|
||||
powerLevels.copyWith(
|
||||
events: powerLevels.events.add(type, level),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
.autoDispose<
|
||||
RoomSettingsSectionsController,
|
||||
IMap<String, IList<SettingsCategory>>,
|
||||
String
|
||||
>(RoomSettingsSectionsController.new);
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ class RoomSummaryController(final JoinRoomRequest request)
|
|||
extends AsyncNotifier<RoomSummary> {
|
||||
@override
|
||||
Future<RoomSummary> build() =>
|
||||
ref.watch(ClientController.provider.notifier).getRoomSummary(request);
|
||||
ref.read(ClientController.provider.notifier).getRoomSummary(request);
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
.autoDispose<RoomSummaryController, RoomSummary, JoinRoomRequest>(
|
||||
|
|
|
|||
34
lib/controllers/room_typing.dart
Normal file
34
lib/controllers/room_typing.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/settings.dart";
|
||||
import "package:nexus/controllers/typing.dart";
|
||||
|
||||
/// The other users typing in a room, or none if typing indicators are hidden
|
||||
/// in settings.
|
||||
class RoomTypingController(final String roomId)
|
||||
extends Notifier<IList<String>> {
|
||||
@override
|
||||
IList<String> build() {
|
||||
final shouldShow = ref.watch(
|
||||
SettingsController.provider.select(
|
||||
(settings) => settings.value?.typingIndicators != .neither,
|
||||
),
|
||||
);
|
||||
if (!shouldShow) return const .empty();
|
||||
|
||||
final userId = ref.watch(
|
||||
ClientStateController.provider.select((state) => state?.userId),
|
||||
);
|
||||
|
||||
return ref
|
||||
.watch(TypingController.provider.select((typing) => typing[roomId]))
|
||||
?.remove(userId ?? "") ??
|
||||
const .empty();
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider.family
|
||||
.autoDispose<RoomTypingController, IList<String>, String>(
|
||||
RoomTypingController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,42 +1,91 @@
|
|||
import "dart:isolate";
|
||||
import "dart:async";
|
||||
|
||||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/read_receipt.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
|
||||
class RoomsController extends Notifier<IMap<String, Room>> {
|
||||
@override
|
||||
IMap<String, Room> build() => .new();
|
||||
|
||||
Future<void> addState(
|
||||
String roomId,
|
||||
IList<Event> state, {
|
||||
bool isMembers = false,
|
||||
}) async => update(
|
||||
.new({
|
||||
roomId: Room(
|
||||
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
|
||||
hasFetchedState: true,
|
||||
hasFetchedMembers: isMembers,
|
||||
state: await Isolate.run(() {
|
||||
final newState = state.fold<IMap<String, IMap<String, int>>>(
|
||||
.new(),
|
||||
(previousValue, stateEvent) => previousValue.add(
|
||||
stateEvent.type,
|
||||
(previousValue[stateEvent.type] ?? .new()).add(
|
||||
stateEvent.stateKey!,
|
||||
stateEvent.rowId,
|
||||
),
|
||||
),
|
||||
);
|
||||
return newState;
|
||||
void addState(String roomId, IList<Event> state, {bool isMembers = false}) =>
|
||||
update(
|
||||
.new({
|
||||
roomId: stateRoom(state)
|
||||
.copyWith(hasFetchedState: true, hasFetchedMembers: isMembers),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
.new(),
|
||||
.new(),
|
||||
);
|
||||
|
||||
void addSpecificState(Iterable<Event> state) => update(
|
||||
.new(
|
||||
state
|
||||
.groupListsBy((event) => event.roomId)
|
||||
.map((roomId, roomState) => .new(roomId, stateRoom(roomState))),
|
||||
),
|
||||
);
|
||||
|
||||
void update(IMap<String, Room> rooms, ISet<String> leftRooms) {
|
||||
static Room stateRoom(Iterable<Event> state) => Room(
|
||||
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
|
||||
state: .new(
|
||||
state
|
||||
.groupListsBy((event) => event.type)
|
||||
.map(
|
||||
(type, group) => .new(
|
||||
type,
|
||||
.fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
static IMap<String, IList<ReadReceipt>> mergeReceipts(
|
||||
IMap<String, IList<ReadReceipt>> existing,
|
||||
IMap<String, IList<ReadReceipt>> incoming,
|
||||
) => incoming.isEmpty
|
||||
? existing
|
||||
: existing.values.flattened
|
||||
.followedBy(incoming.values.flattened)
|
||||
.groupListsBy((receipt) => (receipt.userId, receipt.thread))
|
||||
.values
|
||||
.map(
|
||||
(receipts) => receipts.reduce(
|
||||
(newest, receipt) => newest.timestamp.isAfter(receipt.timestamp)
|
||||
? newest
|
||||
: receipt,
|
||||
),
|
||||
)
|
||||
.groupListsBy((receipt) => receipt.eventId)
|
||||
.map((eventId, receipts) => MapEntry(eventId, receipts.toIList()))
|
||||
.toIMap();
|
||||
|
||||
/// Adds events to their rooms, without changing the rooms' timelines.
|
||||
void storeEvents(Iterable<Event> events) => update(
|
||||
.new(
|
||||
events
|
||||
.groupListsBy((event) => event.roomId)
|
||||
.map(
|
||||
(roomId, roomEvents) => .new(
|
||||
roomId,
|
||||
Room(
|
||||
// ignore: unused_result - I am using it you donkey
|
||||
events: .fromIterable(
|
||||
roomEvents,
|
||||
keyMapper: (event) => event.rowId,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void update(
|
||||
IMap<String, Room> rooms, [
|
||||
ISet<String> leftRooms = const .empty(),
|
||||
]) {
|
||||
final merged = rooms.entries.fold(state, (acc, entry) {
|
||||
final roomId = entry.key;
|
||||
final incoming = entry.value;
|
||||
|
|
@ -46,13 +95,16 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
|||
roomId,
|
||||
existing?.copyWith(
|
||||
hasMore: incoming.hasMore,
|
||||
sticky:
|
||||
(incoming.sticky.isEmpty == true
|
||||
? existing.sticky
|
||||
: existing.sticky.addAll(incoming.sticky))
|
||||
.removeWhere(
|
||||
(rowId) => incoming.timeline.values.contains(rowId),
|
||||
),
|
||||
clientSticky: incoming.resetClientSticky
|
||||
? incoming.clientSticky
|
||||
: (incoming.clientSticky.isEmpty
|
||||
? existing.clientSticky
|
||||
: existing.clientSticky.addAll(
|
||||
incoming.clientSticky,
|
||||
))
|
||||
.removeWhere(
|
||||
(rowId) => incoming.timeline.values.contains(rowId),
|
||||
),
|
||||
metadata: incoming.metadata ?? existing.metadata,
|
||||
events: incoming.events.isEmpty
|
||||
? existing.events
|
||||
|
|
@ -64,32 +116,49 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
|||
(previousValue[event.key] ?? .new()).addAll(event.value),
|
||||
),
|
||||
),
|
||||
resetClientSticky: false,
|
||||
reset: false,
|
||||
hasFetchedMembers:
|
||||
incoming.hasFetchedMembers || existing.hasFetchedMembers,
|
||||
hasFetchedState:
|
||||
incoming.hasFetchedState || existing.hasFetchedState,
|
||||
timeline: (incoming.reset
|
||||
timeline: incoming.reset
|
||||
? incoming.timeline
|
||||
: existing.timeline.addAll(incoming.timeline)),
|
||||
receipts: incoming.receipts.entries.fold(
|
||||
existing.receipts,
|
||||
(receiptAcc, event) => receiptAcc.add(
|
||||
event.key,
|
||||
(receiptAcc[event.key] ?? .new()).addAll(event.value),
|
||||
),
|
||||
),
|
||||
: existing.timeline.addAll(incoming.timeline),
|
||||
receipts: mergeReceipts(existing.receipts, incoming.receipts),
|
||||
) ??
|
||||
incoming,
|
||||
incoming.copyWith(
|
||||
receipts: mergeReceipts(const .empty(), incoming.receipts),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
final prunedList = leftRooms.fold(
|
||||
merged,
|
||||
(acc, roomId) => acc.remove(roomId),
|
||||
);
|
||||
state = leftRooms.fold(merged, (acc, roomId) => acc.remove(roomId));
|
||||
}
|
||||
|
||||
state = prunedList;
|
||||
/// Waits for a sent state event to become the room's state, which happens
|
||||
/// once it comes down sync. Gives up after a while, e.g. if another event
|
||||
/// replaced it first.
|
||||
Future<void> awaitState(
|
||||
String roomId,
|
||||
String type,
|
||||
String eventId, {
|
||||
String stateKey = "",
|
||||
}) {
|
||||
bool arrived(IMap<String, Room> rooms) =>
|
||||
switch (rooms[roomId]?.state[type]?[stateKey]) {
|
||||
final rowId? => rooms[roomId]?.events[rowId]?.eventId == eventId,
|
||||
null => false,
|
||||
};
|
||||
if (arrived(state)) return .value();
|
||||
|
||||
final completer = Completer<void>();
|
||||
final removeListener = listenSelf((_, rooms) {
|
||||
if (arrived(rooms) && !completer.isCompleted) completer.complete();
|
||||
});
|
||||
return completer.future
|
||||
.timeout(const .new(seconds: 10), onTimeout: () {})
|
||||
.whenComplete(removeListener);
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider<RoomsController, IMap<String, Room>>(
|
||||
|
|
|
|||
31
lib/controllers/selected_room.dart
Normal file
31
lib/controllers/selected_room.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/key.dart";
|
||||
import "package:nexus/controllers/selected_space.dart";
|
||||
import "package:nexus/helpers/extensions/get_all_child_rooms.dart";
|
||||
|
||||
class SelectedRoomController extends Notifier<String?> {
|
||||
@override
|
||||
String? build() {
|
||||
final roomId = ref.watch(
|
||||
KeyController.provider(KeyController.roomKey)
|
||||
.select((async) => async.value),
|
||||
);
|
||||
|
||||
final roomIds = ref.watch(
|
||||
SelectedSpaceController.provider.select(
|
||||
(space) => space?.allChildRooms
|
||||
.map((room) => room.metadata?.id)
|
||||
.nonNulls
|
||||
.toIList(),
|
||||
),
|
||||
);
|
||||
if (roomIds == null) return roomId;
|
||||
|
||||
return roomIds.contains(roomId) ? roomId : roomIds.firstOrNull;
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider<SelectedRoomController, String?>(
|
||||
SelectedRoomController.new,
|
||||
);
|
||||
}
|
||||
25
lib/controllers/selected_space.dart
Normal file
25
lib/controllers/selected_space.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/key.dart";
|
||||
import "package:nexus/controllers/spaces.dart";
|
||||
import "package:nexus/models/space.dart";
|
||||
|
||||
class SelectedSpaceController extends Notifier<Space?> {
|
||||
@override
|
||||
Space? build() {
|
||||
final spaces = ref.watch(SpacesController.provider).value;
|
||||
if (spaces == null) return null;
|
||||
|
||||
final spaceId = ref.watch(
|
||||
KeyController.provider(KeyController.spaceKey)
|
||||
.select((async) => async.value),
|
||||
);
|
||||
|
||||
return spaces.firstWhereOrNull((space) => space.id == spaceId) ??
|
||||
spaces.firstOrNull;
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider<SelectedSpaceController, Space?>(
|
||||
SelectedSpaceController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import "dart:convert";
|
||||
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/settings_file.dart";
|
||||
import "package:nexus/models/settings.dart";
|
||||
|
|
@ -17,7 +18,7 @@ class SettingsController extends AsyncNotifier<Settings> {
|
|||
|
||||
Future<void> set(Settings settings) async {
|
||||
state = .data(settings);
|
||||
final file = await ref.watch(SettingsFileController.provider.future);
|
||||
final file = await ref.read(SettingsFileController.provider.future);
|
||||
await file.writeAsString(json.encode(settings.toJson()));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import "dart:io";
|
||||
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:path/path.dart";
|
||||
import "package:path_provider/path_provider.dart";
|
||||
|
|
|
|||
|
|
@ -1,19 +1,34 @@
|
|||
import "dart:io";
|
||||
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:intl/intl.dart";
|
||||
import "package:m3e_buttons/m3e_buttons.dart";
|
||||
import "package:nexus/controllers/account_data.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/init_complete.dart";
|
||||
import "package:nexus/controllers/notification.dart";
|
||||
import "package:nexus/controllers/settings.dart";
|
||||
import "package:nexus/controllers/unified_push.dart";
|
||||
import "package:nexus/controllers/spec_versions.dart";
|
||||
import "package:nexus/controllers/unified_push_allowed.dart";
|
||||
import "package:nexus/controllers/unified_push_distributors.dart";
|
||||
import "package:nexus/helpers/extensions/distributor_name.dart";
|
||||
import "package:nexus/models/account_data.dart";
|
||||
import "package:nexus/models/client_state.dart";
|
||||
import "package:nexus/models/name_color.dart";
|
||||
import "package:nexus/models/room_order.dart";
|
||||
import "package:nexus/models/settings.dart";
|
||||
import "package:nexus/models/settings_category.dart";
|
||||
import "package:nexus/models/show_and_send.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
||||
import "package:nexus/widgets/user_bottom_sheet.dart";
|
||||
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
|
||||
class SettingsSectionsController
|
||||
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {
|
||||
|
|
@ -21,6 +36,30 @@ class SettingsSectionsController
|
|||
Future<IMap<String, IList<SettingsCategory>>> build() async {
|
||||
final settings = await ref.watch(SettingsController.provider.future);
|
||||
|
||||
/// A tile for a [ShowAndSend] setting, saving the settings from [update].
|
||||
Widget showAndSendTile(
|
||||
String title,
|
||||
String description,
|
||||
IconData icon, {
|
||||
required ShowAndSend value,
|
||||
required Settings Function(ShowAndSend value) update,
|
||||
}) => DialogListTile<ShowAndSend>(
|
||||
icon: Icon(icon),
|
||||
title: title,
|
||||
subtitle: Text(description),
|
||||
value: value,
|
||||
options: ShowAndSend.values,
|
||||
getName: (option) => switch (option) {
|
||||
.showAndSend => "Show and send",
|
||||
.show => "Only show",
|
||||
.neither => "Neither",
|
||||
},
|
||||
onChanged: (value) => ref
|
||||
.read(SettingsController.provider.notifier)
|
||||
.set(update(value))
|
||||
.onError(showError),
|
||||
);
|
||||
|
||||
return .new({
|
||||
"General": .new([
|
||||
.new(
|
||||
|
|
@ -36,11 +75,11 @@ class SettingsSectionsController
|
|||
icon: Icon(icon),
|
||||
title: title,
|
||||
subtitle: Text(description),
|
||||
initialValue: settings.theme,
|
||||
value: settings.theme,
|
||||
options: ThemeMode.values,
|
||||
getName: (option) => toBeginningOfSentenceCase(option.name),
|
||||
onChanged: (value) => ref
|
||||
.watch(SettingsController.provider.notifier)
|
||||
.read(SettingsController.provider.notifier)
|
||||
.set(settings.copyWith(theme: value))
|
||||
.onError(showError),
|
||||
),
|
||||
|
|
@ -60,21 +99,108 @@ class SettingsSectionsController
|
|||
Platform.isMacOS ||
|
||||
Platform.isWindows)
|
||||
? (value) => ref
|
||||
.watch(SettingsController.provider.notifier)
|
||||
.read(SettingsController.provider.notifier)
|
||||
.set(settings.copyWith(useDynamicTheming: value))
|
||||
.onError(showError)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Name Colors",
|
||||
description: "Color names by power level, so moderators and admins stand out, or by user, so everyone has their own.",
|
||||
icon: Icons.format_color_text,
|
||||
builder: (title, description, icon) => DialogListTile<NameColor>(
|
||||
icon: Icon(icon),
|
||||
title: title,
|
||||
subtitle: Text(description),
|
||||
value: settings.nameColor,
|
||||
options: NameColor.values,
|
||||
getName: (option) => switch (option) {
|
||||
.powerLevel => "By power level",
|
||||
.user => "By user",
|
||||
},
|
||||
onChanged: (value) => ref
|
||||
.read(SettingsController.provider.notifier)
|
||||
.set(settings.copyWith(nameColor: value))
|
||||
.onError(showError),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
.new(
|
||||
title: "Behavior",
|
||||
icon: Icons.psychology,
|
||||
settings: .new([
|
||||
.new(
|
||||
title: "All Rooms In Home",
|
||||
description: "Show all rooms in home, instead of just ones that aren't in spaces",
|
||||
icon: Icons.home,
|
||||
builder: (title, description, icon) => SwitchListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(description),
|
||||
secondary: Icon(icon),
|
||||
value: settings.showAllInHome,
|
||||
onChanged: (value) => ref
|
||||
.read(SettingsController.provider.notifier)
|
||||
.set(settings.copyWith(showAllInHome: value))
|
||||
.onError(showError),
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Room Ordering",
|
||||
description: "Sort rooms by recent activity, optionally showing mentioned and unread rooms first.",
|
||||
icon: Icons.sort,
|
||||
builder: (title, description, icon) => DialogListTile<RoomOrder>(
|
||||
icon: Icon(icon),
|
||||
title: title,
|
||||
subtitle: Text(description),
|
||||
value: settings.roomOrder,
|
||||
options: RoomOrder.values,
|
||||
getName: (option) => switch (option) {
|
||||
.unreadFirst => "Mentioned, then unread",
|
||||
.recentActivity => "Recent activity only",
|
||||
},
|
||||
onChanged: (value) => ref
|
||||
.read(SettingsController.provider.notifier)
|
||||
.set(settings.copyWith(roomOrder: value))
|
||||
.onError(showError),
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Swipe To Reply",
|
||||
description: "Swipe a message to the right to reply to it.",
|
||||
icon: Icons.swipe_right,
|
||||
builder: (title, description, icon) => SwitchListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(description),
|
||||
secondary: Icon(icon),
|
||||
value: settings.swipeToReply,
|
||||
onChanged: (value) => ref
|
||||
.read(SettingsController.provider.notifier)
|
||||
.set(settings.copyWith(swipeToReply: value))
|
||||
.onError(showError),
|
||||
),
|
||||
),
|
||||
if (!Platform.isAndroid && !Platform.isIOS)
|
||||
.new(
|
||||
title: "Show Minimize Button",
|
||||
description:
|
||||
"Enable this to add a minimize button to the appbar",
|
||||
icon: Icons.minimize,
|
||||
builder: (title, description, icon) => SwitchListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(description),
|
||||
secondary: Icon(icon),
|
||||
value: settings.showMinimizeButton,
|
||||
onChanged: (value) => ref
|
||||
.read(SettingsController.provider.notifier)
|
||||
.set(settings.copyWith(showMinimizeButton: value))
|
||||
.onError(showError),
|
||||
),
|
||||
),
|
||||
.new(
|
||||
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, and swiping drawers open.",
|
||||
icon: Icons.construction,
|
||||
builder: (title, description, icon) => SwitchListTile(
|
||||
title: Text(title),
|
||||
|
|
@ -83,18 +209,201 @@ class SettingsSectionsController
|
|||
value: settings.linuxMobileMode,
|
||||
onChanged: Platform.isLinux
|
||||
? (value) => ref
|
||||
.watch(SettingsController.provider.notifier)
|
||||
.read(SettingsController.provider.notifier)
|
||||
.set(settings.copyWith(linuxMobileMode: value))
|
||||
.onError(showError)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Hide Membership Events",
|
||||
description: "Hides membership events (invites, joins, leaves & bans) from appearing in conversations. Knocks are still shown.",
|
||||
icon: Icons.person_off,
|
||||
builder: (title, description, icon) => SwitchListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(description),
|
||||
secondary: Icon(icon),
|
||||
value: settings.hideMembershipEvents,
|
||||
onChanged: (value) => ref
|
||||
.read(SettingsController.provider.notifier)
|
||||
.set(settings.copyWith(hideMembershipEvents: value))
|
||||
.onError(showError),
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Hide Server ACL Events",
|
||||
description: "Hides server ACL (server ban list) changes from appearing in conversations.",
|
||||
icon: Icons.dns,
|
||||
builder: (title, description, icon) => SwitchListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(description),
|
||||
secondary: Icon(icon),
|
||||
value: settings.hideServerAclEvents,
|
||||
onChanged: (value) => ref
|
||||
.read(SettingsController.provider.notifier)
|
||||
.set(settings.copyWith(hideServerAclEvents: value))
|
||||
.onError(showError),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
.new(
|
||||
title: "Privacy",
|
||||
icon: Icons.privacy_tip,
|
||||
settings: .new([
|
||||
.new(
|
||||
title: "Typing Indicators",
|
||||
description: "Show when others are typing, and let others see when you're typing.",
|
||||
icon: Icons.keyboard,
|
||||
builder: (title, description, icon) => showAndSendTile(
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
value: settings.typingIndicators,
|
||||
update: (value) => settings.copyWith(typingIndicators: value),
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Read Receipts",
|
||||
description: "Show how far others have read, and let others see what you've read. When not sent, read receipts are still sent privately, so your other devices know what you've read.",
|
||||
icon: Icons.done_all,
|
||||
builder: (title, description, icon) => showAndSendTile(
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
value: settings.readReceipts,
|
||||
update: (value) => settings.copyWith(readReceipts: value),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
if (ref.watch(ClientStateController.provider)?.isLoggedIn == true)
|
||||
if (ref.watch(ClientStateController.provider) case ClientState(
|
||||
isLoggedIn: true,
|
||||
:final userId?,
|
||||
))
|
||||
"Account": .new([
|
||||
.new(title: "Profile", icon: Icons.person, settings: .new([])),
|
||||
.new(
|
||||
title: "Profile",
|
||||
icon: Icons.person,
|
||||
settings: .new([
|
||||
.new(
|
||||
title: "Profile",
|
||||
description: "Your avatar, display name, pronouns, and timezone, as others see them.",
|
||||
icon: Icons.person,
|
||||
builder: (title, description, icon) => Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: .new(maxWidth: 500),
|
||||
child: UserBottomSheet(userId, editable: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
.new(
|
||||
title: "Notifications",
|
||||
icon: Icons.notifications,
|
||||
settings: .new([
|
||||
.new(
|
||||
title: "Push notifications",
|
||||
description: "Enable push notifications using Web Push, and choose which UnifiedPush distributor to use.",
|
||||
builder: (title, description, icon) => HookConsumer(
|
||||
builder: (context, ref, _) {
|
||||
final loading = useState(false);
|
||||
final pusherRegistered = ref.watch(
|
||||
UnifiedPushController.provider,
|
||||
);
|
||||
final unifiedPushAllowed = ref.watch(
|
||||
UnifiedPushAllowedController.provider,
|
||||
);
|
||||
final distributors = ref.watch(
|
||||
UnifiedPushDistributorsController.provider,
|
||||
);
|
||||
final current = pusherRegistered.value;
|
||||
|
||||
return DialogListTile<String?>(
|
||||
title: title,
|
||||
subtitle: Text(
|
||||
unifiedPushAllowed.maybeWhen(
|
||||
data: (data) => data,
|
||||
orElse: () => null,
|
||||
) ??
|
||||
description,
|
||||
),
|
||||
icon: Icon(icon),
|
||||
value: current,
|
||||
options: [
|
||||
null,
|
||||
...?distributors.value,
|
||||
if (current != null &&
|
||||
distributors.value?.contains(current) != true)
|
||||
current,
|
||||
],
|
||||
getName: (option) =>
|
||||
option == null ? "Off" : option.distributorName,
|
||||
onChanged: loading.value
|
||||
? null
|
||||
: unifiedPushAllowed.maybeWhen(
|
||||
data: (data) => data == null,
|
||||
orElse: () => false,
|
||||
)
|
||||
? pusherRegistered.maybeWhen(
|
||||
data: (_) => (value) async {
|
||||
if (value == current) return;
|
||||
try {
|
||||
loading.value = true;
|
||||
if (value != null) {
|
||||
final messenger = ScaffoldMessenger.of(
|
||||
context,
|
||||
);
|
||||
if (!await ref
|
||||
.read(
|
||||
NotificationController
|
||||
.provider
|
||||
.notifier,
|
||||
)
|
||||
.requestPermissions()) {
|
||||
messenger.showSnackBar(
|
||||
.new(
|
||||
content: Text(
|
||||
"Notifications are disabled. Enable them for Nexus in your system settings.",
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(
|
||||
UnifiedPushController
|
||||
.provider
|
||||
.notifier,
|
||||
)
|
||||
.register(value);
|
||||
} else {
|
||||
await ref
|
||||
.read(
|
||||
UnifiedPushController
|
||||
.provider
|
||||
.notifier,
|
||||
)
|
||||
.deregister();
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
showError(error, stackTrace);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
orElse: () => null,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
icon: Icons.notification_add,
|
||||
),
|
||||
]),
|
||||
),
|
||||
.new(
|
||||
title: "Safety",
|
||||
icon: Icons.gpp_good,
|
||||
|
|
@ -111,7 +420,7 @@ class SettingsSectionsController
|
|||
icon: Icon(icon),
|
||||
title: title,
|
||||
subtitle: Text(description),
|
||||
initialValue: ref
|
||||
value: ref
|
||||
.watch(AccountDataController.provider)
|
||||
.invitePermissionConfig
|
||||
.defaultAction,
|
||||
|
|
@ -132,7 +441,7 @@ class SettingsSectionsController
|
|||
onChanged: specVersionsResponse.maybeWhen(
|
||||
data: (_) =>
|
||||
(value) => ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.read(ClientController.provider.notifier)
|
||||
.setAccountData(
|
||||
.new(
|
||||
type: AccountData.invitePermissionConfigKey,
|
||||
|
|
@ -155,6 +464,21 @@ class SettingsSectionsController
|
|||
title: "Other",
|
||||
icon: Icons.key,
|
||||
settings: .new([
|
||||
.new(
|
||||
title: "Reset Encryption Identity",
|
||||
description: "Replace your encryption identity and recovery key with new ones, if you've lost your recovery key or your encryption is broken.",
|
||||
builder: (title, description, icon) => Builder(
|
||||
builder: (context) => M3EButton.icon(
|
||||
onPressed: () => context.showAdaptiveSheet(
|
||||
(_) => const ResetEncryptionSheet(),
|
||||
),
|
||||
label: Text(title),
|
||||
icon: Icon(icon),
|
||||
tooltip: description,
|
||||
),
|
||||
),
|
||||
icon: Icons.key_off,
|
||||
),
|
||||
.new(
|
||||
title: "Log Out",
|
||||
description:
|
||||
|
|
@ -164,14 +488,21 @@ class SettingsSectionsController
|
|||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return M3EButton.icon(
|
||||
onPressed: () async {
|
||||
Navigator.of(context)
|
||||
.popUntil((route) => route.isFirst);
|
||||
final container = ProviderScope.containerOf(context);
|
||||
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
final route = ModalRoute.of(context)!;
|
||||
Navigator.of(context).popUntil((r) => r.isFirst);
|
||||
await route.completed;
|
||||
|
||||
await ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.logout();
|
||||
await container
|
||||
.read(UnifiedPushController.provider.notifier)
|
||||
.deregister()
|
||||
.onError(showError);
|
||||
container.invalidate(InitCompleteController.provider);
|
||||
await container
|
||||
.read(ClientController.provider.notifier)
|
||||
.logout()
|
||||
.onError(showError);
|
||||
},
|
||||
label: Text(title),
|
||||
icon: Icon(icon),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
|
||||
class SharedPrefsController extends AsyncNotifier<SharedPreferences> {
|
||||
class SharedPrefsController extends Notifier<SharedPreferencesAsync> {
|
||||
@override
|
||||
Future<SharedPreferences> build() async => .getInstance();
|
||||
SharedPreferencesAsync build() => SharedPreferencesAsync();
|
||||
|
||||
static final provider =
|
||||
AsyncNotifierProvider<SharedPrefsController, SharedPreferences>(
|
||||
NotifierProvider<SharedPrefsController, SharedPreferencesAsync>(
|
||||
SharedPrefsController.new,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
13
lib/controllers/sidebar_page.dart
Normal file
13
lib/controllers/sidebar_page.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/models/sidebar_page.dart";
|
||||
|
||||
class SidebarPageController extends Notifier<SidebarPage> {
|
||||
@override
|
||||
SidebarPage build() => .rooms;
|
||||
|
||||
void set(SidebarPage page) => state = page;
|
||||
|
||||
static final provider = NotifierProvider<SidebarPageController, SidebarPage>(
|
||||
SidebarPageController.new,
|
||||
);
|
||||
}
|
||||
34
lib/controllers/space_hierarchy.dart
Normal file
34
lib/controllers/space_hierarchy.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/models/space_hierarchy.dart";
|
||||
|
||||
class SpaceHierarchyController(final String spaceId)
|
||||
extends AsyncNotifier<SpaceHierarchy> {
|
||||
@override
|
||||
Future<SpaceHierarchy> build() => ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.getSpaceHierarchy(.new(roomId: spaceId, maxDepth: 1));
|
||||
|
||||
Future<void> loadMore() async {
|
||||
final current = await future;
|
||||
if (current.nextBatch == null) return;
|
||||
|
||||
final next = await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.getSpaceHierarchy(
|
||||
.new(roomId: spaceId, maxDepth: 1, from: current.nextBatch),
|
||||
);
|
||||
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
rooms: current.rooms.addAll(next.rooms),
|
||||
nextBatch: next.nextBatch,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
.autoDispose<SpaceHierarchyController, SpaceHierarchy, String>(
|
||||
SpaceHierarchyController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -4,87 +4,90 @@ import "package:material_ui/material_ui.dart";
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/account_data.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/settings.dart";
|
||||
import "package:nexus/controllers/top_level_spaces.dart";
|
||||
import "package:nexus/controllers/space_edges.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
import "package:nexus/models/room_metadata.dart";
|
||||
import "package:nexus/models/space.dart";
|
||||
import "package:nexus/models/subspace.dart";
|
||||
|
||||
class SpacesController extends Notifier<IList<Space>> {
|
||||
class SpacesController extends AsyncNotifier<IList<Space>> {
|
||||
@override
|
||||
IList<Space> build() {
|
||||
Future<IList<Space>> build() async {
|
||||
final rooms = ref.watch(RoomsController.provider);
|
||||
final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider);
|
||||
final spaceEdges = ref.watch(SpaceEdgesController.provider);
|
||||
final accountData = ref.watch(AccountDataController.provider);
|
||||
|
||||
final childrenById = {
|
||||
for (final entry in spaceEdges.entries)
|
||||
entry.key: entry.value.map((e) => e.childId).toList(),
|
||||
};
|
||||
final childrenById = spaceEdges.map(
|
||||
(spaceId, edges) =>
|
||||
MapEntry(spaceId, edges.map((edge) => edge.childId).toList()),
|
||||
);
|
||||
|
||||
Set<String> collectDescendants(String startId) {
|
||||
final visited = <String>{};
|
||||
final stack = [startId];
|
||||
|
||||
while (stack.isNotEmpty) {
|
||||
final current = stack.removeLast();
|
||||
final children = childrenById[current] ?? const [];
|
||||
|
||||
for (final child in children) {
|
||||
if (visited.add(child)) {
|
||||
stack.add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visited;
|
||||
}
|
||||
/// Everything under [spaceId], however deep, skipping any already
|
||||
/// [visited] so cycles end.
|
||||
ISet<String> collectDescendants(
|
||||
String spaceId, [
|
||||
ISet<String> visited = const .empty(),
|
||||
]) => (childrenById[spaceId] ?? const []).fold(
|
||||
visited,
|
||||
(visited, childId) => visited.contains(childId)
|
||||
? visited
|
||||
: collectDescendants(childId, visited.add(childId)),
|
||||
);
|
||||
|
||||
Space buildSpace(String spaceId) {
|
||||
final space = rooms[spaceId];
|
||||
final directChildrenIds = childrenById[spaceId] ?? const [];
|
||||
|
||||
final directRooms = <Room>[];
|
||||
final subSpaces = <Subspace>[];
|
||||
|
||||
for (final childId in directChildrenIds) {
|
||||
final room = rooms[childId];
|
||||
if (room == null) continue;
|
||||
|
||||
if (childrenById.containsKey(childId)) {
|
||||
final descendants = collectDescendants(childId);
|
||||
|
||||
subSpaces.add(
|
||||
.new(
|
||||
room: room,
|
||||
children: .new(descendants.map((id) => rooms[id]).nonNulls),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
directRooms.add(room);
|
||||
}
|
||||
}
|
||||
|
||||
return .new(
|
||||
id: spaceId,
|
||||
room: space,
|
||||
title: space?.metadata?.name ?? "Unnamed Space",
|
||||
children: .new(directRooms),
|
||||
subSpaces: .new(subSpaces),
|
||||
children: .new(
|
||||
directChildrenIds
|
||||
.where((childId) => !childrenById.containsKey(childId))
|
||||
.map((childId) => rooms[childId])
|
||||
.nonNulls,
|
||||
),
|
||||
subSpaces: .new(
|
||||
directChildrenIds
|
||||
.where(childrenById.containsKey)
|
||||
.map(
|
||||
(childId) => switch (rooms[childId]) {
|
||||
final room? => Subspace(
|
||||
room: room,
|
||||
children: .new(
|
||||
collectDescendants(childId)
|
||||
.map((id) => rooms[id])
|
||||
.nonNulls,
|
||||
),
|
||||
),
|
||||
null => null,
|
||||
},
|
||||
)
|
||||
.nonNulls,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final spaces = topLevelSpaceIds.map(buildSpace).toIList();
|
||||
final spaces = topLevelSpaceIds
|
||||
.where(rooms.containsKey)
|
||||
.map(buildSpace)
|
||||
.toIList();
|
||||
|
||||
final usedRoomIds = {
|
||||
for (final space in spaces) ...[
|
||||
...space.children.map((r) => r.metadata?.id),
|
||||
...space.subSpaces.expand((s) => s.children.map((r) => r.metadata?.id)),
|
||||
],
|
||||
}.nonNulls.toISet();
|
||||
final usedRooms = spaces
|
||||
.expand(
|
||||
(space) => [
|
||||
...space.children,
|
||||
...space.subSpaces.expand((s) => s.children),
|
||||
],
|
||||
)
|
||||
.toISet();
|
||||
final usedRoomIds = usedRooms.map((room) => room.metadata?.id).toSet();
|
||||
|
||||
final directMessages = accountData.directMessages.values.flattened;
|
||||
final directMessages = accountData.directMessages.values.flattened.toSet();
|
||||
|
||||
final otherRooms = rooms.entries
|
||||
.where(
|
||||
|
|
@ -97,6 +100,15 @@ class SpacesController extends Notifier<IList<Space>> {
|
|||
.toIList();
|
||||
|
||||
final homeRooms = otherRooms
|
||||
.addAll(
|
||||
await ref.watch(
|
||||
SettingsController.provider.selectAsync(
|
||||
(data) => data.showAllInHome,
|
||||
),
|
||||
)
|
||||
? usedRooms
|
||||
: .empty(),
|
||||
)
|
||||
.where((r) => !directMessages.contains(r.metadata?.id))
|
||||
.toIList();
|
||||
|
||||
|
|
@ -122,28 +134,38 @@ class SpacesController extends Notifier<IList<Space>> {
|
|||
...spaces,
|
||||
];
|
||||
|
||||
final roomOrder = await ref.watch(
|
||||
SettingsController.provider.selectAsync((data) => data.roomOrder),
|
||||
);
|
||||
|
||||
// Mentioned -> Unread -> Other
|
||||
int unreadPriority(Room room) => switch (room.metadata) {
|
||||
RoomMetadata(unreadHighlights: > 0) => 0,
|
||||
RoomMetadata(unreadNotifications: > 0) ||
|
||||
RoomMetadata(unreadMessages: > 0) => 1,
|
||||
_ => 2,
|
||||
};
|
||||
|
||||
return allSpaces
|
||||
.map(
|
||||
(space) => space.copyWith(
|
||||
children: .new(
|
||||
space.children
|
||||
.sortedBy(
|
||||
(element) =>
|
||||
element
|
||||
.metadata
|
||||
?.sortingTimestamp
|
||||
.millisecondsSinceEpoch ??
|
||||
0,
|
||||
)
|
||||
.sortedBy((room) => room.metadata?.unreadMessages ?? 0)
|
||||
.reversed,
|
||||
),
|
||||
children: space.children.sort((a, b) {
|
||||
if (roomOrder == .unreadFirst) {
|
||||
final byUnread = unreadPriority(a).compareTo(unreadPriority(b));
|
||||
if (byUnread != 0) return byUnread;
|
||||
}
|
||||
|
||||
// Most recent activity first
|
||||
return (b.metadata?.sortingTimestamp ?? DateTime(0)).compareTo(
|
||||
a.metadata?.sortingTimestamp ?? DateTime(0),
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.toIList();
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider<SpacesController, IList<Space>>(
|
||||
static final provider = AsyncNotifierProvider<SpacesController, IList<Space>>(
|
||||
SpacesController.new,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import "package:nexus/models/spec_versions_response.dart";
|
|||
class SpecVersionsController extends AsyncNotifier<SpecVersionsResponse> {
|
||||
@override
|
||||
Future<SpecVersionsResponse> build() =>
|
||||
ref.watch(ClientController.provider.notifier).getSpecVersions();
|
||||
ref.read(ClientController.provider.notifier).getSpecVersions();
|
||||
|
||||
static final provider =
|
||||
AsyncNotifierProvider.autoDispose<
|
||||
|
|
|
|||
17
lib/controllers/timezones.dart
Normal file
17
lib/controllers/timezones.dart
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:timezone/data/latest_10y.dart";
|
||||
import "package:timezone/timezone.dart";
|
||||
|
||||
/// The names of all IANA timezones, e.g. `Europe/London`, alphabetically.
|
||||
class TimezonesController extends Notifier<IList<String>> {
|
||||
@override
|
||||
IList<String> build() {
|
||||
initializeTimeZones();
|
||||
return timeZoneDatabase.locations.keys.toIList().sort();
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider<TimezonesController, IList<String>>(
|
||||
TimezonesController.new,
|
||||
);
|
||||
}
|
||||
15
lib/controllers/typing.dart
Normal file
15
lib/controllers/typing.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/models/typing.dart";
|
||||
|
||||
class TypingController extends Notifier<IMap<String, IList<String>>> {
|
||||
@override
|
||||
IMap<String, IList<String>> build() => .new();
|
||||
|
||||
void set(Typing typing) => state = state.add(typing.roomId, typing.userIds);
|
||||
|
||||
static final provider =
|
||||
NotifierProvider<TypingController, IMap<String, IList<String>>>(
|
||||
TypingController.new,
|
||||
);
|
||||
}
|
||||
260
lib/controllers/unified_push.dart
Normal file
260
lib/controllers/unified_push.dart
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import "dart:async";
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:intl/intl.dart";
|
||||
import "package:nexus/controllers/selected_room.dart";
|
||||
import "package:nexus/controllers/notification.dart";
|
||||
import "package:nexus/controllers/push_key.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/gomuks_listener.dart";
|
||||
import "package:unifiedpush/unifiedpush.dart";
|
||||
import "package:unifiedpush_storage_shared_preferences/storage.dart";
|
||||
import "package:window_manager/window_manager.dart";
|
||||
|
||||
class UnifiedPushController extends AsyncNotifier<String?> {
|
||||
Completer<void>? _endpoint;
|
||||
|
||||
@override
|
||||
Future<String?> build() async {
|
||||
if (!Platform.isLinux && !Platform.isAndroid) return null;
|
||||
|
||||
final registered = await UnifiedPush.initialize(
|
||||
linuxOptions: .new(
|
||||
dbusName: "nexus.federated.nexus.UnifiedPush",
|
||||
storage: UnifiedPushStorageSharedPreferences(),
|
||||
background: isInBackground,
|
||||
shouldWriteService: false,
|
||||
),
|
||||
onNewEndpoint: (endpoint, instance) async {
|
||||
final task = Future(() async {
|
||||
final pushKey = endpoint.pubKeySet!.pubKey;
|
||||
await ref
|
||||
.read(PushKeyController.provider(instance).notifier)
|
||||
.set(pushKey);
|
||||
|
||||
await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.registerPusher(
|
||||
.new(
|
||||
appDisplayName: "Nexus",
|
||||
appId: "nexus.federated.nexus",
|
||||
data: .webPush(
|
||||
url: .parse(endpoint.url),
|
||||
auth: endpoint.pubKeySet!.auth,
|
||||
),
|
||||
deviceDisplayName:
|
||||
"Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
|
||||
kind: .webPush,
|
||||
lang: "en",
|
||||
pushKey: pushKey,
|
||||
),
|
||||
);
|
||||
|
||||
state = .data(await UnifiedPush.getDistributor());
|
||||
});
|
||||
|
||||
if (_endpoint case final completer? when !completer.isCompleted) {
|
||||
completer.complete(task);
|
||||
} else {
|
||||
await task;
|
||||
}
|
||||
},
|
||||
onRegistrationFailed: (reason, instance) {
|
||||
if (_endpoint case final completer? when !completer.isCompleted) {
|
||||
completer.completeError(reason);
|
||||
} else {
|
||||
throw reason;
|
||||
}
|
||||
},
|
||||
onMessage: (message, instance) async {
|
||||
debugPrint("UP message received for $instance");
|
||||
if (await deregisterIfDisabled(instance)) {
|
||||
if (isInBackground) exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.decrypted == false) {
|
||||
throw Exception(
|
||||
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
|
||||
);
|
||||
}
|
||||
final notification = await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.handlePush(json.decode(String.fromCharCodes(message.content)));
|
||||
|
||||
if (notification == null ||
|
||||
(!isInBackground &&
|
||||
(!Platform.isAndroid &&
|
||||
(WindowManager.instance.getCurrent()?.isFocused ?? true)) &&
|
||||
ref.read(SelectedRoomController.provider) ==
|
||||
notification.roomId)) {
|
||||
if (isInBackground) exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
|
||||
final icon = notification.roomAvatar == null
|
||||
? null
|
||||
: await client.downloadMedia(
|
||||
.new(mxc: notification.roomAvatar!, isAvatar: true),
|
||||
);
|
||||
|
||||
final pmp = notification.perMessageProfile;
|
||||
final senderAvatarUrl = pmp?.avatar ?? notification.sender.avatar;
|
||||
// Only Android can show the sender's avatar
|
||||
final senderAvatar = senderAvatarUrl == null || !Platform.isAndroid
|
||||
? null
|
||||
: await client.downloadMedia(
|
||||
.new(mxc: senderAvatarUrl, isAvatar: true),
|
||||
);
|
||||
|
||||
await ref
|
||||
.read(NotificationController.provider.notifier)
|
||||
.send(
|
||||
// Android stacks messages into one conversation per room
|
||||
id:
|
||||
(Platform.isAndroid
|
||||
? notification.roomId
|
||||
: notification.eventId)
|
||||
.hashCode &
|
||||
0x7fffffff,
|
||||
threadId: notification.roomId,
|
||||
title: notification.roomName,
|
||||
body: notification.text,
|
||||
payload: notification.eventRowId.toString(),
|
||||
timestamp: notification.timestamp,
|
||||
icon: icon,
|
||||
sound: notification.sound,
|
||||
message: (
|
||||
sender: pmp == null
|
||||
? notification.sender
|
||||
: pmp.copyWith(id: "${notification.sender.id}/${pmp.id}"),
|
||||
self: notification.self,
|
||||
senderAvatar: senderAvatar,
|
||||
isDm: notification.isDm,
|
||||
),
|
||||
);
|
||||
|
||||
if (isInBackground) exit(0);
|
||||
},
|
||||
onUnregistered: deregister,
|
||||
);
|
||||
|
||||
if (!registered || isInBackground) return null;
|
||||
|
||||
try {
|
||||
if (await deregisterIfDisabled()) return null;
|
||||
|
||||
return await register();
|
||||
} catch (error, stackTrace) {
|
||||
showError(error, stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers with [distributor], or the current/default distributor if none
|
||||
/// is given. Returns the distributor that was registered with.
|
||||
Future<String?> register([String? distributor]) async {
|
||||
if (distributor != null &&
|
||||
state.value != null &&
|
||||
state.value != distributor) {
|
||||
await deregister();
|
||||
}
|
||||
|
||||
state = .loading();
|
||||
try {
|
||||
final deviceId = await ref
|
||||
.read(GomuksListenerController.provider.notifier)
|
||||
.deviceId();
|
||||
|
||||
await Future(() async {
|
||||
final capabilities = await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.getCapabilities();
|
||||
|
||||
if (capabilities.webpush?.vapid == null) {
|
||||
throw UnsupportedError(
|
||||
"Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus.",
|
||||
);
|
||||
}
|
||||
|
||||
if (distributor != null) {
|
||||
await UnifiedPush.saveDistributor(distributor);
|
||||
} else if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) {
|
||||
throw Exception("No UnifiedPush distributors found.");
|
||||
}
|
||||
|
||||
final endpoint = _endpoint = Completer<void>();
|
||||
|
||||
try {
|
||||
await Future.wait([
|
||||
UnifiedPush.register(
|
||||
instance: deviceId,
|
||||
vapid: capabilities.webpush?.vapid,
|
||||
),
|
||||
endpoint.future,
|
||||
], eagerError: true);
|
||||
} finally {
|
||||
if (_endpoint == endpoint) _endpoint = null;
|
||||
}
|
||||
}).timeout(
|
||||
.new(seconds: 10),
|
||||
onTimeout: () => throw Exception("UnifiedPush registration timed out."),
|
||||
);
|
||||
|
||||
final current = await UnifiedPush.getDistributor();
|
||||
state = .data(current);
|
||||
return current;
|
||||
} catch (_) {
|
||||
state = .data(null);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> deregisterIfDisabled([String? notificationInstance]) async {
|
||||
if (await ref
|
||||
.read(NotificationController.provider.notifier)
|
||||
.permissionsGranted()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final instance =
|
||||
notificationInstance ??
|
||||
await ref.read(GomuksListenerController.provider.notifier).deviceId();
|
||||
final key = await ref.read(PushKeyController.provider(instance).future);
|
||||
if (key != null) await deregister(instance);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> deregister([String? instance]) async {
|
||||
instance ??= await ref
|
||||
.read(GomuksListenerController.provider.notifier)
|
||||
.deviceId();
|
||||
|
||||
final keyProvider = PushKeyController.provider(instance);
|
||||
final key = await ref.read(keyProvider.future);
|
||||
|
||||
if (key != null) {
|
||||
await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.deregisterPusher(.new(appId: "nexus.federated.nexus", pushKey: key));
|
||||
await ref.read(keyProvider.notifier).set(null);
|
||||
} else {
|
||||
debugPrint(
|
||||
"No matching pushKey found. Skipping deregistration from homeserver.",
|
||||
);
|
||||
}
|
||||
|
||||
await UnifiedPush.unregister(instance);
|
||||
state = .data(null);
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider<UnifiedPushController, String?>(
|
||||
UnifiedPushController.new,
|
||||
);
|
||||
}
|
||||
37
lib/controllers/unified_push_allowed.dart
Normal file
37
lib/controllers/unified_push_allowed.dart
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import "dart:io";
|
||||
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/unified_push_distributors.dart";
|
||||
|
||||
class UnifiedPushAllowedController extends AsyncNotifier<String?> {
|
||||
@override
|
||||
Future<String?> build() async {
|
||||
final distributors = await ref.watch(
|
||||
UnifiedPushDistributorsController.provider.future,
|
||||
);
|
||||
|
||||
if (distributors.isEmpty) {
|
||||
return "No valid distributors found. ${Platform.isLinux
|
||||
? "Try installing KUnifiedPush"
|
||||
: Platform.isAndroid
|
||||
? "Try installing Google Play Services or NTFY"
|
||||
: "Your platform is not currently supported by UnifiedPush"}.";
|
||||
}
|
||||
|
||||
final capabilities = await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.getCapabilities();
|
||||
|
||||
if (capabilities.webpush?.vapid == null) {
|
||||
return "Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static final provider =
|
||||
AsyncNotifierProvider.autoDispose<UnifiedPushAllowedController, String?>(
|
||||
UnifiedPushAllowedController.new,
|
||||
);
|
||||
}
|
||||
26
lib/controllers/unified_push_distributors.dart
Normal file
26
lib/controllers/unified_push_distributors.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import "dart:io";
|
||||
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:unifiedpush/unifiedpush.dart";
|
||||
|
||||
class UnifiedPushDistributorsController extends AsyncNotifier<IList<String>> {
|
||||
@override
|
||||
Future<IList<String>> build() async {
|
||||
if (!Platform.isLinux && !Platform.isAndroid) return .new();
|
||||
|
||||
return IList(
|
||||
await UnifiedPush.getDistributors().timeout(
|
||||
.new(seconds: 10),
|
||||
onTimeout: () =>
|
||||
throw Exception("UnifiedPush distributor check timed out."),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static final provider =
|
||||
AsyncNotifierProvider.autoDispose<
|
||||
UnifiedPushDistributorsController,
|
||||
IList<String>
|
||||
>(UnifiedPushDistributorsController.new);
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ class UrlPreviewController(final Uri url)
|
|||
|
||||
try {
|
||||
return await ref
|
||||
.watch(ClientController.provider.notifier)
|
||||
.read(ClientController.provider.notifier)
|
||||
.getUrlPreview(url);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(label: error.toString(), stackTrace: stackTrace);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/members.dart";
|
||||
|
|
|
|||
|
|
@ -1,53 +1,48 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
|
||||
class ViaController(final Room room) extends Notifier<String> {
|
||||
@override
|
||||
String build() {
|
||||
final servers = <String>{};
|
||||
final powerLevelUsers =
|
||||
switch (room.state[EventType.powerLevels.type]?[""]) {
|
||||
final rowId? => switch (room.events[rowId]?.content) {
|
||||
PowerLevelsContent(:final users) => users.keys.toIList(),
|
||||
_ => null,
|
||||
},
|
||||
null => null,
|
||||
} ??
|
||||
.new([]);
|
||||
|
||||
void addUserId(String? userId) {
|
||||
final server = userId?.split(":").lastOrNull;
|
||||
if (server != null) {
|
||||
servers.add(server);
|
||||
}
|
||||
}
|
||||
final joinedMembers =
|
||||
(room.state[EventType.membership.type]?.values ?? const [])
|
||||
.map(
|
||||
(rowId) => switch (room.events[rowId]) {
|
||||
Event(
|
||||
stateKey: final userId?,
|
||||
content: MembershipContent(status: .join),
|
||||
) =>
|
||||
userId,
|
||||
_ => null,
|
||||
},
|
||||
)
|
||||
.nonNulls
|
||||
.toIList();
|
||||
|
||||
addUserId(ref.watch(ClientStateController.provider)?.userId);
|
||||
|
||||
final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""];
|
||||
final powerLevels = powerLevelsEventId == null
|
||||
? null
|
||||
: room.events[powerLevelsEventId];
|
||||
|
||||
if (powerLevels?.content case PowerLevelsContent(:final users)) {
|
||||
for (final userId in users.keys) {
|
||||
addUserId(userId);
|
||||
if (servers.length >= 5) break;
|
||||
}
|
||||
}
|
||||
|
||||
final members = room.state[EventType.membership.type]?.values.toIList();
|
||||
for (var i = 0; servers.length < 5; i++) {
|
||||
final membershipEventId = members?.getOrNull(i);
|
||||
final member = membershipEventId == null
|
||||
? null
|
||||
: room.events[membershipEventId];
|
||||
|
||||
if (member?.content case MembershipContent(:final status)) {
|
||||
if (status == .join) {
|
||||
addUserId(member?.stateKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (members?.getOrNull(i) == null) break;
|
||||
}
|
||||
final servers = [?ref.watch(ClientStateController.provider)?.userId]
|
||||
.toIList()
|
||||
.addAll(powerLevelUsers)
|
||||
.addAll(joinedMembers)
|
||||
.map((userId) => userId.substring(userId.indexOf(":") + 1))
|
||||
.whereNoDuplicates()
|
||||
.take(5)
|
||||
.toISet();
|
||||
|
||||
return servers.isEmpty
|
||||
? ""
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ extension BetterWhen<T> on AsyncValue<T> {
|
|||
required Widget Function(T value) data,
|
||||
Widget Function() loading = Loading.new,
|
||||
bool skipLoadingOnRefresh = false,
|
||||
bool skipLoadingOnReload = false,
|
||||
}) => when(
|
||||
data: data,
|
||||
error: (error, stackTrace) => ErrorDialog(error, stackTrace),
|
||||
loading: loading,
|
||||
skipLoadingOnRefresh: skipLoadingOnRefresh,
|
||||
skipLoadingOnReload: skipLoadingOnReload,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
295
lib/helpers/extensions/build_event_options.dart
Normal file
295
lib/helpers/extensions/build_event_options.dart
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/pinned_ids.dart";
|
||||
import "package:nexus/controllers/power_level.dart";
|
||||
import "package:nexus/controllers/recent_emoji.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/room_chat.dart";
|
||||
import "package:nexus/controllers/via.dart";
|
||||
import "package:nexus/helpers/extensions/event_id_is_fake.dart";
|
||||
import "package:nexus/helpers/extensions/is_desktop.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/relation_type.dart";
|
||||
import "package:nexus/widgets/emoji_picker.dart";
|
||||
import "package:nexus/widgets/mxc_emoji.dart";
|
||||
import "package:nexus/main.dart";
|
||||
|
||||
extension BuildEventOptions on Event {
|
||||
IList<PopupMenuEntry> buildEventOptions({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required String roomId,
|
||||
required String userId,
|
||||
required void Function(int, RelationType) onRelation,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final danger = theme.colorScheme.error;
|
||||
final isDesktop = context.isDesktop;
|
||||
|
||||
final notifier = RoomChatController.provider((
|
||||
roomId: roomId,
|
||||
contextualEvent: null,
|
||||
)).notifier;
|
||||
|
||||
final isPinned = ref
|
||||
.read(PinnedIdsController.provider(roomId))
|
||||
.contains(eventId);
|
||||
|
||||
void showReasonSheet({
|
||||
required String title,
|
||||
required String description,
|
||||
required String action,
|
||||
required Future<void> Function(String reason) onConfirm,
|
||||
}) {
|
||||
context.showAdaptiveSheet(
|
||||
(context) => HookBuilder(
|
||||
builder: (context) {
|
||||
final reasonController = useTextEditingController();
|
||||
|
||||
return SheetLayout(
|
||||
title: Text(title),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final reason = reasonController.text;
|
||||
Navigator.of(context).pop();
|
||||
|
||||
onConfirm(reason).onError(showError);
|
||||
},
|
||||
child: Text(action),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
Text(description),
|
||||
TextField(
|
||||
controller: reasonController,
|
||||
textCapitalization: .sentences,
|
||||
decoration: .new(labelText: "Reason (optional)"),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return .new([
|
||||
if (ref.read(
|
||||
PowerLevelController.provider(
|
||||
.new(eventType: .reaction, roomId: roomId),
|
||||
),
|
||||
))
|
||||
PopupMenuItem(
|
||||
enabled: false,
|
||||
child: IconTheme(
|
||||
data: theme.iconTheme,
|
||||
child: Row(
|
||||
mainAxisAlignment: isDesktop ? .center : .spaceEvenly,
|
||||
children: [
|
||||
...{
|
||||
// Custom emoji are sent without their shortcode, as only
|
||||
// their mxc URL is recorded
|
||||
...ref
|
||||
.read(RecentEmojiController.provider)
|
||||
.map((entry) => entry.emoji),
|
||||
"👍",
|
||||
"🤣",
|
||||
"😭",
|
||||
"🤔",
|
||||
"❤️",
|
||||
}
|
||||
.take(isDesktop ? 3 : 5)
|
||||
.map(
|
||||
(emoji) => IconButton(
|
||||
icon: emoji.startsWith("mxc://")
|
||||
? SizedBox.square(
|
||||
dimension: isDesktop ? 18 : 24,
|
||||
child: MxcEmoji(Uri.parse(emoji)),
|
||||
)
|
||||
: Text(
|
||||
emoji,
|
||||
style: .new(fontSize: isDesktop ? null : 18),
|
||||
),
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await ref
|
||||
.read(notifier)
|
||||
.sendReaction(emoji, this)
|
||||
.onError(showError);
|
||||
|
||||
await ref
|
||||
.read(RecentEmojiController.provider.notifier)
|
||||
.add(emoji)
|
||||
.onError(showError);
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.emoji_emotions),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
context.showAdaptiveSheet(
|
||||
(context) => EmojiPicker(
|
||||
roomId: roomId,
|
||||
allowFreeText: true,
|
||||
onSelection: (emoji, {shortcode}) {
|
||||
Navigator.of(context).pop();
|
||||
ref
|
||||
.read(notifier)
|
||||
.sendReaction(emoji, this, shortcode: shortcode)
|
||||
.onError(showError);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (ref.read(
|
||||
PowerLevelController.provider(
|
||||
.new(eventType: .message, roomId: roomId),
|
||||
),
|
||||
))
|
||||
PopupMenuItem(
|
||||
onTap: () => onRelation(rowId, .reply),
|
||||
child: const ListTile(
|
||||
leading: Icon(Icons.reply),
|
||||
title: Text("Reply"),
|
||||
),
|
||||
),
|
||||
|
||||
if (content is MessageContent && sender == userId)
|
||||
PopupMenuItem(
|
||||
onTap: () => onRelation(rowId, .edit),
|
||||
child: const ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
|
||||
),
|
||||
|
||||
if (ref.read(
|
||||
PowerLevelController.provider(
|
||||
.state(eventType: .pinnedEvents, roomId: roomId),
|
||||
),
|
||||
))
|
||||
PopupMenuItem(
|
||||
onTap: () async {
|
||||
try {
|
||||
final pins = ref.read(
|
||||
PinnedIdsController.provider(roomId).notifier,
|
||||
);
|
||||
|
||||
if (isPinned) {
|
||||
await pins.removePin(eventId);
|
||||
} else {
|
||||
await pins.addPin(eventId);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
showError(error, stackTrace);
|
||||
}
|
||||
},
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.push_pin),
|
||||
title: Text(isPinned ? "Unpin Event" : "Pin Event"),
|
||||
),
|
||||
),
|
||||
|
||||
PopupMenuItem(
|
||||
onTap: () async {
|
||||
final room = ref.read(
|
||||
RoomsController.provider.select((rooms) => rooms[roomId]),
|
||||
);
|
||||
if (room == null) return;
|
||||
|
||||
final vias = ref.read(ViaController.provider(room));
|
||||
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text:
|
||||
"matrix:roomid/${room.metadata?.id.substring(1)}/e/${eventId.substring(1)}$vias",
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const ListTile(
|
||||
leading: Icon(Icons.link),
|
||||
title: Text("Copy Link"),
|
||||
),
|
||||
),
|
||||
|
||||
if (content case MessageContent(:final body?))
|
||||
PopupMenuItem(
|
||||
onTap: () => Clipboard.setData(ClipboardData(text: body)),
|
||||
child: const ListTile(
|
||||
leading: Icon(Icons.copy),
|
||||
title: Text("Copy Text"),
|
||||
),
|
||||
),
|
||||
|
||||
if (ref.read(
|
||||
PowerLevelController.provider(
|
||||
.redaction(targetUser: sender, roomId: roomId),
|
||||
),
|
||||
) &&
|
||||
!eventId.isFake)
|
||||
PopupMenuItem(
|
||||
onTap: () => showReasonSheet(
|
||||
title: "Delete Message",
|
||||
description:
|
||||
"Are you sure you want to delete this message? "
|
||||
"This cannot be reversed.",
|
||||
action: "Delete",
|
||||
onConfirm: (reason) =>
|
||||
ref.read(notifier).deleteMessage(this, reason: reason),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete, color: danger),
|
||||
title: Text("Delete", style: .new(color: danger)),
|
||||
),
|
||||
),
|
||||
|
||||
if (eventId.isFake)
|
||||
PopupMenuItem(
|
||||
onTap: () => ref.read(notifier).hideClientSticky(rowId),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete, color: danger),
|
||||
title: Text("Hide", style: .new(color: danger)),
|
||||
),
|
||||
),
|
||||
|
||||
PopupMenuItem(
|
||||
onTap: () => showReasonSheet(
|
||||
title: "Report",
|
||||
description:
|
||||
"Report this event to your server administrators, "
|
||||
"who can take action like banning this server or room.",
|
||||
action: "Report",
|
||||
onConfirm: (reason) => ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.reportEvent(
|
||||
.new(
|
||||
roomId: roomId,
|
||||
eventId: eventId,
|
||||
reason: reason.isEmpty ? null : reason,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.report, color: danger),
|
||||
title: Text("Report", style: .new(color: danger)),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
111
lib/helpers/extensions/create_room.dart
Normal file
111
lib/helpers/extensions/create_room.dart
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/sidebar_page.dart";
|
||||
import "package:nexus/controllers/spaces.dart";
|
||||
import "package:nexus/helpers/extensions/focus_room.dart";
|
||||
import "package:nexus/helpers/extensions/get_all_child_rooms.dart";
|
||||
import "package:nexus/helpers/extensions/with_progress_snackbar.dart";
|
||||
import "package:nexus/models/requests/create_room.dart";
|
||||
|
||||
extension CreateRoom on BuildContext {
|
||||
/// Creates a room, showing progress and errors in snackbars, and opens it
|
||||
/// once it has synced. [request] may be a future, so any lookups needed to
|
||||
/// build it happen while the progress snackbar is shown.
|
||||
///
|
||||
/// Returns the created room's ID, or null if creating it failed.
|
||||
Future<String?> createRoom(
|
||||
CreateRoomRequest request, {
|
||||
required String name,
|
||||
}) async {
|
||||
final container = ProviderScope.containerOf(this);
|
||||
final messenger = ScaffoldMessenger.of(this);
|
||||
|
||||
final result = await withProgressSnackbar("Creating $name...", () async {
|
||||
final roomId = await container
|
||||
.read(ClientController.provider.notifier)
|
||||
.createRoom(request);
|
||||
|
||||
// Wait for the room to show up in the room list (under DMs, if it's
|
||||
// one) before opening it.
|
||||
final synced = Completer<bool>();
|
||||
final subscription = container.listen(
|
||||
SpacesController.provider.select(
|
||||
(async) =>
|
||||
async.value?.any(
|
||||
(space) =>
|
||||
space.id == roomId ||
|
||||
((!request.isDirect || space.id == "dms") &&
|
||||
space.allChildRooms.any(
|
||||
(room) => room.metadata?.id == roomId,
|
||||
)),
|
||||
) ??
|
||||
false,
|
||||
),
|
||||
(_, found) {
|
||||
if (found && !synced.isCompleted) synced.complete(true);
|
||||
},
|
||||
fireImmediately: true,
|
||||
);
|
||||
|
||||
return (
|
||||
roomId,
|
||||
await synced.future
|
||||
.timeout(const .new(seconds: 15), onTimeout: () => false)
|
||||
.whenComplete(subscription.close),
|
||||
);
|
||||
});
|
||||
if (result == null) return null;
|
||||
|
||||
final (roomId, synced) = result;
|
||||
if (synced && await container.focusRoom(roomId)) {
|
||||
container.read(SidebarPageController.provider.notifier).set(.rooms);
|
||||
} else {
|
||||
messenger.showSnackBar(
|
||||
.new(
|
||||
content: Text("Created $name, but it hasn't finished syncing yet."),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return roomId;
|
||||
}
|
||||
|
||||
/// Opens the existing DM with [userId], or creates one if there isn't one.
|
||||
Future<void> startDirectMessage(
|
||||
String userId, {
|
||||
required String name,
|
||||
bool encrypted = true,
|
||||
String? inviteReason,
|
||||
}) async {
|
||||
final container = ProviderScope.containerOf(this);
|
||||
final existing = container
|
||||
.read(RoomsController.provider)
|
||||
.values
|
||||
.firstWhereOrNull((room) => room.metadata?.dmUserId == userId)
|
||||
?.metadata
|
||||
?.id;
|
||||
|
||||
if (existing != null) {
|
||||
if (await container.focusRoom(existing)) {
|
||||
container.read(SidebarPageController.provider.notifier).set(.rooms);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await createRoom(
|
||||
.new(
|
||||
isDirect: true,
|
||||
preset: .trustedPrivateChat,
|
||||
invite: .new([userId]),
|
||||
inviteReason: inviteReason,
|
||||
initialState: encrypted ? .new([.encryption]) : const .empty(),
|
||||
),
|
||||
name: "a DM with $name",
|
||||
);
|
||||
}
|
||||
}
|
||||
38
lib/helpers/extensions/displayname_color.dart
Normal file
38
lib/helpers/extensions/displayname_color.dart
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/room_power_levels.dart";
|
||||
import "package:nexus/controllers/settings.dart";
|
||||
import "package:nexus/helpers/extensions/string_to_color.dart";
|
||||
|
||||
extension DisplaynameColor on WidgetRef {
|
||||
/// The color of [userId]'s name in [roomId], from their power level
|
||||
/// (so everyone with the same level shares one) or their user ID, depending
|
||||
/// on the user's settings.
|
||||
Color displaynameColor(String roomId, String userId) => switch (watch(
|
||||
SettingsController.provider.select((settings) => settings.value?.nameColor),
|
||||
)) {
|
||||
.user => userId.colorHash,
|
||||
.powerLevel || null => powerLevelColor(
|
||||
watch(
|
||||
RoomPowerLevelsController.provider(roomId)
|
||||
.select((levels) => levels.levelOf(userId)),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
/// Like [displaynameColor], for when [userId]'s [level] is already known,
|
||||
/// like a level they're being given.
|
||||
Color displaynameColorAt(String userId, double level) => switch (watch(
|
||||
SettingsController.provider.select((settings) => settings.value?.nameColor),
|
||||
)) {
|
||||
.user => userId.colorHash,
|
||||
.powerLevel || null => powerLevelColor(level),
|
||||
};
|
||||
|
||||
static Color powerLevelColor(double level) => HSLColor.fromAHSL(
|
||||
1,
|
||||
level.isInfinite ? 30 : (210 + level / 50 * 137.50776) % 360,
|
||||
0.7,
|
||||
0.5,
|
||||
).toColor();
|
||||
}
|
||||
5
lib/helpers/extensions/distributor_name.dart
Normal file
5
lib/helpers/extensions/distributor_name.dart
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
extension DistributorName on String {
|
||||
String get distributorName => startsWith("nexus.federated.nexus")
|
||||
? "Google Play Services"
|
||||
: split(".").last;
|
||||
}
|
||||
3
lib/helpers/extensions/event_id_is_fake.dart
Normal file
3
lib/helpers/extensions/event_id_is_fake.dart
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
extension EventIdIsFake on String {
|
||||
bool get isFake => startsWith("~") || startsWith("\$gomuks");
|
||||
}
|
||||
46
lib/helpers/extensions/focus_room.dart
Normal file
46
lib/helpers/extensions/focus_room.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:flutter_riverpod/experimental/mutation.dart";
|
||||
import "package:nexus/controllers/jump_request.dart";
|
||||
import "package:nexus/controllers/key.dart";
|
||||
import "package:nexus/controllers/spaces.dart";
|
||||
import "package:nexus/models/space.dart";
|
||||
|
||||
extension FocusRoom on MutationTarget {
|
||||
Future<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;
|
||||
}
|
||||
}
|
||||
8
lib/helpers/extensions/get_all_child_rooms.dart
Normal file
8
lib/helpers/extensions/get_all_child_rooms.dart
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
import "package:nexus/models/space.dart";
|
||||
|
||||
extension GetAllChildRooms on Space {
|
||||
IList<Room> get allChildRooms =>
|
||||
children.addAll(subSpaces.expand((s) => s.children));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue