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
4
.github/workflows/android.yml
vendored
4
.github/workflows/android.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
||||||
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
|
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
|
||||||
|
|
||||||
- name: Build app
|
- name: Build app
|
||||||
run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64"
|
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:
|
env:
|
||||||
KEYSTORE_PATH: ../../keystore.jks
|
KEYSTORE_PATH: ../../keystore.jks
|
||||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
|
@ -36,4 +36,4 @@ jobs:
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: APK
|
name: APK
|
||||||
path: build/app/outputs/flutter-apk/app-release.apk
|
path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
|
||||||
|
|
|
||||||
36
.github/workflows/flatpak.yml
vendored
36
.github/workflows/flatpak.yml
vendored
|
|
@ -35,3 +35,39 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: flatpak-${{ matrix.arch }}
|
name: flatpak-${{ matrix.arch }}
|
||||||
path: result/nexus.federated.nexus.flatpak
|
path: result/nexus.federated.nexus.flatpak
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: build-flatpak
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- name: Install flatpak tooling
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y flatpak ostree
|
||||||
|
|
||||||
|
- name: Download bundles
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
pattern: flatpak-*
|
||||||
|
path: bundles
|
||||||
|
|
||||||
|
- name: Build OSTree repo
|
||||||
|
run: |
|
||||||
|
ostree init --repo=repo --mode=archive-z2
|
||||||
|
for bundle in bundles/*/nexus.federated.nexus.flatpak; do
|
||||||
|
flatpak build-import-bundle repo "$bundle"
|
||||||
|
done
|
||||||
|
flatpak build-update-repo --prune repo
|
||||||
|
|
||||||
|
- uses: actions/upload-pages-artifact@v4
|
||||||
|
with:
|
||||||
|
path: repo
|
||||||
|
|
||||||
|
- name: Deploy to Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
|
|
|
||||||
4
.github/workflows/ios.yml
vendored
4
.github/workflows/ios.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
- name: Set up Flutter
|
- name: Set up Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: 3.47.0
|
flutter-version: 3.47.4
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
|
|
@ -31,7 +31,7 @@ jobs:
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart scripts/generate.dart
|
dart scripts/generate.dart
|
||||||
flutter pub run build_runner build
|
flutter pub run build_runner build
|
||||||
flutter build ios --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
|
# Unsigned IPA: SideStore/AltStore re-sign this on-device with the
|
||||||
# user's own Apple ID at install time, so no certificate is needed here.
|
# user's own Apple ID at install time, so no certificate is needed here.
|
||||||
|
|
|
||||||
4
.github/workflows/macos.yml
vendored
4
.github/workflows/macos.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
- name: Set up Flutter
|
- name: Set up Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: 3.47.0
|
flutter-version: 3.47.4
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
|
|
@ -31,7 +31,7 @@ jobs:
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart scripts/generate.dart
|
dart scripts/generate.dart
|
||||||
flutter pub run build_runner build
|
flutter pub run build_runner build
|
||||||
flutter build macos --release
|
flutter build macos --dart-define=GIT_COMMIT=${{ github.sha }}
|
||||||
|
|
||||||
- name: Create DMG
|
- name: Create DMG
|
||||||
id: create-dmg
|
id: create-dmg
|
||||||
|
|
|
||||||
15
.github/workflows/windows.yml
vendored
15
.github/workflows/windows.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
- name: Set up Flutter
|
- name: Set up Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: 3.47.0
|
flutter-version: 3.47.4
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
|
|
@ -29,7 +29,7 @@ jobs:
|
||||||
- name: Setup MSYS2
|
- name: Setup MSYS2
|
||||||
uses: msys2/setup-msys2@v2
|
uses: msys2/setup-msys2@v2
|
||||||
with:
|
with:
|
||||||
msystem: MINGW64
|
msystem: CLANG64
|
||||||
install: >-
|
install: >-
|
||||||
mingw-w64-x86_64-gcc
|
mingw-w64-x86_64-gcc
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ jobs:
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart scripts/generate.dart
|
dart scripts/generate.dart
|
||||||
flutter pub run build_runner build
|
flutter pub run build_runner build
|
||||||
flutter build windows --release
|
flutter build windows --dart-define=GIT_COMMIT=${{ github.sha }}
|
||||||
|
|
||||||
- name: Copy MinGW runtime DLLs
|
- name: Copy MinGW runtime DLLs
|
||||||
shell: msys2 {0}
|
shell: msys2 {0}
|
||||||
|
|
@ -52,6 +52,15 @@ jobs:
|
||||||
cp /mingw64/bin/libwinpthread-1.dll build/windows/x64/runner/Release/
|
cp /mingw64/bin/libwinpthread-1.dll build/windows/x64/runner/Release/
|
||||||
cp /mingw64/bin/libstdc++-6.dll build/windows/x64/runner/Release/
|
cp /mingw64/bin/libstdc++-6.dll build/windows/x64/runner/Release/
|
||||||
|
|
||||||
|
- name: Copy MSVC runtime DLLs
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$release = "build/windows/x64/runner/Release"
|
||||||
|
|
||||||
|
Copy-Item C:\Windows\System32\MSVCP140.dll $release -Force
|
||||||
|
Copy-Item C:\Windows\System32\VCRUNTIME140.dll $release -Force
|
||||||
|
Copy-Item C:\Windows\System32\VCRUNTIME140_1.dll $release -Force
|
||||||
|
|
||||||
- name: Install Inno Setup
|
- name: Install Inno Setup
|
||||||
run: choco install innosetup -y
|
run: choco install innosetup -y
|
||||||
|
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -42,3 +42,5 @@ password.txt
|
||||||
|
|
||||||
# Nix
|
# Nix
|
||||||
/result
|
/result
|
||||||
|
# Widget Preview related
|
||||||
|
.widget_preview/
|
||||||
|
|
|
||||||
1
.gitmodules
vendored
1
.gitmodules
vendored
|
|
@ -1,4 +1,3 @@
|
||||||
[submodule "gomuks"]
|
[submodule "gomuks"]
|
||||||
path = gomuks
|
path = gomuks
|
||||||
url = https://github.com/gomuks/gomuks
|
url = https://github.com/gomuks/gomuks
|
||||||
branch = main
|
|
||||||
|
|
|
||||||
17
.vscode/launch.json
vendored
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",
|
"msgtype",
|
||||||
"muks",
|
"muks",
|
||||||
"prefs",
|
"prefs",
|
||||||
|
"recents",
|
||||||
"unban",
|
"unban",
|
||||||
"unredact"
|
"unifiedpush",
|
||||||
|
"unredact",
|
||||||
|
"webpush"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,14 @@ See [Effective Dart: Style](https://dart.dev/effective-dart/style) for general r
|
||||||
Controllers live in `lib/controllers/` and provide a source that exposes data and logic via Riverpod providers, allowing other parts of the code to watch state changes with ref.watch (`ref.watch(MyController.provider)`), access the current value with ref.read (`ref.read(MyController.provider)`), and run helper methods on those classes using the notifier:
|
Controllers live in `lib/controllers/` and provide a source that exposes data and logic via Riverpod providers, allowing other parts of the code to watch state changes with ref.watch (`ref.watch(MyController.provider)`), access the current value with ref.read (`ref.read(MyController.provider)`), and run helper methods on those classes using the notifier:
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
ref.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.
|
We use an object oriented style for controllers, where `provider` is a static member on the controller class. E.g.
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
class MyController extends AsyncNotifier<DataThisControllerExposes> {
|
class MyController(final SomeInputType input)
|
||||||
final SomeInputType input;
|
extends AsyncNotifier<DataThisControllerExposes> {
|
||||||
MyController(this.input);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<DataThisControllerExposes> build() async {
|
Future<DataThisControllerExposes> build() async {
|
||||||
return input.foo;
|
return input.foo;
|
||||||
|
|
@ -38,11 +36,17 @@ class MyController extends AsyncNotifier<DataThisControllerExposes> {
|
||||||
|
|
||||||
static final provider =
|
static final provider =
|
||||||
AsyncNotifierProvider.family<MyController, DataThisControllerExposes, SomeInputType>(
|
AsyncNotifierProvider.family<MyController, DataThisControllerExposes, SomeInputType>(
|
||||||
AuthorController.new,
|
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/`.
|
Providers which are not controllers, e.g. they expose no data, only methods, should instead live in `lib/helpers/`. For an example, see `lib/helpers/launch_helper.dart`. Other, non-provider helpers, like extensions or helper methods can also go in `lib/helpers/`.
|
||||||
|
|
||||||
### Don't use StatefulWidgets ([Flutter Hooks](https://pub.dev/packages/flutter_hooks))
|
### Don't use StatefulWidgets ([Flutter Hooks](https://pub.dev/packages/flutter_hooks))
|
||||||
|
|
@ -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.
|
When possible, we prefer not to create global variables or methods. You can usually replace a global variable with a Riverpod controller, and a global method with an extension method.
|
||||||
|
|
||||||
|
I'm also not a big fan of unneeded private methods or variables, but this is contextual.
|
||||||
|
|
||||||
|
### Modern Dart syntax
|
||||||
|
|
||||||
|
We try to use newer Dart language features where they make code shorter:
|
||||||
|
|
||||||
|
- Primary constructors for classes, including widgets, e.g. `class const MemberList(final String roomId, {super.key}) extends HookConsumerWidget`.
|
||||||
|
- Dot shorthands wherever the type can be inferred, e.g. `.new(...)`, `padding: .all(8)`, `relationType == .reply`.
|
||||||
|
- Patterns and switch expressions over null checks and casts, e.g. `if (event.content case MessageContent(:final body?))` rather than checking `is` then using `!`.
|
||||||
|
|
||||||
|
### Prefer `map` over inline `for` loops
|
||||||
|
|
||||||
|
When building a list (or set, or map) from another collection, use `.map`, `.expand` and friends rather than a `for` inside the collection literal:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Prefer
|
||||||
|
children: rooms.map((room) => RoomTile(room)).toList(),
|
||||||
|
|
||||||
|
// Over
|
||||||
|
children: [for (final room in rooms) RoomTile(room)],
|
||||||
|
```
|
||||||
|
|
||||||
|
Regular `for` statements are fine for logic that doesn't just build a collection.
|
||||||
|
|
||||||
|
### Async values and errors
|
||||||
|
|
||||||
|
When rendering an `AsyncValue`, use the `betterWhen` extension, which shows our standard loading and error widgets, or a switch expression if you need more control (e.g. `AsyncData(:final value) || AsyncLoading(:final value?)` to keep showing old data while reloading).
|
||||||
|
|
||||||
|
For futures started from the UI that nothing awaits, like button presses, add `.onError(showError)` so failures are shown to the user instead of being lost.
|
||||||
|
|
||||||
## LLM/AI Assisted Contributions
|
## LLM/AI Assisted Contributions
|
||||||
|
|
||||||
LLM generated code is NOT allowed. All contributions should be written by humans. Using LLMs for interacting with others, e.g. for Comments, PRs, etc, is also not allowed.
|
LLM generated code is NOT allowed. All contributions should be written by humans. Using LLMs for interacting with others, e.g. for Comments, PRs, etc, is also not allowed.
|
||||||
|
|
|
||||||
159
README.md
159
README.md
|
|
@ -1,8 +1,5 @@
|
||||||
# Nexus Client
|
# Nexus Client
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> Nexus Client is still in development, and doesn't support everything needed for daily use.
|
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
|
A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
|
||||||
|
|
@ -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
|
## 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)
|
- [Android APK](https://nightly.link/Henry-Hiles/nexus/workflows/android/main/APK.zip)
|
||||||
- [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip)
|
|
||||||
- [Unsigned iOS IPA](https://nightly.link/Henry-Hiles/nexus/workflows/ios/main/Nexus.ipa.zip)
|
- [Unsigned iOS IPA](https://nightly.link/Henry-Hiles/nexus/workflows/ios/main/Nexus.ipa.zip)
|
||||||
- [Unsigned MacOS DMG](https://nightly.link/Henry-Hiles/nexus/workflows/macos/main/nexus.dmg.zip)
|
- [Unsigned MacOS DMG](https://nightly.link/Henry-Hiles/nexus/workflows/macos/main/nexus.dmg.zip)
|
||||||
- [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip)
|
- [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip)
|
||||||
- Flatpak
|
|
||||||
- [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip)
|
Or, use one of the below methods:
|
||||||
- [x86_64/AMD64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-x86_64.zip)
|
|
||||||
|
### Nightly Flatpak Repo
|
||||||
|
|
||||||
|
Make sure Flatpak is set up on your system, then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak install --user https://nexus.federated.nexus/flatpak/nexus.flatpakref
|
||||||
|
```
|
||||||
|
|
||||||
|
This adds the Nexus remote and installs the app for your user only, so no root access is needed (x86_64 and aarch64 are supported). To install system-wide instead, use `--system` in place of `--user`. Updates arrive through `flatpak update` or your software center.
|
||||||
|
|
||||||
|
### NixOS Module
|
||||||
|
|
||||||
|
Add Nexus as a flake input, following `nixpkgs` for less fetches:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
nexus = {
|
||||||
|
url = "git+https://git.federated.nexus/Nexus/nexus";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This tracks the latest commit on `main`. To stay on a release instead, pin the input to its tag:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0";
|
||||||
|
```
|
||||||
|
|
||||||
|
Then import the module and enable it in your NixOS configuration:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
{ inputs, ... }:
|
||||||
|
{
|
||||||
|
imports = [ inputs.nexus.nixosModules.default ];
|
||||||
|
|
||||||
|
programs.nexus = {
|
||||||
|
enable = true;
|
||||||
|
enableNotifications = true; # optional: push notifications via UnifiedPush
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs Nexus system-wide. With `enableNotifications`, it also installs KUnifiedPush and registers its systemd and D-Bus services so notifications work. The `package` option lets you override the package if you need to.
|
||||||
|
|
||||||
## Build it yourself
|
## Build it yourself
|
||||||
|
|
||||||
|
|
@ -129,7 +74,7 @@ If you want to try out Nexus, grab one of the following artifacts from CI:
|
||||||
|
|
||||||
#### Linux
|
#### 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.
|
- On other distros: Install Flutter, Go, Git, Libclang, Libass, MPV, and Glibc. Do not use any Snap packages, they cause various compilation issues.
|
||||||
|
|
||||||
#### Windows
|
#### Windows
|
||||||
|
|
@ -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.
|
Join the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client.
|
||||||
|
|
||||||
# Credits
|
## Credits
|
||||||
|
|
||||||
Thank you Hylke Bons (https://planetpeanut.studio) for making the amazing icon for Nexus!
|
Thank you Hylke Bons (https://planetpeanut.studio) for making the amazing icon for Nexus!
|
||||||
Thank you Tulir Asokan for making [Gomuks](https://github.com/gomuks/gomuks), and helping us integrate it into Nexus!
|
Thank you Tulir Asokan for making [Gomuks](https://github.com/gomuks/gomuks), and helping us integrate it into Nexus!
|
||||||
|
|
|
||||||
|
|
@ -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:
|
errors:
|
||||||
invalid_annotation_target: ignore
|
invalid_annotation_target: ignore
|
||||||
avoid_print: ignore
|
avoid_print: ignore
|
||||||
annotate_overrides: ignore
|
|
||||||
exclude:
|
exclude:
|
||||||
- "build/**"
|
- "build/**"
|
||||||
- "**/*.g.dart"
|
- "**/*.g.dart"
|
||||||
|
|
|
||||||
|
|
@ -20,16 +20,21 @@ android {
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
targetCompatibility = JavaVersion.VERSION_21
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "nexus.federated.nexus"
|
applicationId = "nexus.federated.nexus"
|
||||||
|
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|
||||||
|
multiDexEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
@ -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 {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="29" />
|
android:maxSdkVersion="29" />
|
||||||
|
<!-- Until https://github.com/flutter/flutter/issues/145290 is fixed, we need enableOnBackInvokedCallback as false -->
|
||||||
<application
|
<application
|
||||||
|
android:enableOnBackInvokedCallback="false"
|
||||||
android:label="Nexus"
|
android:label="Nexus"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
|
@ -49,6 +51,16 @@
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
<!-- Needed by pasteboard, for pasting files from the clipboard -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.provider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/provider_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|
|
||||||
|
|
@ -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
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.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"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785627969,
|
"lastModified": 1788450739,
|
||||||
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
|
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
|
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -42,11 +42,11 @@
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774860670,
|
"lastModified": 1791751882,
|
||||||
"narHash": "sha256-YjJkQrvxrErXtfDi3obUn6rNmkA+CIAZ3f5NgL5xuYE=",
|
"narHash": "sha256-DWcMlQFQMEqQGAHU6rAbl4uKWmlJMR1HZG0BvH0H2fk=",
|
||||||
"owner": "neobrain",
|
"owner": "neobrain",
|
||||||
"repo": "nix2flatpak",
|
"repo": "nix2flatpak",
|
||||||
"rev": "61d68e21e3fbc2d57590051f48736bea271f4aba",
|
"rev": "7731e8b3fca73879f8217de5a919c0a5d17303de",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -73,11 +73,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs-lib": {
|
"nixpkgs-lib": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785031560,
|
"lastModified": 1788057806,
|
||||||
"narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=",
|
"narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixpkgs.lib",
|
"repo": "nixpkgs.lib",
|
||||||
"rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c",
|
"rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -88,11 +88,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1786862985,
|
"lastModified": 1790689126,
|
||||||
"narHash": "sha256-FBJRXmbGXiSUDvYEbfLYRkckayyZ6SK1UEqhCrIZ2Cs=",
|
"narHash": "sha256-ilerN1WLSvF+HMjziC/Wv99J5y02maDH+6hZPwsORKg=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "e5bdc4a41d4c072fe1e3787eaa0320a384741d44",
|
"rev": "b4fd65b198c599cbe814fcb9f42d25d021595ec9",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
52
flake.nix
52
flake.nix
|
|
@ -16,62 +16,12 @@
|
||||||
...
|
...
|
||||||
}@inputs:
|
}@inputs:
|
||||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
|
imports = [ ./linux/nix ];
|
||||||
systems = [
|
systems = [
|
||||||
"x86_64-linux"
|
"x86_64-linux"
|
||||||
"aarch64-linux"
|
"aarch64-linux"
|
||||||
"aarch64-darwin"
|
"aarch64-darwin"
|
||||||
"x86_64-darwin"
|
"x86_64-darwin"
|
||||||
];
|
];
|
||||||
|
|
||||||
perSystem =
|
|
||||||
{
|
|
||||||
lib,
|
|
||||||
pkgs,
|
|
||||||
system,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
|
|
||||||
{
|
|
||||||
_module.args.pkgs = import nixpkgs {
|
|
||||||
inherit system;
|
|
||||||
config = {
|
|
||||||
android_sdk.accept_license = true;
|
|
||||||
allowUnfree = true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
packages =
|
|
||||||
let
|
|
||||||
default = pkgs.callPackage ./linux/nix/pkg {
|
|
||||||
src = self;
|
|
||||||
};
|
|
||||||
in
|
|
||||||
{
|
|
||||||
inherit default;
|
|
||||||
|
|
||||||
flatpak = inputs.nix2flatpak.lib.${system}.mkFlatpak {
|
|
||||||
appName = "Nexus";
|
|
||||||
developer = "QuadRadical";
|
|
||||||
appId = "nexus.federated.nexus";
|
|
||||||
package = default;
|
|
||||||
runtime = "org.gnome.Platform/49";
|
|
||||||
permissions = {
|
|
||||||
share = [ "network" ];
|
|
||||||
sockets = [
|
|
||||||
"pulseaudio"
|
|
||||||
"fallback-x11"
|
|
||||||
"wayland"
|
|
||||||
];
|
|
||||||
devices = [ "dri" ];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
gomuks = pkgs.callPackage ./linux/nix/pkg/gomuks.nix {
|
|
||||||
src = self;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
devShells.default = pkgs.callPackage ./linux/nix/devshell.nix { };
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
gomuks
2
gomuks
|
|
@ -1 +1 @@
|
||||||
Subproject commit 0e06c7b74f96d3e29a8101893ca1921d13a90573
|
Subproject commit 53cae0e0334aef5ac376ea84f19ba1cff8a92310
|
||||||
|
|
@ -122,7 +122,7 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
|
||||||
"sqlite_fts5",
|
"sqlite_fts5",
|
||||||
"goolm",
|
"goolm",
|
||||||
// goheif/dav1d is not supported on Android or iOS, would need to be fixed upstream
|
// goheif/dav1d is not supported on Android or iOS, would need to be fixed upstream
|
||||||
if (targetOS == OS.android || targetOS == OS.iOS) "noheic",
|
"noheic",
|
||||||
].join(",");
|
].join(",");
|
||||||
|
|
||||||
final archiveFile = targetOS == OS.iOS
|
final archiveFile = targetOS == OS.iOS
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import UIKit
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter/foundation.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/models/account_data.dart";
|
import "package:nexus/models/account_data.dart";
|
||||||
|
|
||||||
|
|
@ -9,11 +10,16 @@ class AccountDataController extends Notifier<AccountData> {
|
||||||
AccountData build() => .new();
|
AccountData build() => .new();
|
||||||
|
|
||||||
void update(IMap<String, IMap<String, dynamic>> newAccountData) =>
|
void update(IMap<String, IMap<String, dynamic>> newAccountData) =>
|
||||||
state = .fromJson({
|
state = newAccountData.entries.fold(state, (data, entry) {
|
||||||
...json.decode(json.encode(state.toJson())),
|
try {
|
||||||
...newAccountData
|
return .fromJson({
|
||||||
.map((key, value) => MapEntry(key, value["content"]))
|
...json.decode(json.encode(data.toJson())),
|
||||||
.unlock,
|
entry.key: entry.value["content"],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint("Ignoring invalid ${entry.key} account data: $error");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
static final provider = NotifierProvider<AccountDataController, AccountData>(
|
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
|
final content = await ref
|
||||||
.watch(ClientController.provider.notifier)
|
.read(ClientController.provider.notifier)
|
||||||
.uploadMedia(.new(path: file.path, encrypt: isEncrypted));
|
.uploadMedia(.new(path: file.path, encrypt: isEncrypted));
|
||||||
|
|
||||||
state = (filename, content);
|
state = (filename, content);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ class AuthUrlController(final Uri homeserver)
|
||||||
extends AsyncNotifier<OAuthAuthCodeResponse> {
|
extends AsyncNotifier<OAuthAuthCodeResponse> {
|
||||||
@override
|
@override
|
||||||
Future<OAuthAuthCodeResponse> build() async => ref
|
Future<OAuthAuthCodeResponse> build() async => ref
|
||||||
.watch(ClientController.provider.notifier)
|
.read(ClientController.provider.notifier)
|
||||||
.getAuthUrl(
|
.getAuthUrl(
|
||||||
.new(
|
.new(
|
||||||
homeserverUrl: homeserver,
|
homeserverUrl: homeserver,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import "dart:async";
|
||||||
import "dart:ffi";
|
import "dart:ffi";
|
||||||
import "dart:io";
|
import "dart:io";
|
||||||
import "dart:isolate";
|
import "dart:isolate";
|
||||||
|
|
@ -5,33 +6,44 @@ import "dart:math";
|
||||||
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:ffi/ffi.dart";
|
import "package:ffi/ffi.dart";
|
||||||
import "package:flutter/foundation.dart";
|
import "package:flutter/cupertino.dart";
|
||||||
import "package:nexus/controllers/account_data.dart";
|
import "package:intl/intl.dart";
|
||||||
import "package:nexus/controllers/client_state.dart";
|
import "package:nexus/controllers/settings.dart";
|
||||||
import "package:nexus/controllers/init_complete.dart";
|
|
||||||
import "package:nexus/controllers/rooms.dart";
|
|
||||||
import "package:nexus/controllers/space_edges.dart";
|
|
||||||
import "package:nexus/controllers/sync_status.dart";
|
|
||||||
import "package:nexus/controllers/top_level_spaces.dart";
|
|
||||||
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/models/capabilities.dart";
|
||||||
import "package:nexus/models/content/message.dart";
|
import "package:nexus/models/content/message.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
|
import "package:nexus/models/event_context.dart";
|
||||||
|
import "package:nexus/models/mentions_response.dart";
|
||||||
|
import "package:nexus/models/gomuks_config.dart";
|
||||||
|
import "package:nexus/models/gomuks_exception.dart";
|
||||||
import "package:nexus/models/oauth_auth_code_response.dart";
|
import "package:nexus/models/oauth_auth_code_response.dart";
|
||||||
import "package:nexus/models/open_graph_data.dart";
|
import "package:nexus/models/open_graph_data.dart";
|
||||||
import "package:nexus/models/paginate.dart";
|
import "package:nexus/models/paginate.dart";
|
||||||
|
import "package:nexus/models/paginate_manual.dart";
|
||||||
|
import "package:nexus/models/requests/create_room.dart";
|
||||||
|
import "package:nexus/models/requests/deregister_pusher.dart";
|
||||||
import "package:nexus/models/requests/download_media.dart";
|
import "package:nexus/models/requests/download_media.dart";
|
||||||
import "package:nexus/models/requests/get_event.dart";
|
import "package:nexus/models/requests/get_event.dart";
|
||||||
|
import "package:nexus/models/requests/get_event_context.dart";
|
||||||
|
import "package:nexus/models/requests/get_mentions.dart";
|
||||||
import "package:nexus/models/requests/get_related_events.dart";
|
import "package:nexus/models/requests/get_related_events.dart";
|
||||||
import "package:nexus/models/requests/get_room_state.dart";
|
import "package:nexus/models/requests/get_room_state.dart";
|
||||||
|
import "package:nexus/models/requests/get_specific_room_state.dart";
|
||||||
|
import "package:nexus/models/requests/get_space_hierarchy.dart";
|
||||||
import "package:nexus/models/requests/join_room.dart";
|
import "package:nexus/models/requests/join_room.dart";
|
||||||
import "package:nexus/models/profile_response.dart";
|
import "package:nexus/models/profile_response.dart";
|
||||||
|
import "package:nexus/models/push_notification.dart";
|
||||||
|
import "package:nexus/models/recovery_key.dart";
|
||||||
import "package:nexus/models/requests/oauth/exchange_token.dart";
|
import "package:nexus/models/requests/oauth/exchange_token.dart";
|
||||||
import "package:nexus/models/requests/oauth/get_auth_url.dart";
|
import "package:nexus/models/requests/oauth/get_auth_url.dart";
|
||||||
import "package:nexus/models/requests/oauth/register_client.dart";
|
import "package:nexus/models/requests/oauth/register_client.dart";
|
||||||
import "package:nexus/models/requests/paginate.dart";
|
import "package:nexus/models/requests/paginate.dart";
|
||||||
|
import "package:nexus/models/requests/paginate_manual.dart";
|
||||||
import "package:nexus/models/requests/redact_event.dart";
|
import "package:nexus/models/requests/redact_event.dart";
|
||||||
|
import "package:nexus/models/requests/register_pusher.dart";
|
||||||
import "package:nexus/models/requests/report.dart";
|
import "package:nexus/models/requests/report.dart";
|
||||||
|
import "package:nexus/models/requests/search.dart";
|
||||||
import "package:nexus/models/requests/send_event.dart";
|
import "package:nexus/models/requests/send_event.dart";
|
||||||
import "package:nexus/models/requests/send_message.dart";
|
import "package:nexus/models/requests/send_message.dart";
|
||||||
import "package:nexus/models/requests/set_account_data.dart";
|
import "package:nexus/models/requests/set_account_data.dart";
|
||||||
|
|
@ -40,8 +52,8 @@ import "package:nexus/models/requests/set_state.dart";
|
||||||
import "package:nexus/models/requests/upload_media.dart";
|
import "package:nexus/models/requests/upload_media.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
import "package:nexus/models/room_summary.dart";
|
import "package:nexus/models/room_summary.dart";
|
||||||
|
import "package:nexus/models/space_hierarchy.dart";
|
||||||
import "package:nexus/models/spec_versions_response.dart";
|
import "package:nexus/models/spec_versions_response.dart";
|
||||||
import "package:nexus/models/sync_data.dart";
|
|
||||||
import "package:nexus/src/third_party/gomuks.g.dart";
|
import "package:nexus/src/third_party/gomuks.g.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:path_provider/path_provider.dart";
|
import "package:path_provider/path_provider.dart";
|
||||||
|
|
@ -49,134 +61,115 @@ import "package:path_provider/path_provider.dart";
|
||||||
class ClientController extends AsyncNotifier<int> {
|
class ClientController extends AsyncNotifier<int> {
|
||||||
@override
|
@override
|
||||||
Future<int> build() async {
|
Future<int> build() async {
|
||||||
final Pointer<Char> root;
|
debugPrint("Setting Gomuks env...");
|
||||||
if (Platform.isAndroid || Platform.isIOS) {
|
if (Platform.isAndroid || Platform.isIOS) {
|
||||||
final dir = await getApplicationSupportDirectory();
|
final env = {
|
||||||
root = "${dir.path}/gomuks".toNativeUtf8().cast();
|
"GOMUKS_ROOT": (await getApplicationSupportDirectory()).path,
|
||||||
} else {
|
"GOMUKS_CACHE_HOME": (await getApplicationCacheDirectory()).path,
|
||||||
root = nullptr.cast();
|
};
|
||||||
}
|
for (final MapEntry(:key, :value) in env.entries) {
|
||||||
|
final keyPtr = key.toNativeUtf8().cast<Char>();
|
||||||
final handle = GomuksInit(root);
|
final valuePtr = value.toNativeUtf8().cast<Char>();
|
||||||
|
|
||||||
final callable =
|
|
||||||
NativeCallable<
|
|
||||||
Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer)
|
|
||||||
>.listener((
|
|
||||||
Pointer<Char> command,
|
|
||||||
int requestId,
|
|
||||||
GomuksOwnedBuffer data,
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
final muksEventType = command.cast<Utf8>().toDartString();
|
GomuksSetEnv(keyPtr, valuePtr);
|
||||||
debugPrint("Handling $muksEventType...");
|
} finally {
|
||||||
final decodedMuksEvent = data.toJson();
|
calloc
|
||||||
|
..free(keyPtr)
|
||||||
switch (muksEventType) {
|
..free(valuePtr);
|
||||||
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
|
debugPrint("Initializing Gomuks...");
|
||||||
.watch(roomProvider.notifier)
|
final handle = await Isolate.run(() {
|
||||||
.update(syncData.rooms, syncData.leftRooms);
|
final bufferPointer = GomuksConfig(
|
||||||
ref
|
matrix: .new(
|
||||||
.watch(accountDataProvider.notifier)
|
initialDeviceDisplayName:
|
||||||
.update(syncData.accountData);
|
"Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
|
||||||
|
),
|
||||||
|
).toJson().toGomuksBufferPtr();
|
||||||
|
|
||||||
if (syncData.topLevelSpaces != null) {
|
try {
|
||||||
ref
|
return GomuksInit(bufferPointer.ref);
|
||||||
.watch(TopLevelSpacesController.provider.notifier)
|
} finally {
|
||||||
.set(syncData.topLevelSpaces!);
|
calloc
|
||||||
}
|
..free(bufferPointer.ref.base)
|
||||||
|
..free(bufferPointer);
|
||||||
if (syncData.spaceEdges != null) {
|
|
||||||
ref
|
|
||||||
.watch(SpaceEdgesController.provider.notifier)
|
|
||||||
.set(syncData.spaceEdges!);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref
|
|
||||||
// .watch(SyncStatusController.provider.notifier)
|
|
||||||
// .set(SyncStatus.fromJson(decodedMuksEvent));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
debugPrint("Unhandled event: $muksEventType");
|
|
||||||
}
|
|
||||||
debugPrint("Finished handling $muksEventType...");
|
|
||||||
} catch (error, stackTrace) {
|
|
||||||
if (kDebugMode) {
|
|
||||||
debugPrintStack(stackTrace: stackTrace, label: error.toString());
|
|
||||||
rethrow;
|
|
||||||
} else {
|
|
||||||
showError(error, stackTrace);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!ref.mounted) {
|
||||||
|
GomuksDestroy(handle);
|
||||||
|
return Completer<int>().future;
|
||||||
|
}
|
||||||
|
|
||||||
ref.onDispose(() => GomuksDestroy(handle));
|
ref.onDispose(() => GomuksDestroy(handle));
|
||||||
ref.onDispose(callable.close);
|
|
||||||
|
|
||||||
final errorCode = GomuksStart(handle, callable.nativeFunction);
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
if (errorCode == 0) return handle;
|
/// Calls gomuks with [data] as JSON, returning its decoded response.
|
||||||
throw Exception("GomuksStart returned error code $errorCode");
|
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(
|
Future<dynamic> _sendCommand(
|
||||||
String command, [
|
String command, [
|
||||||
Map<String, dynamic> data = const {},
|
Map<String, dynamic> data = const {},
|
||||||
]) async {
|
]) => callGomuksMethod(data, (handle, data, callback) {
|
||||||
final bufferPointer = data.toGomuksBufferPtr();
|
final commandPointer = command.toNativeUtf8().cast<Char>();
|
||||||
final handle = await future;
|
try {
|
||||||
final response = await Isolate.run(
|
GomuksSubmitCommandAsync(handle, commandPointer, data, callback);
|
||||||
() => GomuksSubmitCommand(
|
} finally {
|
||||||
handle,
|
// gomuks copies the command name before returning
|
||||||
command.toNativeUtf8().cast<Char>(),
|
calloc.free(commandPointer);
|
||||||
bufferPointer.ref,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
calloc.free(bufferPointer);
|
|
||||||
|
|
||||||
final json = response.buf.toJson();
|
|
||||||
if (response.command.cast<Utf8>().toDartString() == "error") {
|
|
||||||
throw json;
|
|
||||||
}
|
|
||||||
|
|
||||||
return json;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Future<void> redactEvent(RedactEventRequest report) =>
|
Future<void> redactEvent(RedactEventRequest report) =>
|
||||||
_sendCommand("redact_event", report.toJson());
|
_sendCommand("redact_event", report.toJson());
|
||||||
|
|
@ -188,11 +181,12 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
final json = request.toJson();
|
final json = request.toJson();
|
||||||
final content = request.content.toJson();
|
final content = request.content.toJson();
|
||||||
|
|
||||||
return Event.fromJson(
|
return .fromJson(
|
||||||
await _sendCommand("send_event", {
|
await _sendCommand("send_event", {
|
||||||
...json,
|
...json,
|
||||||
"content": {
|
"content": {
|
||||||
...content,
|
...content,
|
||||||
|
if (request.relatesTo != null)
|
||||||
"m.relates_to": {
|
"m.relates_to": {
|
||||||
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
|
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
|
||||||
"event_id": request.relatesTo,
|
"event_id": request.relatesTo,
|
||||||
|
|
@ -206,30 +200,54 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<String?> setState(SetStateRequest request) async =>
|
Future<String?> setState(SetStateRequest request) async =>
|
||||||
await _sendCommand("set_state", request.toJson());
|
await _sendCommand("set_state", request.toJson());
|
||||||
|
|
||||||
Future<String?> verify(String recoveryKey) async {
|
Future<void> verify(String recoveryKey) =>
|
||||||
try {
|
_sendCommand("verify", {"recovery_key": recoveryKey});
|
||||||
await _sendCommand("verify", {"recovery_key": recoveryKey});
|
|
||||||
return null;
|
Future<RecoveryKey> generateRecoveryKey(String passphrase) async => .fromJson(
|
||||||
} catch (error) {
|
await _sendCommand("generate_recovery_key", {"passphrase": passphrase}),
|
||||||
return error.toString();
|
);
|
||||||
|
|
||||||
|
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 =>
|
Future<String> joinRoom(JoinRoomRequest request) async =>
|
||||||
(await _sendCommand("join_room", request.toJson()))["room_id"];
|
(await _sendCommand("join_room", request.toJson()))["room_id"];
|
||||||
|
|
||||||
|
Future<String> knockRoom(JoinRoomRequest request) async =>
|
||||||
|
(await _sendCommand("knock_room", request.toJson()))["room_id"];
|
||||||
|
|
||||||
|
Future<String> createRoom(CreateRoomRequest request) async =>
|
||||||
|
(await _sendCommand("create_room", request.toJson()))["room_id"];
|
||||||
|
|
||||||
|
Future<SpaceHierarchy> getSpaceHierarchy(
|
||||||
|
GetSpaceHierarchyRequest request,
|
||||||
|
) async =>
|
||||||
|
.fromJson(await _sendCommand("get_space_hierarchy", request.toJson()));
|
||||||
|
|
||||||
Future<RoomSummary> getRoomSummary(JoinRoomRequest request) async =>
|
Future<RoomSummary> getRoomSummary(JoinRoomRequest request) async =>
|
||||||
.fromJson(await _sendCommand("get_room_summary", request.toJson()));
|
.fromJson(await _sendCommand("get_room_summary", request.toJson()));
|
||||||
|
|
||||||
Future<void> leaveRoom(Room room) async {
|
Future<void> leaveRoom(String roomId) =>
|
||||||
if (room.metadata == null) return;
|
_sendCommand("leave_room", {"room_id": roomId});
|
||||||
await _sendCommand("leave_room", {"room_id": room.metadata!.id});
|
|
||||||
}
|
|
||||||
|
|
||||||
// (await _sendCommand("get_event_context", {
|
|
||||||
// "room_id": request.roomId,
|
|
||||||
// "event_id": r"$OqZT4NuTj0J1-771IOEEWRI4XdumRNu6ighlvO3K3gc",
|
|
||||||
// }));
|
|
||||||
|
|
||||||
Future<IList<Event>> getRoomState(GetRoomStateRequest request) async {
|
Future<IList<Event>> getRoomState(GetRoomStateRequest request) async {
|
||||||
Future<List?> getState(GetRoomStateRequest request) async =>
|
Future<List?> getState(GetRoomStateRequest request) async =>
|
||||||
|
|
@ -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(
|
Future<IList<Event>?> getRelatedEvents(
|
||||||
GetRelatedEventsRequest request,
|
GetRelatedEventsRequest request,
|
||||||
) async {
|
) async {
|
||||||
|
|
@ -251,6 +278,9 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
return .new(response?.map((event) => .fromJson(event)));
|
return .new(response?.map((event) => .fromJson(event)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<MentionsResponse> getMentions(GetMentionsRequest request) async =>
|
||||||
|
.fromJson(await _sendCommand("get_mentions", request.toJson()));
|
||||||
|
|
||||||
Future<Event?> getEvent(GetEventRequest request) async {
|
Future<Event?> getEvent(GetEventRequest request) async {
|
||||||
final json = await _sendCommand("get_event", request.toJson());
|
final json = await _sendCommand("get_event", request.toJson());
|
||||||
return json == null ? null : .fromJson(json);
|
return json == null ? null : .fromJson(json);
|
||||||
|
|
@ -262,14 +292,35 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<Paginate> paginate(PaginateRequest request) async =>
|
Future<Paginate> paginate(PaginateRequest request) async =>
|
||||||
.fromJson(await _sendCommand("paginate", request.toJson()));
|
.fromJson(await _sendCommand("paginate", request.toJson()));
|
||||||
|
|
||||||
|
Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
|
||||||
|
.fromJson(await _sendCommand("paginate_manual", request.toJson()));
|
||||||
|
|
||||||
|
Future<PaginateManual> searchLocal(SearchRequest request) async =>
|
||||||
|
.fromJson(await _sendCommand("search_local", request.toJson()));
|
||||||
|
|
||||||
|
Future<PaginateManual> searchServer(SearchRequest request) async =>
|
||||||
|
.fromJson(await _sendCommand("search_server", request.toJson()));
|
||||||
|
|
||||||
|
Future<EventContext> getEventContext(GetEventContextRequest request) async =>
|
||||||
|
.fromJson(await _sendCommand("get_event_context", request.toJson()));
|
||||||
|
|
||||||
Future<ProfileResponse> getProfile(String userId) async {
|
Future<ProfileResponse> getProfile(String userId) async {
|
||||||
try {
|
try {
|
||||||
return .fromJson(await _sendCommand("get_profile", {"user_id": userId}));
|
return .fromJson(await _sendCommand("get_profile", {"user_id": userId}));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return 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) =>
|
Future<void> reportEvent(ReportRequest request) =>
|
||||||
_sendCommand("report_event", request.toJson());
|
_sendCommand("report_event", request.toJson());
|
||||||
|
|
||||||
|
|
@ -279,6 +330,15 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<void> setAccountData(SetAccountDataRequest request) =>
|
Future<void> setAccountData(SetAccountDataRequest request) =>
|
||||||
_sendCommand("set_account_data", request.toJson());
|
_sendCommand("set_account_data", request.toJson());
|
||||||
|
|
||||||
|
Future<void> registerPusher(RegisterPusherRequest request) =>
|
||||||
|
_sendCommand("register_homeserver_push", request.toJson());
|
||||||
|
|
||||||
|
Future<void> deregisterPusher(DeregisterPusherRequest request) =>
|
||||||
|
_sendCommand("register_homeserver_push", {
|
||||||
|
...request.toJson(),
|
||||||
|
"kind": null,
|
||||||
|
});
|
||||||
|
|
||||||
Future<MessageContent> uploadMedia(UploadMediaRequest request) async =>
|
Future<MessageContent> uploadMedia(UploadMediaRequest request) async =>
|
||||||
.fromJson(await _sendCommand("upload_media", request.toJson()));
|
.fromJson(await _sendCommand("upload_media", request.toJson()));
|
||||||
|
|
||||||
|
|
@ -287,14 +347,26 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
|
|
||||||
Future<void> logout() => _sendCommand("logout");
|
Future<void> logout() => _sendCommand("logout");
|
||||||
|
|
||||||
|
Future<void> setTyping(String roomId, Duration timeout) => _sendCommand(
|
||||||
|
"set_typing",
|
||||||
|
{"room_id": roomId, "timeout": timeout.inMilliseconds},
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> markRead(Room room) async {
|
Future<void> markRead(Room room) async {
|
||||||
|
if (room.timeline.isEmpty || room.metadata == null) return;
|
||||||
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
|
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
|
||||||
final event = eventRowId == null ? null : room.events[eventRowId];
|
final event = eventRowId == null ? null : room.events[eventRowId];
|
||||||
if (event == null || room.metadata == null) return;
|
|
||||||
|
if (event == null) return;
|
||||||
|
|
||||||
|
// Private until settings have loaded, in case the user turned sending off
|
||||||
|
final sendPublic =
|
||||||
|
ref.read(SettingsController.provider).value?.readReceipts ==
|
||||||
|
.showAndSend;
|
||||||
|
|
||||||
await _sendCommand("mark_read", {
|
await _sendCommand("mark_read", {
|
||||||
"room_id": room.metadata!.id,
|
"room_id": room.metadata!.id,
|
||||||
"receipt_type": "m.read",
|
"receipt_type": sendPublic ? "m.read" : "m.read.private",
|
||||||
"event_id": event.eventId,
|
"event_id": event.eventId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -313,9 +385,40 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<void> exchangeToken(OAuthExchangeTokenRequest request) async =>
|
Future<void> exchangeToken(OAuthExchangeTokenRequest request) async =>
|
||||||
await _sendCommand("oauth_exchange_token", request.toJson());
|
await _sendCommand("oauth_exchange_token", request.toJson());
|
||||||
|
|
||||||
|
Future<IList<String>> getMutualRooms(String userId) async => IList(
|
||||||
|
((await _sendCommand("get_mutual_rooms", {"user_id": userId}))["joined"]
|
||||||
|
as List?)
|
||||||
|
?.cast<String>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<IList<String>> getLocalAliases(String roomId) async => IList(
|
||||||
|
((await _sendCommand("get_local_aliases", {"room_id": roomId}))["aliases"]
|
||||||
|
as List?)
|
||||||
|
?.cast<String>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> createAlias(String roomId, String alias) =>
|
||||||
|
_sendCommand("create_alias", {"room_id": roomId, "alias": alias});
|
||||||
|
|
||||||
|
Future<void> deleteAlias(String alias) =>
|
||||||
|
_sendCommand("delete_alias", {"alias": alias});
|
||||||
|
|
||||||
|
/// The room [alias] points to, or null if it doesn't exist.
|
||||||
|
Future<String?> resolveAlias(String alias) async {
|
||||||
|
try {
|
||||||
|
return (await _sendCommand("resolve_alias", {"alias": alias}))["room_id"];
|
||||||
|
} on GomuksException catch (error) {
|
||||||
|
if (error.statusCode == 404) return null;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<SpecVersionsResponse> getSpecVersions() async =>
|
Future<SpecVersionsResponse> getSpecVersions() async =>
|
||||||
.fromJson(await _sendCommand("get_versions"));
|
.fromJson(await _sendCommand("get_versions"));
|
||||||
|
|
||||||
|
Future<Capabilities> getCapabilities() async =>
|
||||||
|
.fromJson((await _sendCommand("get_capabilities"))["capabilities"]);
|
||||||
|
|
||||||
Future<Uri?> discoverHomeserver(Uri homeserver) async {
|
Future<Uri?> discoverHomeserver(Uri homeserver) async {
|
||||||
try {
|
try {
|
||||||
final response = await _sendCommand("discover_homeserver", {
|
final response = await _sendCommand("discover_homeserver", {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import "package:nexus/controllers/client.dart";
|
||||||
class ClientIdController(final Uri homeserver) extends AsyncNotifier<String> {
|
class ClientIdController(final Uri homeserver) extends AsyncNotifier<String> {
|
||||||
@override
|
@override
|
||||||
Future<String> build() => ref
|
Future<String> build() => ref
|
||||||
.watch(ClientController.provider.notifier)
|
.read(ClientController.provider.notifier)
|
||||||
.registerClient(
|
.registerClient(
|
||||||
.new(
|
.new(
|
||||||
clientName: "Nexus",
|
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?> {
|
extends AsyncNotifier<Event?> {
|
||||||
@override
|
@override
|
||||||
Future<Event?> build() async {
|
Future<Event?> build() async {
|
||||||
final room = ref.watch(
|
final event = ref.watch(
|
||||||
RoomsController.provider.select((value) => value[request.roomId]),
|
RoomsController.provider.select(
|
||||||
);
|
(rooms) => rooms[request.roomId]?.events.values.firstWhereOrNull(
|
||||||
final event = room?.events.values.firstWhereOrNull(
|
|
||||||
(event) => event.eventId == request.eventId,
|
(event) => event.eventId == request.eventId,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return event ??
|
if (event != null) return event;
|
||||||
await ref
|
|
||||||
.watch(ClientController.provider.notifier)
|
final link = ref.keepAlive();
|
||||||
|
final fetchedEvent = await ref
|
||||||
|
.read(ClientController.provider.notifier)
|
||||||
.getEvent(request)
|
.getEvent(request)
|
||||||
.onError((_, _) => null);
|
.onError((_, _) => null)
|
||||||
|
.whenComplete(link.close);
|
||||||
|
|
||||||
|
if (fetchedEvent != null) {
|
||||||
|
// Storing the event invalidates this provider, as it watches the room.
|
||||||
|
// If nothing is listening, Riverpod won't rebuild it, and this build's
|
||||||
|
// result would be lost, so store it after returning.
|
||||||
|
final rooms = ref.read(RoomsController.provider.notifier);
|
||||||
|
Future.microtask(
|
||||||
|
() => rooms.update(
|
||||||
|
.new({
|
||||||
|
request.roomId: .new(
|
||||||
|
events: .new({fetchedEvent.rowId: fetchedEvent}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchedEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
static final provider = AsyncNotifierProvider.family
|
||||||
|
|
|
||||||
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:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/shared_prefs.dart";
|
import "package:nexus/controllers/shared_prefs.dart";
|
||||||
|
|
||||||
class KeyController(final String key) extends Notifier<String?> {
|
class KeyController(final String key) extends AsyncNotifier<String?> {
|
||||||
static const String spaceKey = "space";
|
static const String spaceKey = "space";
|
||||||
static const String roomKey = "room";
|
static const String roomKey = "room";
|
||||||
|
static const String pushKeyKey = "pushKey";
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? build() =>
|
Future<String?> build() =>
|
||||||
ref.watch(SharedPrefsController.provider).requireValue.getString(key);
|
ref.watch(SharedPrefsController.provider).getString(key);
|
||||||
|
|
||||||
Future<void> set(String? value) async {
|
Future<void> set(String? value) async {
|
||||||
final prefs = ref.watch(SharedPrefsController.provider).requireValue;
|
final prefs = ref.read(SharedPrefsController.provider);
|
||||||
state = value;
|
state = .data(value);
|
||||||
|
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
prefs.remove(key);
|
await prefs.remove(key);
|
||||||
} else {
|
} else {
|
||||||
prefs.setString(key, value);
|
await prefs.setString(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider =
|
static final provider =
|
||||||
NotifierProvider.family<KeyController, String?, String>(
|
AsyncNotifierProvider.family<KeyController, String?, String>(
|
||||||
KeyController.new,
|
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:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/shared_prefs.dart";
|
import "package:nexus/controllers/shared_prefs.dart";
|
||||||
|
|
||||||
class MemberListOpenedController extends Notifier<bool> {
|
class MemberListOpenedController extends AsyncNotifier<bool> {
|
||||||
static const String key = "memberListOpened";
|
static const String key = "memberListOpened";
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool build() =>
|
Future<bool> build() async =>
|
||||||
ref.watch(SharedPrefsController.provider).requireValue.getBool(key) ??
|
await ref.watch(SharedPrefsController.provider).getBool(key) ?? true;
|
||||||
true;
|
|
||||||
|
|
||||||
Future<void> set(bool value) async {
|
Future<void> set(bool value) async {
|
||||||
final prefs = ref.watch(SharedPrefsController.provider).requireValue;
|
state = .data(value);
|
||||||
state = value;
|
await ref.read(SharedPrefsController.provider).setBool(key, value);
|
||||||
|
|
||||||
prefs.setBool(key, value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = NotifierProvider<MemberListOpenedController, bool>(
|
static final provider =
|
||||||
|
AsyncNotifierProvider<MemberListOpenedController, bool>(
|
||||||
MemberListOpenedController.new,
|
MemberListOpenedController.new,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class MembersController(final String roomId)
|
||||||
|
|
||||||
if (!room.hasFetchedMembers) {
|
if (!room.hasFetchedMembers) {
|
||||||
final fetchedState = await ref
|
final fetchedState = await ref
|
||||||
.watch(ClientController.provider.notifier)
|
.read(ClientController.provider.notifier)
|
||||||
.getRoomState(
|
.getRoomState(
|
||||||
GetRoomStateRequest(
|
GetRoomStateRequest(
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
|
|
@ -27,7 +27,7 @@ class MembersController(final String roomId)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await ref
|
ref
|
||||||
.read(RoomsController.provider.notifier)
|
.read(RoomsController.provider.notifier)
|
||||||
.addState(roomId, fetchedState, isMembers: true);
|
.addState(roomId, fetchedState, isMembers: true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,27 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/members_by_status.dart";
|
import "package:nexus/controllers/members_by_status.dart";
|
||||||
import "package:nexus/controllers/room_creators.dart";
|
import "package:nexus/controllers/room_power_levels.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
|
||||||
import "package:nexus/models/configs/members_by_status.dart";
|
import "package:nexus/models/configs/members_by_status.dart";
|
||||||
import "package:nexus/models/content/content.dart";
|
|
||||||
import "package:nexus/models/content/power_levels.dart";
|
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
|
|
||||||
class MembersGroupedController(final MembersByStatusConfig config)
|
class MembersGroupedController(final MembersByStatusConfig config)
|
||||||
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
|
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
|
||||||
@override
|
@override
|
||||||
Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
|
Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
|
||||||
final room = ref.watch(
|
final powerLevels = ref.watch(
|
||||||
RoomsController.provider.select((value) => value[config.roomId]),
|
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(
|
final members = await ref.watch(
|
||||||
MembersByStatusController.provider(config).future,
|
MembersByStatusController.provider(config).future,
|
||||||
);
|
);
|
||||||
|
|
||||||
return members
|
return members
|
||||||
.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
|
.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
|
||||||
final groupKey = roomCreators?.contains(event.stateKey!) == true
|
final groupKey = powerLevels.creators.contains(event.stateKey!)
|
||||||
? null
|
? null
|
||||||
: content.users[event.stateKey!] ?? content.usersDefault;
|
: powerLevels.levelOf(event.stateKey!).toInt();
|
||||||
|
|
||||||
return result.update(
|
return result.update(
|
||||||
groupKey,
|
groupKey,
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
class MultiProviderController(final IList<AsyncNotifierProvider> providers)
|
class MultiProviderController(final IList<AsyncNotifierProvider> providers)
|
||||||
extends AsyncNotifier<void> {
|
extends AsyncNotifier<void> {
|
||||||
@override
|
@override
|
||||||
Future<void> build() =>
|
Future<void> build() => .wait(
|
||||||
.wait(providers.map((provider) => ref.watch(provider.future)));
|
providers.map((provider) => ref.watch(provider.future)),
|
||||||
|
eagerError: true,
|
||||||
|
);
|
||||||
|
|
||||||
static final provider =
|
static final provider =
|
||||||
AsyncNotifierProvider.family<
|
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:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:nexus/controllers/event.dart";
|
import "package:nexus/controllers/event.dart";
|
||||||
import "package:nexus/controllers/pinned_ids.dart";
|
import "package:nexus/controllers/pinned_ids.dart";
|
||||||
import "package:nexus/models/event.dart";
|
|
||||||
|
|
||||||
class PinnedEventsController(final String roomId)
|
class PinnedEventsController(final String roomId)
|
||||||
extends AsyncNotifier<IList<Event>> {
|
extends AsyncNotifier<IList<int>> {
|
||||||
@override
|
@override
|
||||||
Future<IList<Event>> build() async {
|
Future<IList<int>> build() async {
|
||||||
final pinIds = ref.watch(PinnedIdsController.provider(roomId));
|
final pinIds = ref.watch(PinnedIdsController.provider(roomId));
|
||||||
|
|
||||||
return (await Future.wait(
|
return (await Future.wait(
|
||||||
pinIds.map(
|
pinIds.map(
|
||||||
(eventId) => ref.watch(
|
(eventId) => ref.watch(
|
||||||
EventController.provider(.new(eventId: eventId, roomId: roomId))
|
EventController.provider(.new(eventId: eventId, roomId: roomId))
|
||||||
.future,
|
.selectAsync((data) => data?.rowId),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)).nonNulls.toIList();
|
)).nonNulls.toIList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<PinnedEventsController, IList<Event>, String>(
|
.autoDispose<PinnedEventsController, IList<int>, String>(
|
||||||
PinnedEventsController.new,
|
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:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client_state.dart";
|
import "package:nexus/controllers/client_state.dart";
|
||||||
import "package:nexus/controllers/room_creators.dart";
|
import "package:nexus/controllers/room_power_levels.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
|
||||||
import "package:nexus/models/configs/power_level.dart";
|
import "package:nexus/models/configs/power_level.dart";
|
||||||
import "package:nexus/models/content/content.dart";
|
import "package:nexus/models/content/content.dart";
|
||||||
import "package:nexus/models/content/power_levels.dart";
|
|
||||||
|
|
||||||
class PowerLevelController(final PowerLevelConfig config)
|
class PowerLevelController(final PowerLevelConfig config)
|
||||||
extends Notifier<bool> {
|
extends Notifier<bool> {
|
||||||
|
|
@ -17,31 +15,17 @@ class PowerLevelController(final PowerLevelConfig config)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final room = ref.watch(
|
final powerLevels = ref.watch(
|
||||||
RoomsController.provider.select((value) => value[config.roomId]),
|
RoomPowerLevelsController.provider(config.roomId),
|
||||||
);
|
);
|
||||||
|
final content = powerLevels.content;
|
||||||
final roomCreators = room == null
|
|
||||||
? null
|
|
||||||
: ref.watch(RoomCreatorsController.provider(room));
|
|
||||||
|
|
||||||
final eventRowId = room?.state[EventType.powerLevels.type]?[""];
|
|
||||||
|
|
||||||
final event = eventRowId == null ? null : room?.events[eventRowId];
|
|
||||||
final content = event?.content is PowerLevelsContent
|
|
||||||
? event!.content
|
|
||||||
: PowerLevelsContent();
|
|
||||||
|
|
||||||
final user = ref.watch(
|
final user = ref.watch(
|
||||||
ClientStateController.provider.select((value) => value?.userId),
|
ClientStateController.provider.select((value) => value?.userId),
|
||||||
);
|
);
|
||||||
if (user == null || content is! PowerLevelsContent) return false;
|
if (user == null) return false;
|
||||||
|
|
||||||
double powerLevelOf(String userId) => roomCreators?.contains(userId) == true
|
final userLevel = powerLevels.levelOf(user);
|
||||||
? double.infinity
|
|
||||||
: (content.users[userId] ?? content.usersDefault).toDouble();
|
|
||||||
|
|
||||||
final userLevel = powerLevelOf(user);
|
|
||||||
|
|
||||||
return switch (config) {
|
return switch (config) {
|
||||||
EventPowerLevelConfig(:final eventType) =>
|
EventPowerLevelConfig(:final eventType) =>
|
||||||
|
|
@ -52,10 +36,12 @@ class PowerLevelController(final PowerLevelConfig config)
|
||||||
.invite => userLevel >= content.invite,
|
.invite => userLevel >= content.invite,
|
||||||
|
|
||||||
.kick =>
|
.kick =>
|
||||||
userLevel >= content.kick && userLevel > powerLevelOf(targetUser),
|
userLevel >= content.kick &&
|
||||||
|
userLevel > powerLevels.levelOf(targetUser),
|
||||||
|
|
||||||
.ban =>
|
.ban =>
|
||||||
userLevel >= content.ban && userLevel > powerLevelOf(targetUser),
|
userLevel >= content.ban &&
|
||||||
|
userLevel > powerLevels.levelOf(targetUser),
|
||||||
|
|
||||||
.unban => userLevel >= content.ban,
|
.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> {
|
extends AsyncNotifier<ProfileResponse> {
|
||||||
@override
|
@override
|
||||||
Future<ProfileResponse> build() {
|
Future<ProfileResponse> build() {
|
||||||
final client = ref.watch(ClientController.provider.notifier);
|
final client = ref.read(ClientController.provider.notifier);
|
||||||
return client.getProfile(userId);
|
return client.getProfile(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setField(String field, Object? value) async {
|
||||||
|
await ref
|
||||||
|
.read(ClientController.provider.notifier)
|
||||||
|
.setProfileField(field, value);
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
ref.invalidateSelf();
|
||||||
|
await future;
|
||||||
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<ProfileController, ProfileResponse, String>(
|
.autoDispose<ProfileController, ProfileResponse, String>(
|
||||||
ProfileController.new,
|
ProfileController.new,
|
||||||
|
|
|
||||||
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
|
final reactionEvents = eventInfo?.$2.isNotEmpty == true
|
||||||
? await ref
|
? await ref
|
||||||
.watch(ClientController.provider.notifier)
|
.read(ClientController.provider.notifier)
|
||||||
.getRelatedEvents(
|
.getRelatedEvents(
|
||||||
.new(
|
.new(
|
||||||
roomId: config.roomId,
|
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,81 +9,134 @@ import "package:nexus/controllers/attachment.dart";
|
||||||
import "package:nexus/controllers/client.dart";
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
import "package:nexus/controllers/rooms.dart";
|
||||||
import "package:nexus/models/content/content.dart";
|
import "package:nexus/models/content/content.dart";
|
||||||
|
import "package:nexus/models/content/image_pack.dart";
|
||||||
import "package:nexus/models/content/reaction.dart";
|
import "package:nexus/models/content/reaction.dart";
|
||||||
|
import "package:nexus/models/content/sticker.dart";
|
||||||
|
import "package:nexus/models/direction.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/requests/redact_event.dart";
|
import "package:nexus/models/requests/redact_event.dart";
|
||||||
import "package:nexus/models/relation_type.dart";
|
import "package:nexus/models/relation_type.dart";
|
||||||
import "package:nexus/models/requests/send_message.dart";
|
import "package:nexus/models/requests/send_message.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
|
import "package:nexus/models/room_chat.dart";
|
||||||
|
|
||||||
class RoomChatController(final String roomId)
|
typedef ChatParams = ({String roomId, int? contextualEvent});
|
||||||
extends AsyncNotifier<IList<Event>?> {
|
|
||||||
|
class RoomChatController(final ChatParams info)
|
||||||
|
extends AsyncNotifier<RoomChat?> {
|
||||||
@override
|
@override
|
||||||
Future<IList<Event>?> build() async {
|
Future<RoomChat?> build() async {
|
||||||
final client = ref.watch(ClientController.provider.notifier);
|
final client = ref.read(ClientController.provider.notifier);
|
||||||
final room = ref.watch(
|
|
||||||
RoomsController.provider.select((rooms) => rooms[roomId]),
|
final room = ref.read(
|
||||||
|
RoomsController.provider.select((rooms) => rooms[info.roomId]),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (room == null) return null;
|
if (room == null) return null;
|
||||||
|
|
||||||
if (!room.hasFetchedState) {
|
if (!room.hasFetchedState) {
|
||||||
final state = await client.getRoomState(.new(roomId: roomId));
|
final state = await client.getRoomState(.new(roomId: info.roomId));
|
||||||
|
ref.read(RoomsController.provider.notifier).addState(info.roomId, state);
|
||||||
await ref.read(RoomsController.provider.notifier).addState(roomId, state);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// While there are under 20 events, try to load more
|
final timeline = room.timeline
|
||||||
// until there's no more or the conditions are met.
|
|
||||||
if (room.hasMore && room.timeline.length < 20) {
|
|
||||||
loadOlder();
|
|
||||||
}
|
|
||||||
|
|
||||||
return room.timeline
|
|
||||||
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
|
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
|
||||||
.map((element) => element.value)
|
.map((e) => e.value)
|
||||||
.toIList()
|
.toIList()
|
||||||
.addAll(room.sticky)
|
.addAll(room.clientSticky)
|
||||||
.map((entry) {
|
|
||||||
final foundEvent = entry == null ? null : room.events[entry];
|
|
||||||
|
|
||||||
final editedEvent =
|
|
||||||
foundEvent == null || foundEvent.lastEditRowId == 0
|
|
||||||
? null
|
|
||||||
: room.events[foundEvent.lastEditRowId];
|
|
||||||
|
|
||||||
return editedEvent == null
|
|
||||||
? foundEvent
|
|
||||||
: foundEvent?.copyWith(
|
|
||||||
content: editedEvent.content,
|
|
||||||
localContent: editedEvent.localContent,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.nonNulls
|
.nonNulls
|
||||||
.toIList();
|
.toIList();
|
||||||
|
if (info.contextualEvent == null ||
|
||||||
|
timeline.contains(info.contextualEvent)) {
|
||||||
|
ref.watch(
|
||||||
|
RoomsController.provider.select(
|
||||||
|
(rooms) =>
|
||||||
|
(rooms[info.roomId]?.timeline, rooms[info.roomId]?.clientSticky),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return .new(
|
||||||
|
timeline: timeline,
|
||||||
|
hasMoreBackward: room.hasMore,
|
||||||
|
hasMoreForward: false,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final context = await client.getEventContext(
|
||||||
|
.new(
|
||||||
|
roomId: info.roomId,
|
||||||
|
eventId: room.events[info.contextualEvent!]!.eventId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// `before` is in reverse chronological order
|
||||||
|
final events = context.before.reversed
|
||||||
|
.toIList()
|
||||||
|
.add(context.event)
|
||||||
|
.addAll(context.after);
|
||||||
|
ref
|
||||||
|
.read(RoomsController.provider.notifier)
|
||||||
|
.storeEvents(events.addAll(context.relatedEvents));
|
||||||
|
return .new(
|
||||||
|
timeline: .new(events.map((element) => element.rowId)),
|
||||||
|
hasMoreBackward: true,
|
||||||
|
hasMoreForward: true,
|
||||||
|
historicalData: .new(start: context.start, end: context.end),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteMessage(Event event, {String? reason}) => ref
|
Future<void> deleteMessage(Event event, {String? reason}) => ref
|
||||||
.watch(ClientController.provider.notifier)
|
.read(ClientController.provider.notifier)
|
||||||
.redactEvent(
|
.redactEvent(
|
||||||
RedactEventRequest(
|
RedactEventRequest(
|
||||||
eventId: event.eventId,
|
eventId: event.eventId,
|
||||||
roomId: roomId,
|
roomId: info.roomId,
|
||||||
reason: reason,
|
reason: reason,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<bool> loadOlder() async {
|
void hideClientSticky(int eventRowId) => ref
|
||||||
state = AsyncLoading();
|
.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
|
final timelineKeys = ref
|
||||||
.read(RoomsController.provider.select((value) => value[roomId]))
|
.read(RoomsController.provider.select((value) => value[info.roomId]))
|
||||||
?.timeline
|
?.timeline
|
||||||
.keys;
|
.keys;
|
||||||
final response = await ref
|
final response = await client.paginate(
|
||||||
.read(ClientController.provider.notifier)
|
|
||||||
.paginate(
|
|
||||||
.new(
|
.new(
|
||||||
roomId: roomId,
|
roomId: info.roomId,
|
||||||
maxTimelineId: timelineKeys?.isNotEmpty == true
|
maxTimelineId: timelineKeys?.isNotEmpty == true
|
||||||
? timelineKeys?.reduce(min)
|
? timelineKeys?.reduce(min)
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -91,19 +144,21 @@ class RoomChatController(final String roomId)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.events.isEmpty) {
|
if (response.events.isEmpty) {
|
||||||
state = .data(state.value);
|
state = .data(chat);
|
||||||
} else {
|
}
|
||||||
|
|
||||||
ref
|
ref
|
||||||
.read(RoomsController.provider.notifier)
|
.read(RoomsController.provider.notifier)
|
||||||
.update(
|
.update(
|
||||||
IMap({
|
IMap({
|
||||||
roomId: Room(
|
info.roomId: Room(
|
||||||
events: IMap.fromIterable(
|
events: IMap.fromIterable(
|
||||||
response.events.addAll(response.relatedEvents),
|
response.events.addAll(response.relatedEvents),
|
||||||
keyMapper: (event) => event.rowId,
|
keyMapper: (event) => event.rowId,
|
||||||
valueMapper: (event) => event,
|
valueMapper: (event) => event,
|
||||||
),
|
),
|
||||||
hasMore: response.hasMore,
|
hasMore: response.hasMore,
|
||||||
|
receipts: response.receipts,
|
||||||
timeline: IMap.fromIterable(
|
timeline: IMap.fromIterable(
|
||||||
response.events,
|
response.events,
|
||||||
keyMapper: (event) => event.timelineRowId,
|
keyMapper: (event) => event.timelineRowId,
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
ref
|
||||||
|
.read(RoomsController.provider.notifier)
|
||||||
|
.storeEvents(
|
||||||
|
paginationResponse.events.addAll(paginationResponse.relatedEvents),
|
||||||
|
);
|
||||||
|
|
||||||
|
final eventRowIds =
|
||||||
|
(direction == .forward
|
||||||
|
? paginationResponse.events
|
||||||
|
: paginationResponse.events.reversed)
|
||||||
|
.map((element) => element.rowId)
|
||||||
|
.toIList();
|
||||||
|
|
||||||
|
state = .data(
|
||||||
|
.new(
|
||||||
|
timeline: direction == .forward
|
||||||
|
? chat.timeline.addAll(eventRowIds)
|
||||||
|
: eventRowIds.addAll(chat.timeline),
|
||||||
|
hasMoreForward:
|
||||||
|
direction == .forward && paginationResponse.nextBatch == null
|
||||||
|
? false
|
||||||
|
: chat.hasMoreForward,
|
||||||
|
hasMoreBackward:
|
||||||
|
direction == .backward && paginationResponse.nextBatch == null
|
||||||
|
? false
|
||||||
|
: chat.hasMoreBackward,
|
||||||
|
historicalData: chat.historicalData?.copyWith(
|
||||||
|
start:
|
||||||
|
(direction == .backward
|
||||||
|
? paginationResponse.nextBatch
|
||||||
|
: null) ??
|
||||||
|
chat.historicalData!.start,
|
||||||
|
end:
|
||||||
|
(direction == .forward ? paginationResponse.nextBatch : null) ??
|
||||||
|
chat.historicalData!.end,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.hasMore;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> send(
|
Future<void> send(
|
||||||
|
|
@ -123,60 +224,62 @@ class RoomChatController(final String roomId)
|
||||||
bool shouldMention = true,
|
bool shouldMention = true,
|
||||||
required IList<Tag> tags,
|
required IList<Tag> tags,
|
||||||
required RelationType relationType,
|
required RelationType relationType,
|
||||||
Event? relation,
|
int? relatedEventRowId,
|
||||||
}) async {
|
}) async {
|
||||||
Content? baseContent;
|
Content? baseContent;
|
||||||
if (relationType == .edit) {
|
final relatedEvent = relatedEventRowId == null
|
||||||
baseContent = relation?.content;
|
? null
|
||||||
|
: ref.read(
|
||||||
|
RoomsController.provider.select(
|
||||||
|
(rooms) => rooms[info.roomId]?.events[relatedEventRowId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (relationType == .edit && relatedEventRowId != null) {
|
||||||
|
baseContent = relatedEvent?.content;
|
||||||
} else {
|
} else {
|
||||||
final provider = AttachmentController.provider(roomId);
|
final provider = AttachmentController.provider(info.roomId);
|
||||||
baseContent = ref.watch(provider)?.$2;
|
baseContent = ref.read(provider)?.$2;
|
||||||
ref.invalidate(provider);
|
ref.invalidate(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
var taggedMessage = text;
|
// Replaces each formatted tag, like "@id#name#", with its ID
|
||||||
|
final taggedMessage = tags.fold(
|
||||||
for (final tag in tags) {
|
text,
|
||||||
final escaped = RegExp.escape(tag.id);
|
(message, tag) => message.replaceAllMapped(
|
||||||
final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?");
|
RegExp(r"@+(" + RegExp.escape(tag.id) + r")(#[^#]*#)?"),
|
||||||
|
|
||||||
taggedMessage = taggedMessage.replaceAllMapped(
|
|
||||||
pattern,
|
|
||||||
(match) => match.group(1)!,
|
(match) => match.group(1)!,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
final client = ref.watch(ClientController.provider.notifier);
|
final client = ref.read(ClientController.provider.notifier);
|
||||||
final event = await client.sendMessage(
|
final event = await client.sendMessage(
|
||||||
SendMessageRequest(
|
SendMessageRequest(
|
||||||
roomId: roomId,
|
roomId: info.roomId,
|
||||||
baseContent: baseContent,
|
baseContent: baseContent,
|
||||||
mentions: Mentions(
|
mentions: Mentions(
|
||||||
userIds: [
|
userIds: [
|
||||||
if (shouldMention == true &&
|
if (shouldMention && relatedEvent != null && relationType == .reply)
|
||||||
relation != null &&
|
relatedEvent.sender,
|
||||||
relationType == RelationType.reply)
|
|
||||||
relation.sender,
|
|
||||||
].toIList(),
|
].toIList(),
|
||||||
room: taggedMessage.contains("@room"),
|
room: taggedMessage.contains("@room"),
|
||||||
),
|
),
|
||||||
text: taggedMessage,
|
text: taggedMessage,
|
||||||
relation: relation == null
|
relation: relatedEvent == null
|
||||||
? null
|
? null
|
||||||
: .new(eventId: relation.eventId, relationType: relationType),
|
: .new(eventId: relatedEvent.eventId, relationType: relationType),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
ref
|
ref
|
||||||
.watch(RoomsController.provider.notifier)
|
.read(RoomsController.provider.notifier)
|
||||||
.update(
|
.update(
|
||||||
.new({
|
.new({
|
||||||
roomId: .new(
|
info.roomId: .new(
|
||||||
events: .new({event.rowId: event}),
|
events: .new({event.rowId: event}),
|
||||||
sticky: .new({event.rowId}),
|
clientSticky: .new({event.rowId}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
.new(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,21 +288,19 @@ class RoomChatController(final String roomId)
|
||||||
Event event,
|
Event event,
|
||||||
String userId,
|
String userId,
|
||||||
) async {
|
) async {
|
||||||
final client = ref.watch(ClientController.provider.notifier);
|
final client = ref.read(ClientController.provider.notifier);
|
||||||
final allReactionEvents = await client.getRelatedEvents(
|
final allReactionEvents = await client.getRelatedEvents(
|
||||||
.new(
|
.new(
|
||||||
roomId: roomId,
|
roomId: info.roomId,
|
||||||
eventId: event.eventId,
|
eventId: event.eventId,
|
||||||
relationType: "m.annotation",
|
relationType: "m.annotation",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final reactionEvents = allReactionEvents
|
final reactionEvent = allReactionEvents?.firstWhereOrNull(
|
||||||
?.where((event) => event.redactedBy == null)
|
(event) =>
|
||||||
.toIList();
|
event.redactedBy == null &&
|
||||||
|
switch (event.content) {
|
||||||
final reactionEvent = reactionEvents?.firstWhereOrNull(
|
|
||||||
(event) => switch (event.content) {
|
|
||||||
ReactionContent(:final key) =>
|
ReactionContent(:final key) =>
|
||||||
key == reaction && event.sender == userId,
|
key == reaction && event.sender == userId,
|
||||||
_ => false,
|
_ => false,
|
||||||
|
|
@ -207,20 +308,30 @@ class RoomChatController(final String roomId)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (reactionEvent != null) {
|
if (reactionEvent != null) {
|
||||||
await ref
|
await client.redactEvent(
|
||||||
.watch(ClientController.provider.notifier)
|
.new(eventId: reactionEvent.eventId, roomId: info.roomId),
|
||||||
.redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendReaction(String reaction, Event event) async {
|
/// Reacts to [event] with [reaction], which is an mxc URL for custom emoji,
|
||||||
final client = ref.watch(ClientController.provider.notifier);
|
/// with their [shortcode].
|
||||||
|
Future<void> sendReaction(
|
||||||
|
String reaction,
|
||||||
|
Event event, {
|
||||||
|
String? shortcode,
|
||||||
|
}) async {
|
||||||
|
final client = ref.read(ClientController.provider.notifier);
|
||||||
|
|
||||||
await client.sendEvent(
|
await client.sendEvent(
|
||||||
.new(
|
.new(
|
||||||
roomId: roomId,
|
roomId: info.roomId,
|
||||||
type: EventType.reaction.type,
|
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,
|
synchronous: true,
|
||||||
disableEncryption: true,
|
disableEncryption: true,
|
||||||
relatesTo: event.eventId,
|
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
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<RoomChatController, IList<Event>?, String>(
|
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
|
||||||
RoomChatController.new,
|
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> {
|
extends AsyncNotifier<RoomSummary> {
|
||||||
@override
|
@override
|
||||||
Future<RoomSummary> build() =>
|
Future<RoomSummary> build() =>
|
||||||
ref.watch(ClientController.provider.notifier).getRoomSummary(request);
|
ref.read(ClientController.provider.notifier).getRoomSummary(request);
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<RoomSummaryController, RoomSummary, JoinRoomRequest>(
|
.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:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
|
import "package:nexus/models/read_receipt.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
|
|
||||||
class RoomsController extends Notifier<IMap<String, Room>> {
|
class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
@override
|
@override
|
||||||
IMap<String, Room> build() => .new();
|
IMap<String, Room> build() => .new();
|
||||||
|
|
||||||
Future<void> addState(
|
void addState(String roomId, IList<Event> state, {bool isMembers = false}) =>
|
||||||
String roomId,
|
update(
|
||||||
IList<Event> state, {
|
|
||||||
bool isMembers = false,
|
|
||||||
}) async => update(
|
|
||||||
.new({
|
.new({
|
||||||
roomId: Room(
|
roomId: stateRoom(state)
|
||||||
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
|
.copyWith(hasFetchedState: true, hasFetchedMembers: isMembers),
|
||||||
hasFetchedState: true,
|
|
||||||
hasFetchedMembers: isMembers,
|
|
||||||
state: await Isolate.run(() {
|
|
||||||
final newState = state.fold<IMap<String, IMap<String, int>>>(
|
|
||||||
.new(),
|
|
||||||
(previousValue, stateEvent) => previousValue.add(
|
|
||||||
stateEvent.type,
|
|
||||||
(previousValue[stateEvent.type] ?? .new()).add(
|
|
||||||
stateEvent.stateKey!,
|
|
||||||
stateEvent.rowId,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return newState;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
.new(),
|
.new(),
|
||||||
);
|
);
|
||||||
|
|
||||||
void update(IMap<String, Room> rooms, ISet<String> leftRooms) {
|
void addSpecificState(Iterable<Event> state) => update(
|
||||||
|
.new(
|
||||||
|
state
|
||||||
|
.groupListsBy((event) => event.roomId)
|
||||||
|
.map((roomId, roomState) => .new(roomId, stateRoom(roomState))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
static Room stateRoom(Iterable<Event> state) => Room(
|
||||||
|
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
|
||||||
|
state: .new(
|
||||||
|
state
|
||||||
|
.groupListsBy((event) => event.type)
|
||||||
|
.map(
|
||||||
|
(type, group) => .new(
|
||||||
|
type,
|
||||||
|
.fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
static IMap<String, IList<ReadReceipt>> mergeReceipts(
|
||||||
|
IMap<String, IList<ReadReceipt>> existing,
|
||||||
|
IMap<String, IList<ReadReceipt>> incoming,
|
||||||
|
) => incoming.isEmpty
|
||||||
|
? existing
|
||||||
|
: existing.values.flattened
|
||||||
|
.followedBy(incoming.values.flattened)
|
||||||
|
.groupListsBy((receipt) => (receipt.userId, receipt.thread))
|
||||||
|
.values
|
||||||
|
.map(
|
||||||
|
(receipts) => receipts.reduce(
|
||||||
|
(newest, receipt) => newest.timestamp.isAfter(receipt.timestamp)
|
||||||
|
? newest
|
||||||
|
: receipt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupListsBy((receipt) => receipt.eventId)
|
||||||
|
.map((eventId, receipts) => MapEntry(eventId, receipts.toIList()))
|
||||||
|
.toIMap();
|
||||||
|
|
||||||
|
/// Adds events to their rooms, without changing the rooms' timelines.
|
||||||
|
void storeEvents(Iterable<Event> events) => update(
|
||||||
|
.new(
|
||||||
|
events
|
||||||
|
.groupListsBy((event) => event.roomId)
|
||||||
|
.map(
|
||||||
|
(roomId, roomEvents) => .new(
|
||||||
|
roomId,
|
||||||
|
Room(
|
||||||
|
// ignore: unused_result - I am using it you donkey
|
||||||
|
events: .fromIterable(
|
||||||
|
roomEvents,
|
||||||
|
keyMapper: (event) => event.rowId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
void update(
|
||||||
|
IMap<String, Room> rooms, [
|
||||||
|
ISet<String> leftRooms = const .empty(),
|
||||||
|
]) {
|
||||||
final merged = rooms.entries.fold(state, (acc, entry) {
|
final merged = rooms.entries.fold(state, (acc, entry) {
|
||||||
final roomId = entry.key;
|
final roomId = entry.key;
|
||||||
final incoming = entry.value;
|
final incoming = entry.value;
|
||||||
|
|
@ -46,10 +95,13 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
roomId,
|
roomId,
|
||||||
existing?.copyWith(
|
existing?.copyWith(
|
||||||
hasMore: incoming.hasMore,
|
hasMore: incoming.hasMore,
|
||||||
sticky:
|
clientSticky: incoming.resetClientSticky
|
||||||
(incoming.sticky.isEmpty == true
|
? incoming.clientSticky
|
||||||
? existing.sticky
|
: (incoming.clientSticky.isEmpty
|
||||||
: existing.sticky.addAll(incoming.sticky))
|
? existing.clientSticky
|
||||||
|
: existing.clientSticky.addAll(
|
||||||
|
incoming.clientSticky,
|
||||||
|
))
|
||||||
.removeWhere(
|
.removeWhere(
|
||||||
(rowId) => incoming.timeline.values.contains(rowId),
|
(rowId) => incoming.timeline.values.contains(rowId),
|
||||||
),
|
),
|
||||||
|
|
@ -64,32 +116,49 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
(previousValue[event.key] ?? .new()).addAll(event.value),
|
(previousValue[event.key] ?? .new()).addAll(event.value),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
resetClientSticky: false,
|
||||||
reset: false,
|
reset: false,
|
||||||
hasFetchedMembers:
|
hasFetchedMembers:
|
||||||
incoming.hasFetchedMembers || existing.hasFetchedMembers,
|
incoming.hasFetchedMembers || existing.hasFetchedMembers,
|
||||||
hasFetchedState:
|
hasFetchedState:
|
||||||
incoming.hasFetchedState || existing.hasFetchedState,
|
incoming.hasFetchedState || existing.hasFetchedState,
|
||||||
timeline: (incoming.reset
|
timeline: incoming.reset
|
||||||
? incoming.timeline
|
? incoming.timeline
|
||||||
: existing.timeline.addAll(incoming.timeline)),
|
: existing.timeline.addAll(incoming.timeline),
|
||||||
receipts: incoming.receipts.entries.fold(
|
receipts: mergeReceipts(existing.receipts, incoming.receipts),
|
||||||
existing.receipts,
|
|
||||||
(receiptAcc, event) => receiptAcc.add(
|
|
||||||
event.key,
|
|
||||||
(receiptAcc[event.key] ?? .new()).addAll(event.value),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) ??
|
) ??
|
||||||
incoming,
|
incoming.copyWith(
|
||||||
|
receipts: mergeReceipts(const .empty(), incoming.receipts),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final prunedList = leftRooms.fold(
|
state = leftRooms.fold(merged, (acc, roomId) => acc.remove(roomId));
|
||||||
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>>(
|
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 "dart:convert";
|
||||||
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/settings_file.dart";
|
import "package:nexus/controllers/settings_file.dart";
|
||||||
import "package:nexus/models/settings.dart";
|
import "package:nexus/models/settings.dart";
|
||||||
|
|
@ -17,7 +18,7 @@ class SettingsController extends AsyncNotifier<Settings> {
|
||||||
|
|
||||||
Future<void> set(Settings settings) async {
|
Future<void> set(Settings settings) async {
|
||||||
state = .data(settings);
|
state = .data(settings);
|
||||||
final file = await ref.watch(SettingsFileController.provider.future);
|
final file = await ref.read(SettingsFileController.provider.future);
|
||||||
await file.writeAsString(json.encode(settings.toJson()));
|
await file.writeAsString(json.encode(settings.toJson()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import "dart:io";
|
import "dart:io";
|
||||||
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:path/path.dart";
|
import "package:path/path.dart";
|
||||||
import "package:path_provider/path_provider.dart";
|
import "package:path_provider/path_provider.dart";
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,34 @@
|
||||||
import "dart:io";
|
import "dart:io";
|
||||||
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:intl/intl.dart";
|
import "package:intl/intl.dart";
|
||||||
import "package:m3e_buttons/m3e_buttons.dart";
|
import "package:m3e_buttons/m3e_buttons.dart";
|
||||||
import "package:nexus/controllers/account_data.dart";
|
import "package:nexus/controllers/account_data.dart";
|
||||||
import "package:nexus/controllers/client.dart";
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/controllers/client_state.dart";
|
import "package:nexus/controllers/client_state.dart";
|
||||||
|
import "package:nexus/controllers/init_complete.dart";
|
||||||
|
import "package:nexus/controllers/notification.dart";
|
||||||
import "package:nexus/controllers/settings.dart";
|
import "package:nexus/controllers/settings.dart";
|
||||||
|
import "package:nexus/controllers/unified_push.dart";
|
||||||
import "package:nexus/controllers/spec_versions.dart";
|
import "package:nexus/controllers/spec_versions.dart";
|
||||||
|
import "package:nexus/controllers/unified_push_allowed.dart";
|
||||||
|
import "package:nexus/controllers/unified_push_distributors.dart";
|
||||||
|
import "package:nexus/helpers/extensions/distributor_name.dart";
|
||||||
import "package:nexus/models/account_data.dart";
|
import "package:nexus/models/account_data.dart";
|
||||||
|
import "package:nexus/models/client_state.dart";
|
||||||
|
import "package:nexus/models/name_color.dart";
|
||||||
|
import "package:nexus/models/room_order.dart";
|
||||||
|
import "package:nexus/models/settings.dart";
|
||||||
import "package:nexus/models/settings_category.dart";
|
import "package:nexus/models/settings_category.dart";
|
||||||
|
import "package:nexus/models/show_and_send.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
||||||
|
import "package:nexus/widgets/user_bottom_sheet.dart";
|
||||||
|
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
||||||
|
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||||
|
|
||||||
class SettingsSectionsController
|
class SettingsSectionsController
|
||||||
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {
|
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {
|
||||||
|
|
@ -21,6 +36,30 @@ class SettingsSectionsController
|
||||||
Future<IMap<String, IList<SettingsCategory>>> build() async {
|
Future<IMap<String, IList<SettingsCategory>>> build() async {
|
||||||
final settings = await ref.watch(SettingsController.provider.future);
|
final settings = await ref.watch(SettingsController.provider.future);
|
||||||
|
|
||||||
|
/// A tile for a [ShowAndSend] setting, saving the settings from [update].
|
||||||
|
Widget showAndSendTile(
|
||||||
|
String title,
|
||||||
|
String description,
|
||||||
|
IconData icon, {
|
||||||
|
required ShowAndSend value,
|
||||||
|
required Settings Function(ShowAndSend value) update,
|
||||||
|
}) => DialogListTile<ShowAndSend>(
|
||||||
|
icon: Icon(icon),
|
||||||
|
title: title,
|
||||||
|
subtitle: Text(description),
|
||||||
|
value: value,
|
||||||
|
options: ShowAndSend.values,
|
||||||
|
getName: (option) => switch (option) {
|
||||||
|
.showAndSend => "Show and send",
|
||||||
|
.show => "Only show",
|
||||||
|
.neither => "Neither",
|
||||||
|
},
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(SettingsController.provider.notifier)
|
||||||
|
.set(update(value))
|
||||||
|
.onError(showError),
|
||||||
|
);
|
||||||
|
|
||||||
return .new({
|
return .new({
|
||||||
"General": .new([
|
"General": .new([
|
||||||
.new(
|
.new(
|
||||||
|
|
@ -36,11 +75,11 @@ class SettingsSectionsController
|
||||||
icon: Icon(icon),
|
icon: Icon(icon),
|
||||||
title: title,
|
title: title,
|
||||||
subtitle: Text(description),
|
subtitle: Text(description),
|
||||||
initialValue: settings.theme,
|
value: settings.theme,
|
||||||
options: ThemeMode.values,
|
options: ThemeMode.values,
|
||||||
getName: (option) => toBeginningOfSentenceCase(option.name),
|
getName: (option) => toBeginningOfSentenceCase(option.name),
|
||||||
onChanged: (value) => ref
|
onChanged: (value) => ref
|
||||||
.watch(SettingsController.provider.notifier)
|
.read(SettingsController.provider.notifier)
|
||||||
.set(settings.copyWith(theme: value))
|
.set(settings.copyWith(theme: value))
|
||||||
.onError(showError),
|
.onError(showError),
|
||||||
),
|
),
|
||||||
|
|
@ -60,21 +99,108 @@ class SettingsSectionsController
|
||||||
Platform.isMacOS ||
|
Platform.isMacOS ||
|
||||||
Platform.isWindows)
|
Platform.isWindows)
|
||||||
? (value) => ref
|
? (value) => ref
|
||||||
.watch(SettingsController.provider.notifier)
|
.read(SettingsController.provider.notifier)
|
||||||
.set(settings.copyWith(useDynamicTheming: value))
|
.set(settings.copyWith(useDynamicTheming: value))
|
||||||
.onError(showError)
|
.onError(showError)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
.new(
|
||||||
|
title: "Name Colors",
|
||||||
|
description: "Color names by power level, so moderators and admins stand out, or by user, so everyone has their own.",
|
||||||
|
icon: Icons.format_color_text,
|
||||||
|
builder: (title, description, icon) => DialogListTile<NameColor>(
|
||||||
|
icon: Icon(icon),
|
||||||
|
title: title,
|
||||||
|
subtitle: Text(description),
|
||||||
|
value: settings.nameColor,
|
||||||
|
options: NameColor.values,
|
||||||
|
getName: (option) => switch (option) {
|
||||||
|
.powerLevel => "By power level",
|
||||||
|
.user => "By user",
|
||||||
|
},
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(SettingsController.provider.notifier)
|
||||||
|
.set(settings.copyWith(nameColor: value))
|
||||||
|
.onError(showError),
|
||||||
|
),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
.new(
|
.new(
|
||||||
title: "Behavior",
|
title: "Behavior",
|
||||||
icon: Icons.psychology,
|
icon: Icons.psychology,
|
||||||
settings: .new([
|
settings: .new([
|
||||||
|
.new(
|
||||||
|
title: "All Rooms In Home",
|
||||||
|
description: "Show all rooms in home, instead of just ones that aren't in spaces",
|
||||||
|
icon: Icons.home,
|
||||||
|
builder: (title, description, icon) => SwitchListTile(
|
||||||
|
title: Text(title),
|
||||||
|
subtitle: Text(description),
|
||||||
|
secondary: Icon(icon),
|
||||||
|
value: settings.showAllInHome,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(SettingsController.provider.notifier)
|
||||||
|
.set(settings.copyWith(showAllInHome: value))
|
||||||
|
.onError(showError),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
.new(
|
||||||
|
title: "Room Ordering",
|
||||||
|
description: "Sort rooms by recent activity, optionally showing mentioned and unread rooms first.",
|
||||||
|
icon: Icons.sort,
|
||||||
|
builder: (title, description, icon) => DialogListTile<RoomOrder>(
|
||||||
|
icon: Icon(icon),
|
||||||
|
title: title,
|
||||||
|
subtitle: Text(description),
|
||||||
|
value: settings.roomOrder,
|
||||||
|
options: RoomOrder.values,
|
||||||
|
getName: (option) => switch (option) {
|
||||||
|
.unreadFirst => "Mentioned, then unread",
|
||||||
|
.recentActivity => "Recent activity only",
|
||||||
|
},
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(SettingsController.provider.notifier)
|
||||||
|
.set(settings.copyWith(roomOrder: value))
|
||||||
|
.onError(showError),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
.new(
|
||||||
|
title: "Swipe To Reply",
|
||||||
|
description: "Swipe a message to the right to reply to it.",
|
||||||
|
icon: Icons.swipe_right,
|
||||||
|
builder: (title, description, icon) => SwitchListTile(
|
||||||
|
title: Text(title),
|
||||||
|
subtitle: Text(description),
|
||||||
|
secondary: Icon(icon),
|
||||||
|
value: settings.swipeToReply,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(SettingsController.provider.notifier)
|
||||||
|
.set(settings.copyWith(swipeToReply: value))
|
||||||
|
.onError(showError),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!Platform.isAndroid && !Platform.isIOS)
|
||||||
|
.new(
|
||||||
|
title: "Show Minimize Button",
|
||||||
|
description:
|
||||||
|
"Enable this to add a minimize button to the appbar",
|
||||||
|
icon: Icons.minimize,
|
||||||
|
builder: (title, description, icon) => SwitchListTile(
|
||||||
|
title: Text(title),
|
||||||
|
subtitle: Text(description),
|
||||||
|
secondary: Icon(icon),
|
||||||
|
value: settings.showMinimizeButton,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(SettingsController.provider.notifier)
|
||||||
|
.set(settings.copyWith(showMinimizeButton: value))
|
||||||
|
.onError(showError),
|
||||||
|
),
|
||||||
|
),
|
||||||
.new(
|
.new(
|
||||||
title: "Linux Mobile Mode",
|
title: "Linux Mobile Mode",
|
||||||
description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.",
|
description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window, and swiping drawers open.",
|
||||||
icon: Icons.construction,
|
icon: Icons.construction,
|
||||||
builder: (title, description, icon) => SwitchListTile(
|
builder: (title, description, icon) => SwitchListTile(
|
||||||
title: Text(title),
|
title: Text(title),
|
||||||
|
|
@ -83,18 +209,201 @@ class SettingsSectionsController
|
||||||
value: settings.linuxMobileMode,
|
value: settings.linuxMobileMode,
|
||||||
onChanged: Platform.isLinux
|
onChanged: Platform.isLinux
|
||||||
? (value) => ref
|
? (value) => ref
|
||||||
.watch(SettingsController.provider.notifier)
|
.read(SettingsController.provider.notifier)
|
||||||
.set(settings.copyWith(linuxMobileMode: value))
|
.set(settings.copyWith(linuxMobileMode: value))
|
||||||
.onError(showError)
|
.onError(showError)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
.new(
|
||||||
|
title: "Hide Membership Events",
|
||||||
|
description: "Hides membership events (invites, joins, leaves & bans) from appearing in conversations. Knocks are still shown.",
|
||||||
|
icon: Icons.person_off,
|
||||||
|
builder: (title, description, icon) => SwitchListTile(
|
||||||
|
title: Text(title),
|
||||||
|
subtitle: Text(description),
|
||||||
|
secondary: Icon(icon),
|
||||||
|
value: settings.hideMembershipEvents,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(SettingsController.provider.notifier)
|
||||||
|
.set(settings.copyWith(hideMembershipEvents: value))
|
||||||
|
.onError(showError),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
.new(
|
||||||
|
title: "Hide Server ACL Events",
|
||||||
|
description: "Hides server ACL (server ban list) changes from appearing in conversations.",
|
||||||
|
icon: Icons.dns,
|
||||||
|
builder: (title, description, icon) => SwitchListTile(
|
||||||
|
title: Text(title),
|
||||||
|
subtitle: Text(description),
|
||||||
|
secondary: Icon(icon),
|
||||||
|
value: settings.hideServerAclEvents,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(SettingsController.provider.notifier)
|
||||||
|
.set(settings.copyWith(hideServerAclEvents: value))
|
||||||
|
.onError(showError),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
.new(
|
||||||
|
title: "Privacy",
|
||||||
|
icon: Icons.privacy_tip,
|
||||||
|
settings: .new([
|
||||||
|
.new(
|
||||||
|
title: "Typing Indicators",
|
||||||
|
description: "Show when others are typing, and let others see when you're typing.",
|
||||||
|
icon: Icons.keyboard,
|
||||||
|
builder: (title, description, icon) => showAndSendTile(
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
value: settings.typingIndicators,
|
||||||
|
update: (value) => settings.copyWith(typingIndicators: value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
.new(
|
||||||
|
title: "Read Receipts",
|
||||||
|
description: "Show how far others have read, and let others see what you've read. When not sent, read receipts are still sent privately, so your other devices know what you've read.",
|
||||||
|
icon: Icons.done_all,
|
||||||
|
builder: (title, description, icon) => showAndSendTile(
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
value: settings.readReceipts,
|
||||||
|
update: (value) => settings.copyWith(readReceipts: value),
|
||||||
|
),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
if (ref.watch(ClientStateController.provider)?.isLoggedIn == true)
|
if (ref.watch(ClientStateController.provider) case ClientState(
|
||||||
|
isLoggedIn: true,
|
||||||
|
:final userId?,
|
||||||
|
))
|
||||||
"Account": .new([
|
"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(
|
.new(
|
||||||
title: "Safety",
|
title: "Safety",
|
||||||
icon: Icons.gpp_good,
|
icon: Icons.gpp_good,
|
||||||
|
|
@ -111,7 +420,7 @@ class SettingsSectionsController
|
||||||
icon: Icon(icon),
|
icon: Icon(icon),
|
||||||
title: title,
|
title: title,
|
||||||
subtitle: Text(description),
|
subtitle: Text(description),
|
||||||
initialValue: ref
|
value: ref
|
||||||
.watch(AccountDataController.provider)
|
.watch(AccountDataController.provider)
|
||||||
.invitePermissionConfig
|
.invitePermissionConfig
|
||||||
.defaultAction,
|
.defaultAction,
|
||||||
|
|
@ -132,7 +441,7 @@ class SettingsSectionsController
|
||||||
onChanged: specVersionsResponse.maybeWhen(
|
onChanged: specVersionsResponse.maybeWhen(
|
||||||
data: (_) =>
|
data: (_) =>
|
||||||
(value) => ref
|
(value) => ref
|
||||||
.watch(ClientController.provider.notifier)
|
.read(ClientController.provider.notifier)
|
||||||
.setAccountData(
|
.setAccountData(
|
||||||
.new(
|
.new(
|
||||||
type: AccountData.invitePermissionConfigKey,
|
type: AccountData.invitePermissionConfigKey,
|
||||||
|
|
@ -155,6 +464,21 @@ class SettingsSectionsController
|
||||||
title: "Other",
|
title: "Other",
|
||||||
icon: Icons.key,
|
icon: Icons.key,
|
||||||
settings: .new([
|
settings: .new([
|
||||||
|
.new(
|
||||||
|
title: "Reset Encryption Identity",
|
||||||
|
description: "Replace your encryption identity and recovery key with new ones, if you've lost your recovery key or your encryption is broken.",
|
||||||
|
builder: (title, description, icon) => Builder(
|
||||||
|
builder: (context) => M3EButton.icon(
|
||||||
|
onPressed: () => context.showAdaptiveSheet(
|
||||||
|
(_) => const ResetEncryptionSheet(),
|
||||||
|
),
|
||||||
|
label: Text(title),
|
||||||
|
icon: Icon(icon),
|
||||||
|
tooltip: description,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: Icons.key_off,
|
||||||
|
),
|
||||||
.new(
|
.new(
|
||||||
title: "Log Out",
|
title: "Log Out",
|
||||||
description:
|
description:
|
||||||
|
|
@ -164,14 +488,21 @@ class SettingsSectionsController
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return M3EButton.icon(
|
return M3EButton.icon(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
Navigator.of(context)
|
final container = ProviderScope.containerOf(context);
|
||||||
.popUntil((route) => route.isFirst);
|
|
||||||
|
|
||||||
await WidgetsBinding.instance.endOfFrame;
|
final route = ModalRoute.of(context)!;
|
||||||
|
Navigator.of(context).popUntil((r) => r.isFirst);
|
||||||
|
await route.completed;
|
||||||
|
|
||||||
await ref
|
await container
|
||||||
.watch(ClientController.provider.notifier)
|
.read(UnifiedPushController.provider.notifier)
|
||||||
.logout();
|
.deregister()
|
||||||
|
.onError(showError);
|
||||||
|
container.invalidate(InitCompleteController.provider);
|
||||||
|
await container
|
||||||
|
.read(ClientController.provider.notifier)
|
||||||
|
.logout()
|
||||||
|
.onError(showError);
|
||||||
},
|
},
|
||||||
label: Text(title),
|
label: Text(title),
|
||||||
icon: Icon(icon),
|
icon: Icon(icon),
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:shared_preferences/shared_preferences.dart";
|
import "package:shared_preferences/shared_preferences.dart";
|
||||||
|
|
||||||
class SharedPrefsController extends AsyncNotifier<SharedPreferences> {
|
class SharedPrefsController extends Notifier<SharedPreferencesAsync> {
|
||||||
@override
|
@override
|
||||||
Future<SharedPreferences> build() async => .getInstance();
|
SharedPreferencesAsync build() => SharedPreferencesAsync();
|
||||||
|
|
||||||
static final provider =
|
static final provider =
|
||||||
AsyncNotifierProvider<SharedPrefsController, SharedPreferences>(
|
NotifierProvider<SharedPrefsController, SharedPreferencesAsync>(
|
||||||
SharedPrefsController.new,
|
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:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/account_data.dart";
|
import "package:nexus/controllers/account_data.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
import "package:nexus/controllers/rooms.dart";
|
||||||
|
import "package:nexus/controllers/settings.dart";
|
||||||
import "package:nexus/controllers/top_level_spaces.dart";
|
import "package:nexus/controllers/top_level_spaces.dart";
|
||||||
import "package:nexus/controllers/space_edges.dart";
|
import "package:nexus/controllers/space_edges.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
|
import "package:nexus/models/room_metadata.dart";
|
||||||
import "package:nexus/models/space.dart";
|
import "package:nexus/models/space.dart";
|
||||||
import "package:nexus/models/subspace.dart";
|
import "package:nexus/models/subspace.dart";
|
||||||
|
|
||||||
class SpacesController extends Notifier<IList<Space>> {
|
class SpacesController extends AsyncNotifier<IList<Space>> {
|
||||||
@override
|
@override
|
||||||
IList<Space> build() {
|
Future<IList<Space>> build() async {
|
||||||
final rooms = ref.watch(RoomsController.provider);
|
final rooms = ref.watch(RoomsController.provider);
|
||||||
final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider);
|
final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider);
|
||||||
final spaceEdges = ref.watch(SpaceEdgesController.provider);
|
final spaceEdges = ref.watch(SpaceEdgesController.provider);
|
||||||
final accountData = ref.watch(AccountDataController.provider);
|
final accountData = ref.watch(AccountDataController.provider);
|
||||||
|
|
||||||
final childrenById = {
|
final childrenById = spaceEdges.map(
|
||||||
for (final entry in spaceEdges.entries)
|
(spaceId, edges) =>
|
||||||
entry.key: entry.value.map((e) => e.childId).toList(),
|
MapEntry(spaceId, edges.map((edge) => edge.childId).toList()),
|
||||||
};
|
);
|
||||||
|
|
||||||
Set<String> collectDescendants(String startId) {
|
/// Everything under [spaceId], however deep, skipping any already
|
||||||
final visited = <String>{};
|
/// [visited] so cycles end.
|
||||||
final stack = [startId];
|
ISet<String> collectDescendants(
|
||||||
|
String spaceId, [
|
||||||
while (stack.isNotEmpty) {
|
ISet<String> visited = const .empty(),
|
||||||
final current = stack.removeLast();
|
]) => (childrenById[spaceId] ?? const []).fold(
|
||||||
final children = childrenById[current] ?? const [];
|
visited,
|
||||||
|
(visited, childId) => visited.contains(childId)
|
||||||
for (final child in children) {
|
? visited
|
||||||
if (visited.add(child)) {
|
: collectDescendants(childId, visited.add(childId)),
|
||||||
stack.add(child);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return visited;
|
|
||||||
}
|
|
||||||
|
|
||||||
Space buildSpace(String spaceId) {
|
Space buildSpace(String spaceId) {
|
||||||
final space = rooms[spaceId];
|
final space = rooms[spaceId];
|
||||||
final directChildrenIds = childrenById[spaceId] ?? const [];
|
final directChildrenIds = childrenById[spaceId] ?? const [];
|
||||||
|
|
||||||
final directRooms = <Room>[];
|
|
||||||
final subSpaces = <Subspace>[];
|
|
||||||
|
|
||||||
for (final childId in directChildrenIds) {
|
|
||||||
final room = rooms[childId];
|
|
||||||
if (room == null) continue;
|
|
||||||
|
|
||||||
if (childrenById.containsKey(childId)) {
|
|
||||||
final descendants = collectDescendants(childId);
|
|
||||||
|
|
||||||
subSpaces.add(
|
|
||||||
.new(
|
|
||||||
room: room,
|
|
||||||
children: .new(descendants.map((id) => rooms[id]).nonNulls),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
directRooms.add(room);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return .new(
|
return .new(
|
||||||
id: spaceId,
|
id: spaceId,
|
||||||
room: space,
|
room: space,
|
||||||
title: space?.metadata?.name ?? "Unnamed Space",
|
title: space?.metadata?.name ?? "Unnamed Space",
|
||||||
children: .new(directRooms),
|
children: .new(
|
||||||
subSpaces: .new(subSpaces),
|
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 = {
|
final usedRooms = spaces
|
||||||
for (final space in spaces) ...[
|
.expand(
|
||||||
...space.children.map((r) => r.metadata?.id),
|
(space) => [
|
||||||
...space.subSpaces.expand((s) => s.children.map((r) => r.metadata?.id)),
|
...space.children,
|
||||||
|
...space.subSpaces.expand((s) => s.children),
|
||||||
],
|
],
|
||||||
}.nonNulls.toISet();
|
)
|
||||||
|
.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
|
final otherRooms = rooms.entries
|
||||||
.where(
|
.where(
|
||||||
|
|
@ -97,6 +100,15 @@ class SpacesController extends Notifier<IList<Space>> {
|
||||||
.toIList();
|
.toIList();
|
||||||
|
|
||||||
final homeRooms = otherRooms
|
final homeRooms = otherRooms
|
||||||
|
.addAll(
|
||||||
|
await ref.watch(
|
||||||
|
SettingsController.provider.selectAsync(
|
||||||
|
(data) => data.showAllInHome,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
? usedRooms
|
||||||
|
: .empty(),
|
||||||
|
)
|
||||||
.where((r) => !directMessages.contains(r.metadata?.id))
|
.where((r) => !directMessages.contains(r.metadata?.id))
|
||||||
.toIList();
|
.toIList();
|
||||||
|
|
||||||
|
|
@ -122,28 +134,38 @@ class SpacesController extends Notifier<IList<Space>> {
|
||||||
...spaces,
|
...spaces,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
final roomOrder = await ref.watch(
|
||||||
|
SettingsController.provider.selectAsync((data) => data.roomOrder),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mentioned -> Unread -> Other
|
||||||
|
int unreadPriority(Room room) => switch (room.metadata) {
|
||||||
|
RoomMetadata(unreadHighlights: > 0) => 0,
|
||||||
|
RoomMetadata(unreadNotifications: > 0) ||
|
||||||
|
RoomMetadata(unreadMessages: > 0) => 1,
|
||||||
|
_ => 2,
|
||||||
|
};
|
||||||
|
|
||||||
return allSpaces
|
return allSpaces
|
||||||
.map(
|
.map(
|
||||||
(space) => space.copyWith(
|
(space) => space.copyWith(
|
||||||
children: .new(
|
children: space.children.sort((a, b) {
|
||||||
space.children
|
if (roomOrder == .unreadFirst) {
|
||||||
.sortedBy(
|
final byUnread = unreadPriority(a).compareTo(unreadPriority(b));
|
||||||
(element) =>
|
if (byUnread != 0) return byUnread;
|
||||||
element
|
}
|
||||||
.metadata
|
|
||||||
?.sortingTimestamp
|
// Most recent activity first
|
||||||
.millisecondsSinceEpoch ??
|
return (b.metadata?.sortingTimestamp ?? DateTime(0)).compareTo(
|
||||||
0,
|
a.metadata?.sortingTimestamp ?? DateTime(0),
|
||||||
)
|
);
|
||||||
.sortedBy((room) => room.metadata?.unreadMessages ?? 0)
|
}),
|
||||||
.reversed,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toIList();
|
.toIList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = NotifierProvider<SpacesController, IList<Space>>(
|
static final provider = AsyncNotifierProvider<SpacesController, IList<Space>>(
|
||||||
SpacesController.new,
|
SpacesController.new,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import "package:nexus/models/spec_versions_response.dart";
|
||||||
class SpecVersionsController extends AsyncNotifier<SpecVersionsResponse> {
|
class SpecVersionsController extends AsyncNotifier<SpecVersionsResponse> {
|
||||||
@override
|
@override
|
||||||
Future<SpecVersionsResponse> build() =>
|
Future<SpecVersionsResponse> build() =>
|
||||||
ref.watch(ClientController.provider.notifier).getSpecVersions();
|
ref.read(ClientController.provider.notifier).getSpecVersions();
|
||||||
|
|
||||||
static final provider =
|
static final provider =
|
||||||
AsyncNotifierProvider.autoDispose<
|
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 {
|
try {
|
||||||
return await ref
|
return await ref
|
||||||
.watch(ClientController.provider.notifier)
|
.read(ClientController.provider.notifier)
|
||||||
.getUrlPreview(url);
|
.getUrlPreview(url);
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
debugPrintStack(label: error.toString(), stackTrace: stackTrace);
|
debugPrintStack(label: error.toString(), stackTrace: stackTrace);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import "dart:async";
|
import "dart:async";
|
||||||
|
|
||||||
import "package:collection/collection.dart";
|
import "package:collection/collection.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/members.dart";
|
import "package:nexus/controllers/members.dart";
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,48 @@
|
||||||
import "package:collection/collection.dart";
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client_state.dart";
|
import "package:nexus/controllers/client_state.dart";
|
||||||
import "package:nexus/models/content/content.dart";
|
import "package:nexus/models/content/content.dart";
|
||||||
import "package:nexus/models/content/membership.dart";
|
import "package:nexus/models/content/membership.dart";
|
||||||
import "package:nexus/models/content/power_levels.dart";
|
import "package:nexus/models/content/power_levels.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
|
|
||||||
class ViaController(final Room room) extends Notifier<String> {
|
class ViaController(final Room room) extends Notifier<String> {
|
||||||
@override
|
@override
|
||||||
String build() {
|
String build() {
|
||||||
final 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 joinedMembers =
|
||||||
final server = userId?.split(":").lastOrNull;
|
(room.state[EventType.membership.type]?.values ?? const [])
|
||||||
if (server != null) {
|
.map(
|
||||||
servers.add(server);
|
(rowId) => switch (room.events[rowId]) {
|
||||||
}
|
Event(
|
||||||
}
|
stateKey: final userId?,
|
||||||
|
content: MembershipContent(status: .join),
|
||||||
|
) =>
|
||||||
|
userId,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.nonNulls
|
||||||
|
.toIList();
|
||||||
|
|
||||||
addUserId(ref.watch(ClientStateController.provider)?.userId);
|
final servers = [?ref.watch(ClientStateController.provider)?.userId]
|
||||||
|
.toIList()
|
||||||
final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""];
|
.addAll(powerLevelUsers)
|
||||||
final powerLevels = powerLevelsEventId == null
|
.addAll(joinedMembers)
|
||||||
? null
|
.map((userId) => userId.substring(userId.indexOf(":") + 1))
|
||||||
: room.events[powerLevelsEventId];
|
.whereNoDuplicates()
|
||||||
|
.take(5)
|
||||||
if (powerLevels?.content case PowerLevelsContent(:final users)) {
|
.toISet();
|
||||||
for (final userId in users.keys) {
|
|
||||||
addUserId(userId);
|
|
||||||
if (servers.length >= 5) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final members = room.state[EventType.membership.type]?.values.toIList();
|
|
||||||
for (var i = 0; servers.length < 5; i++) {
|
|
||||||
final membershipEventId = members?.getOrNull(i);
|
|
||||||
final member = membershipEventId == null
|
|
||||||
? null
|
|
||||||
: room.events[membershipEventId];
|
|
||||||
|
|
||||||
if (member?.content case MembershipContent(:final status)) {
|
|
||||||
if (status == .join) {
|
|
||||||
addUserId(member?.stateKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (members?.getOrNull(i) == null) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return servers.isEmpty
|
return servers.isEmpty
|
||||||
? ""
|
? ""
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,12 @@ extension BetterWhen<T> on AsyncValue<T> {
|
||||||
required Widget Function(T value) data,
|
required Widget Function(T value) data,
|
||||||
Widget Function() loading = Loading.new,
|
Widget Function() loading = Loading.new,
|
||||||
bool skipLoadingOnRefresh = false,
|
bool skipLoadingOnRefresh = false,
|
||||||
|
bool skipLoadingOnReload = false,
|
||||||
}) => when(
|
}) => when(
|
||||||
data: data,
|
data: data,
|
||||||
error: (error, stackTrace) => ErrorDialog(error, stackTrace),
|
error: (error, stackTrace) => ErrorDialog(error, stackTrace),
|
||||||
loading: loading,
|
loading: loading,
|
||||||
skipLoadingOnRefresh: skipLoadingOnRefresh,
|
skipLoadingOnRefresh: skipLoadingOnRefresh,
|
||||||
|
skipLoadingOnReload: skipLoadingOnReload,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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